diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fcb9b3..1149bc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ versioning: [SemVer](https://semver.org/spec/v2.0.0.html). ### Added +- **Prompt history + undo/redo** — `Up`/`Down` in the prompt recall previously + submitted messages, shell-style: recall triggers only at the editor's + vertical edges (`Up` on the first row, `Down` on the last) so multi-line + cursor movement is unaffected, and stepping back down past the newest entry + restores your in-progress draft. History is de-duplicated and capped. + `Ctrl+Z` / `Ctrl+Y` undo / redo prompt edits. + - **Syntax-highlighted code blocks** — fenced code in assistant markdown is now highlighted by language via `syntect` (pure-Rust `fancy-regex` engine, no C toolchain). Common LLM fence tags (`rust`, `python`, `ts`, `bash`, …) are diff --git a/README.md b/README.md index 0b66711..d394622 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ cargo test -p codeoid-tui state:: # reducer tests (no Tokio, no Ratatui) | `Enter` | Send prompt | | `Shift+Enter` / `Ctrl+J` | Newline in prompt | | `Tab` (in prompt) | Autocomplete — slash-command after `/`, or a file after `@` (fuzzy-ranked; directories keep drilling) | +| `↑` / `↓` (in prompt) | Recall previous / next submitted prompt (at the editor's top / bottom edge) | +| `Ctrl+Z` / `Ctrl+Y` | Undo / redo prompt edits | | `←` / `→`, `p` / `n` | Prev / next session | | `y` / `d` | Approve / deny pending tool | | `m` | Cycle execution mode | diff --git a/crates/codeoid-tui/src/app.rs b/crates/codeoid-tui/src/app.rs index f82768b..5e6661e 100644 --- a/crates/codeoid-tui/src/app.rs +++ b/crates/codeoid-tui/src/app.rs @@ -318,10 +318,31 @@ impl App { && matches!(key.code, crossterm::event::KeyCode::Tab) && state.active_mention().is_some(); + // Up/Down recall submitted prompts, but only at the vertical + // edges of the editor so multi-line cursor movement still works + // (Up on the first row, Down on the last). Also runtime- + // conditional — it depends on the cursor row and history state. + let plain_key = key.modifiers.is_empty(); + let (cursor_row, _) = state.prompt.cursor(); + let last_row = state.prompt.lines().len().saturating_sub(1); + let recall_ok = prompt_focused && !command_mode && !modal_open && plain_key; + let history_prev = recall_ok + && matches!(key.code, crossterm::event::KeyCode::Up) + && cursor_row == 0 + && !state.prompt_history.is_empty(); + let history_next = recall_ok + && matches!(key.code, crossterm::event::KeyCode::Down) + && cursor_row == last_row + && state.history_index.is_some(); + let action = if esc_interrupts { Some(crate::keymap::Action::Interrupt) } else if mention_tab { Some(crate::keymap::Action::AutocompleteCommand) + } else if history_prev { + Some(crate::keymap::Action::HistoryPrev) + } else if history_next { + Some(crate::keymap::Action::HistoryNext) } else { resolve(key, prompt_focused, modal_kind, command_mode) }; @@ -416,6 +437,14 @@ impl App { Action::AutocompleteCommand => { autocomplete(state); } + Action::UndoPrompt => { + state.prompt.undo(); + } + Action::RedoPrompt => { + state.prompt.redo(); + } + Action::HistoryPrev => state.history_prev(), + Action::HistoryNext => state.history_next(), Action::NextSession => { state.sessions.focus_next(); state.scroll_to_bottom(); diff --git a/crates/codeoid-tui/src/keymap.rs b/crates/codeoid-tui/src/keymap.rs index 003f435..df7cd48 100644 --- a/crates/codeoid-tui/src/keymap.rs +++ b/crates/codeoid-tui/src/keymap.rs @@ -13,6 +13,14 @@ pub enum Action { SubmitPrompt, NewlineInPrompt, AutocompleteCommand, + /// Undo the last prompt edit (Ctrl+Z). + UndoPrompt, + /// Redo the last undone prompt edit (Ctrl+Y). + RedoPrompt, + /// Recall the previous (older) submitted prompt into the editor. + HistoryPrev, + /// Recall the next (newer) submitted prompt, or restore the live draft. + HistoryNext, NextSession, PrevSession, Interrupt, @@ -257,6 +265,10 @@ pub fn resolve( (Enter, m) if m.contains(KeyModifiers::SHIFT) => Some(Action::NewlineInPrompt), (Char('j'), KeyModifiers::CONTROL) => Some(Action::NewlineInPrompt), + // Undo / redo of prompt edits. + (Char('z'), KeyModifiers::CONTROL) => Some(Action::UndoPrompt), + (Char('y'), KeyModifiers::CONTROL) => Some(Action::RedoPrompt), + // Global controls — always reachable while typing. (Char('c'), KeyModifiers::CONTROL) => Some(Action::Quit), (Char('x'), KeyModifiers::CONTROL) => Some(Action::Interrupt), @@ -609,6 +621,33 @@ mod tests { ); } + #[test] + fn prompt_ctrl_z_undoes_and_ctrl_y_redoes() { + assert_eq!( + resolve(ctrl(KeyCode::Char('z')), true, ModalKind::None, false), + Some(Action::UndoPrompt) + ); + assert_eq!( + resolve(ctrl(KeyCode::Char('y')), true, ModalKind::None, false), + Some(Action::RedoPrompt) + ); + } + + #[test] + fn prompt_plain_up_down_fall_through_for_runtime_history_gating() { + // History recall is decided in the app reducer (it needs the cursor + // row + history state), so the static keymap must leave plain Up/Down + // unbound in prompt mode — the editor/history handler takes them. + assert_eq!( + resolve(key(KeyCode::Up), true, ModalKind::None, false), + None + ); + assert_eq!( + resolve(key(KeyCode::Down), true, ModalKind::None, false), + None + ); + } + // ------------ nav mode (no prompt focus) ------------ #[test] diff --git a/crates/codeoid-tui/src/state/mod.rs b/crates/codeoid-tui/src/state/mod.rs index 5b9aabb..00c628f 100644 --- a/crates/codeoid-tui/src/state/mod.rs +++ b/crates/codeoid-tui/src/state/mod.rs @@ -13,7 +13,7 @@ pub mod render_cache; pub mod scrollback_build; pub mod sessions; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use codeoid_protocol::{ AuthOkMsg, ModelInfo, ProviderCommand, SessionInfo, SessionUiRequestMsg, UiRequestMethod, @@ -139,6 +139,18 @@ pub struct AppState { /// focused session. `None` means "no explicit selection yet" and /// `Enter` falls back to the most recent tool_call message. pub selected_tool_message_id: Option, + /// Previously submitted prompts, oldest first, for shell-style Up/Down + /// recall. Capped at [`PROMPT_HISTORY_MAX`]; consecutive duplicates are + /// collapsed so hammering the same message doesn't flood the ring. A + /// `VecDeque` so trimming the oldest entry is an O(1) `pop_front`. + pub prompt_history: VecDeque, + /// Position within [`AppState::prompt_history`] while recalling. `None` + /// means the user is editing a live draft (not browsing history); `Some(i)` + /// means the prompt currently mirrors `prompt_history[i]`. + pub history_index: Option, + /// The live draft stashed when the user first steps into history, restored + /// when they step back down past the newest entry. + pub history_draft: Option, } impl std::fmt::Debug for AppState { @@ -170,6 +182,10 @@ pub enum ConnectionState { /// every place that (re)builds the `TextArea` stays in sync. pub const PROMPT_PLACEHOLDER: &str = "Message… Enter sends · Shift+Enter newline · Esc blurs"; +/// Cap on remembered prompts for Up/Down recall. Old entries drop off the +/// front once exceeded — plenty for a session without unbounded growth. +pub const PROMPT_HISTORY_MAX: usize = 200; + /// An `@`-file mention the cursor is currently inside. Char indices are into /// the referenced prompt line; `query` is the partial path after `@`. #[derive(Debug, Clone, PartialEq, Eq)] @@ -198,6 +214,18 @@ pub(crate) fn configure_prompt(prompt: &mut TextArea<'static>) { prompt.set_placeholder_text(PROMPT_PLACEHOLDER); } +/// Build a configured prompt editor holding `text`, cursor at the very end. +/// A free function (not a `&mut self` method) so callers can pass a borrow of +/// another `self` field — e.g. `&self.prompt_history[i]` — without cloning. +fn prompt_from_text(text: &str) -> TextArea<'static> { + let lines: Vec = text.split('\n').map(str::to_owned).collect(); + let mut prompt = TextArea::new(lines); + configure_prompt(&mut prompt); + prompt.move_cursor(tui_textarea::CursorMove::Bottom); + prompt.move_cursor(tui_textarea::CursorMove::End); + prompt +} + impl AppState { #[must_use] pub fn new(auth: AuthOkMsg) -> Self { @@ -233,6 +261,9 @@ impl AppState { verbose_tool_output: false, expanded_tool_message_ids: HashSet::new(), selected_tool_message_id: None, + prompt_history: VecDeque::new(), + history_index: None, + history_draft: None, } } @@ -327,12 +358,14 @@ impl AppState { } /// Drain the prompt into a `String` and reset the editor. Returns - /// `None` if the editor was empty (or whitespace-only). + /// `None` if the editor was empty (or whitespace-only). Records the + /// submitted text in the recall history and resets history navigation. pub fn take_prompt(&mut self) -> Option { let text = self.prompt.lines().join("\n"); if text.trim().is_empty() { return None; } + self.record_history(&text); // TextArea doesn't have a clear() method; re-initialize. let mut fresh = TextArea::default(); configure_prompt(&mut fresh); @@ -340,6 +373,58 @@ impl AppState { Some(text) } + /// Push a submitted prompt onto the recall history (skipping a repeat of + /// the newest entry) and reset any in-flight history navigation. + fn record_history(&mut self, text: &str) { + if self.prompt_history.back().map(String::as_str) != Some(text) { + self.prompt_history.push_back(text.to_owned()); + // We only ever add one at a time, so a single pop keeps the cap. + while self.prompt_history.len() > PROMPT_HISTORY_MAX { + self.prompt_history.pop_front(); + } + } + self.history_index = None; + self.history_draft = None; + } + + /// Recall the previous (older) prompt from history. On first step it stashes + /// the current live draft so [`AppState::history_next`] can restore it. + /// No-op when history is empty. + pub fn history_prev(&mut self) { + if self.prompt_history.is_empty() { + return; + } + let index = match self.history_index { + None => { + self.history_draft = Some(self.prompt.lines().join("\n")); + self.prompt_history.len() - 1 + } + Some(0) => 0, // already at the oldest entry + Some(i) => i - 1, + }; + self.history_index = Some(index); + // Borrow the entry directly (no clone): `prompt_from_text` doesn't + // touch `self`, so the immutable borrow of `prompt_history` ends + // before the assignment to `self.prompt`. + self.prompt = prompt_from_text(&self.prompt_history[index]); + } + + /// Recall the next (newer) prompt from history; stepping past the newest + /// entry restores the stashed live draft. No-op when not browsing history. + pub fn history_next(&mut self) { + let Some(i) = self.history_index else { + return; + }; + if i + 1 < self.prompt_history.len() { + self.history_index = Some(i + 1); + self.prompt = prompt_from_text(&self.prompt_history[i + 1]); + } else { + self.history_index = None; + let draft = self.history_draft.take().unwrap_or_default(); + self.prompt = prompt_from_text(&draft); + } + } + #[must_use] pub fn prompt_is_empty(&self) -> bool { self.prompt.lines().iter().all(|l| l.is_empty()) @@ -1737,4 +1822,93 @@ mod tests { state.replace_mention(&m, "src/", false); assert_eq!(state.prompt.lines()[0], "open @src/"); } + + fn prompt_text(state: &AppState) -> String { + state.prompt.lines().join("\n") + } + + #[test] + fn take_prompt_records_history_and_dedups() { + let mut state = mk_state(); + state.prompt.insert_str("first"); + assert_eq!(state.take_prompt().as_deref(), Some("first")); + state.prompt.insert_str("first"); // exact repeat — should not double up + state.take_prompt(); + state.prompt.insert_str("second"); + state.take_prompt(); + assert_eq!( + state.prompt_history.iter().collect::>(), + vec!["first", "second"] + ); + } + + #[test] + fn take_prompt_ignores_blank() { + let mut state = mk_state(); + state.prompt.insert_str(" "); + assert!(state.take_prompt().is_none()); + assert!(state.prompt_history.is_empty()); + } + + #[test] + fn history_prev_walks_backwards_then_pins_at_oldest() { + let mut state = mk_state(); + for msg in ["one", "two", "three"] { + state.prompt.insert_str(msg); + state.take_prompt(); + } + state.history_prev(); + assert_eq!(prompt_text(&state), "three"); + state.history_prev(); + assert_eq!(prompt_text(&state), "two"); + state.history_prev(); + assert_eq!(prompt_text(&state), "one"); + state.history_prev(); // already oldest — stays put + assert_eq!(prompt_text(&state), "one"); + } + + #[test] + fn history_next_restores_live_draft_past_newest() { + let mut state = mk_state(); + state.prompt.insert_str("sent"); + state.take_prompt(); + // Start a new draft, then browse into history and back out. + state.prompt.insert_str("draft in progress"); + state.history_prev(); + assert_eq!(prompt_text(&state), "sent"); + state.history_next(); + assert_eq!(prompt_text(&state), "draft in progress"); + assert!(state.history_index.is_none()); + } + + #[test] + fn history_next_is_noop_when_not_browsing() { + let mut state = mk_state(); + state.prompt.insert_str("sent"); + state.take_prompt(); + state.prompt.insert_str("live"); + state.history_next(); // not in history — must not touch the draft + assert_eq!(prompt_text(&state), "live"); + } + + #[test] + fn history_prev_noop_without_history() { + let mut state = mk_state(); + state.prompt.insert_str("typing"); + state.history_prev(); + assert_eq!(prompt_text(&state), "typing"); + } + + #[test] + fn history_is_capped() { + let mut state = mk_state(); + for i in 0..(PROMPT_HISTORY_MAX + 5) { + state.prompt.insert_str(format!("msg{i}")); + state.take_prompt(); + } + assert_eq!(state.prompt_history.len(), PROMPT_HISTORY_MAX); + // Oldest entries dropped off the front; newest retained. + assert_eq!(state.prompt_history.back().unwrap(), "msg204"); + assert_eq!(state.prompt_history.front().unwrap(), "msg5"); + } }