diff --git a/Cargo.lock b/Cargo.lock index cda357f..78f5040 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1284,6 +1284,7 @@ dependencies = [ "crossterm_winapi", "derive_more", "document-features", + "filedescriptor", "futures-core", "mio", "parking_lot", @@ -1634,6 +1635,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.29" diff --git a/Cargo.toml b/Cargo.toml index ba398ed..7a953d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,10 @@ uuid = { version = "=1.26.0", features = ["v5"] } zip = { version = "=8.6.0", default-features = false, features = ["deflate-flate2-zlib-rs"] } zeroize = { version = "=1.9.0", features = ["derive"] } +# Level-triggered terminal polling avoids false quiet with unread Linux input. +[target.'cfg(target_os = "linux")'.dependencies] +crossterm = { version = "=0.29.0", features = ["use-dev-tty"] } + [target.'cfg(unix)'.dependencies] libc = "=0.2.189" diff --git a/docs/user/tui-and-sessions.md b/docs/user/tui-and-sessions.md index 874b2f2..1dee5df 100644 --- a/docs/user/tui-and-sessions.md +++ b/docs/user/tui-and-sessions.md @@ -41,8 +41,9 @@ Use the `artifact` tool to read spilled output, including memory-only artifacts; | --- | --- | | `Enter` | Send a non-empty prompt when idle; steer and finish the current response while active if the agent advertises that capability | | `Shift+Enter`, `Option+Enter`, `Ctrl+J` | Insert a newline | -| `Esc` | Cancel a pending-message edit and restore the previous draft; leave the queue selector; otherwise interrupt a running turn or dismiss an idle notice | +| `Esc` | Close the transcript navigator; cancel a pending-message edit and restore the previous draft; leave the queue selector; otherwise interrupt a running turn or dismiss an idle notice | | `F2` | Focus the pending-message queue (or return to the composer) | +| `F3` | Open or close the read-only transcript navigator, including while streaming | | `Up` / `Down`, `Enter`, `Backspace` / `Delete` in the queue | Select a pending message, edit it if supported, or remove it | | `Command+B` | Move the newest running foreground top-level compose call to the background | | `Ctrl+C` | Interrupt a running turn; clear a non-empty idle prompt; quit when idle with an empty prompt | @@ -65,7 +66,30 @@ Use the `artifact` tool to read spilled output, including memory-only artifacts; Pasted text is inserted rather than sent. Bracketed paste is used when available; otherwise Kit treats a rapid key burst as a paste, so returns in that burst become line breaks. This keeps a multiline paste in one prompt. Press plain `Enter` afterward to submit it. -When the session is idle, `Enter` starts a normal prompt. While the agent is active, `Enter` uses ACP v2 `steer` injection with `finish` stream behavior only when the agent advertised both capabilities. An accepted injected user message first appears in the pending queue above the composer. It moves to the transcript when the agent delivers it as part of the current turn. If steering is unavailable, the editor keeps the message and shows `this agent does not support active steering`. Local commands and agent-advertised session commands are available only while idle. +When the session is idle, `Enter` starts a normal prompt. While the agent is active, `Enter` uses ACP v2 `steer` injection with `finish` stream behavior only when the agent advertised both capabilities. An accepted injected user message first appears in the pending queue above the composer. It moves to the transcript when the agent delivers it as part of the current turn. If steering is unavailable, the editor keeps the message and shows `this agent does not support active steering`. Except for the read-only `/transcript` navigator, local commands and agent-advertised session commands are available only while idle. + +### Find and reveal transcript blocks + +Press `F3` to open the read-only transcript navigator, even while a response is streaming. This dedicated shortcut does not replace an editor shortcut and leaves the prompt draft and attachments untouched. You can also type the exact local command `/transcript` and press `Enter`: it opens the navigator, preserves the typed command in the editor, and never sends it to the agent. The command takes no arguments; `/transcript query` is an ordinary prompt instead. + +| Key in the navigator | Action | +| --- | --- | +| Type text | Filter blocks with a case-insensitive substring query | +| `Tab` / `Shift+Tab` | Cycle forward / backward through All, User, Assistant, Thought, and Tool filters | +| `Up` / `Down` | Select the previous / next matching block in chronological order | +| `Ctrl+Up` / `Ctrl+Down` | Select the previous / next User prompt, switching to the User filter while retaining the query | +| `Enter` | Reveal and highlight the selected rendered block in the transcript | +| `Esc` / `F3` | Close without changing transcript scroll position, editor text, or attachments | + +Navigator queries are limited to 4,096 UTF-8 bytes. Oversized pastes keep a bounded whole-grapheme prefix; pasted newlines and tabs are ignored rather than activating navigator controls. + +The terminal input queue retains at most 1,024 events and 256 KiB of paste-string allocations (including spare string capacity). These limits apply across all TUI views, not just the navigator. If either limit is exceeded, Kit discards **all pending input**, shows a persistent input-overflow warning, and continues draining the terminal without applying events. Input already applied is not undone; parked drafts and attachments are not cleared. + +To recover, stop typing/pasting and wait for **Press Esc to resume**, then press plain `Esc`. The reader displays this prompt only after observing at least 100 ms of quiet input; further activity changes it back to **Waiting for quiet input**. Kit consumes that acknowledgement without closing a modal or cancelling a turn. It also discards input until another 100 ms quiet interval after the acknowledgement, so a buffered paste tail cannot become an actionable Enter or Tab. Then input resumes; check your draft before sending. The limits bound Kit's queued events and retained paste payloads, not the terminal library's temporary allocation while decoding a single bracketed-paste event. + +Revealing a Thought block temporarily shows it even when reasoning is hidden. Browsing and closing alone do not move the transcript; `Enter` explicitly reveals the selected block. While the navigator is open, `Esc` closes it rather than interrupting the turn. + +The navigator searches only the currently displayed or replayed history, not a compacted archive or undelivered messages in the pending queue. Tool searches include display text such as the title, script, and output; media labels are searchable, but binary media payloads are not. Block identities are local and ephemeral, not durable addresses for forking. This is navigation only: it does not fork a session, write history, cancel a turn, or send a prompt. ### Edit or remove a pending message @@ -115,7 +139,7 @@ When the agent roster is visible, terminals at least 108 columns wide show the t ## Manage sessions and compact from the TUI -The TUI handles `/new`, `/resume`, `/sessions`, `/close`, `/model`, `/effort`, and `/agents` as exact local slash-command tokens. It also discovers agent commands through ACP and highlights them without interpreting them locally: +The TUI handles `/new`, `/resume`, `/sessions`, `/close`, `/model`, `/effort`, `/agents`, and `/transcript` as exact local slash-command tokens. It also discovers agent commands through ACP and highlights them without interpreting them locally: ```text /new @@ -129,9 +153,10 @@ The TUI handles `/new`, `/resume`, `/sessions`, `/close`, `/model`, `/effort`, a /effort /effort high /agents +/transcript ``` -These local commands are available only while the session is idle. `/agents` toggles the agent roster without starting a model turn. `/new` closes the current session and starts a fresh persisted session. It clears the visible transcript but does not delete or alter the previous session, which remains resumable by its ID. Text following `/new` becomes the new session's first prompt. `/resume ` closes the current session, resumes the requested durable session, and replays its transcript; selecting the already-active ID is a no-op. `/sessions` opens a visible newest-first selector for the same workspace. Up and Down move, Enter uses the existing resume flow, `R` opens an inline rename field, and Esc cancels renaming or closes the dialog. Submit an empty rename and confirm to clear the custom name. After a save, the picker remains open on the selected session and refreshes its displayed name. `/close` closes the current session and exits the TUI. +Except for `/transcript`, which also opens while streaming, these local commands are available only while the session is idle. `/agents` toggles the agent roster without starting a model turn. `/new` closes the current session and starts a fresh persisted session. It clears the visible transcript but does not delete or alter the previous session, which remains resumable by its ID. Text following `/new` becomes the new session's first prompt. `/resume ` closes the current session, resumes the requested durable session, and replays its transcript; selecting the already-active ID is a no-op. `/sessions` opens a visible newest-first selector for the same workspace. Up and Down move, Enter uses the existing resume flow, `R` opens an inline rename field, and Esc cancels renaming or closes the dialog. Submit an empty rename and confirm to clear the custom name. After a save, the picker remains open on the selected session and refreshes its displayed name. `/close` closes the current session and exits the TUI. `/model` opens the model selector. `/effort` opens the advertised ACP reasoning-effort selector; `/effort default|low|medium|high` selects directly. In either dialog, Tab toggles saving the selection to `~/.kit/config.toml`, Enter selects, and Esc closes. Saving `default` removes top-level `reasoning_effort`; other values update it without replacing unrelated TOML. A new or resumed process starts from the resolved CLI/TOML default unless the selection was saved. diff --git a/src/tui/app.rs b/src/tui/app.rs index 0368877..a5f98c2 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -34,6 +34,7 @@ use super::{ command::{self, Command as SlashCommand, Parsed, known_token, parse}, editor::Editor, plan::{PlanNode, parse as parse_plan}, + transcript::{Navigation, Navigator, Role}, wrap::LinkHit, }; @@ -645,6 +646,7 @@ pub struct App { retained_image_source_bytes: usize, next_transcript_revision: u64, transcript_focus_index: Option, + pub(super) navigation: Navigation, pub editor: Editor, pub attachments: Vec, next_attachment: usize, @@ -707,6 +709,9 @@ pub struct App { /// dismissed an older selection, while still allowing it to start a drag. press: Option<(usize, usize, bool)>, pub toast: Option<(String, Instant)>, + /// Terminal queue overload requires acknowledgement before input resumes. + pub input_overflow: bool, + pub input_recovery_ready: bool, /// When the last key arrived, for telling a paste from typing. pub last_key: Option, next_file_search_revision: u64, @@ -893,6 +898,7 @@ impl App { retained_image_source_bytes: 0, next_transcript_revision: 0, transcript_focus_index: None, + navigation: Navigation::default(), editor: Editor::default(), attachments: Vec::new(), next_attachment: 0, @@ -942,6 +948,8 @@ impl App { selection: None, press: None, toast: None, + input_overflow: false, + input_recovery_ready: false, last_key: None, next_file_search_revision: 0, } @@ -1019,6 +1027,7 @@ impl App { _ => None, }; self.blocks.push(block); + self.navigation.push(); self.next_transcript_revision = self.next_transcript_revision.wrapping_add(1); self.transcript_revisions .push(self.next_transcript_revision); @@ -1065,6 +1074,7 @@ impl App { /// Aligns cache bookkeeping for tests and other direct transcript setup. pub(super) fn sync_transcript_cache(&mut self) { + self.navigation.sync(self.blocks.len()); self.transcript_cache.truncate(self.blocks.len()); self.transcript_revisions.truncate(self.blocks.len()); self.transcript_dirty @@ -1980,6 +1990,7 @@ impl App { self.command_completion_query = None; self.command_completion_dismissed = None; self.blocks.clear(); + self.navigation.reset(); self.transcript_cache.clear(); self.transcript_revisions.clear(); self.transcript_dirty.clear(); @@ -2176,6 +2187,7 @@ impl App { } pub fn scroll_by(&mut self, lines: isize) { + self.navigation.anchored = false; self.press = None; let top = self.total_lines.saturating_sub(self.viewport); let current = self.scroll.min(top); @@ -2184,12 +2196,14 @@ impl App { } fn scroll_to_top(&mut self) { + self.navigation.anchored = false; self.press = None; self.follow = false; self.scroll = 0; } pub fn scroll_to_bottom(&mut self) { + self.navigation.anchored = false; self.press = None; self.follow = true; self.scroll = usize::MAX; @@ -2274,6 +2288,11 @@ impl App { if self.model_switch.is_some() { return; } + if let Some(dialog) = &mut self.navigation.dialog { + dialog.insert(text); + self.sync_navigation(); + return; + } // An explicit bracketed paste is not part of the unbracketed key-burst heuristic. self.last_key = None; if let Some(rename) = self @@ -2884,8 +2903,156 @@ impl App { Some(id) } - /// Applies a key press, returning work for the event loop. - pub fn handle_key(&mut self, key: KeyEvent) -> Action { + /// Open a read-only view without borrowing the parked composer's state. + pub(super) fn open_navigation(&mut self) { + self.navigation.sync(self.blocks.len()); + self.navigation.dialog = Some(Navigator { + selected: self.navigation.revealed, + ..Navigator::default() + }); + self.sync_navigation(); + } + + pub(super) fn sync_navigation(&mut self) -> Vec { + let matches = self + .navigation + .matches(&self.blocks, &self.transcript_revisions); + self.navigation.reconcile(&matches); + matches + } + + fn handle_navigation_key(&mut self, key: KeyEvent, pasted: bool) -> Action { + if pasted && matches!(key.code, KeyCode::Enter | KeyCode::Tab) { + // Keep unbracketed paste inside the query, never reveal or cycle + // roles. Its control whitespace is discarded just like Event::Paste. + self.paste(if key.code == KeyCode::Enter { + "\n" + } else { + "\t" + }); + return Action::None; + } + let matches = self.sync_navigation(); + let selected = self + .navigation + .dialog + .as_ref() + .and_then(|dialog| dialog.selected); + let current = selected.and_then(|id| self.navigation.index(id)); + match key.code { + KeyCode::Esc | KeyCode::F(3) => self.navigation.dialog = None, + KeyCode::Enter if key.modifiers.is_empty() => { + if let Some(index) = current { + if let Some(old) = self + .navigation + .revealed + .and_then(|id| self.navigation.index(id)) + { + self.mark_block_dirty(old); + } + self.navigation.revealed = selected; + self.navigation.reveal_pending = true; + self.navigation.anchored = true; + self.mark_block_dirty(index); + self.navigation.dialog = None; + } + } + KeyCode::Up | KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.navigation.dialog.as_mut().unwrap().role = Role::User; + let users = self + .navigation + .matches(&self.blocks, &self.transcript_revisions); + let target = if key.code == KeyCode::Up { + users + .iter() + .rev() + .find(|index| current.is_none_or(|current| **index < current)) + } else { + users + .iter() + .find(|index| current.is_none_or(|current| **index > current)) + } + .copied(); + // At an edge retain the current matching prompt, otherwise use + // the nearest matching prompt. The query is never discarded. + let target = target + .or_else(|| current.filter(|index| users.contains(index))) + .or_else(|| { + if key.code == KeyCode::Up { + users.first() + } else { + users.last() + } + .copied() + }); + self.navigation.dialog.as_mut().unwrap().selected = + target.and_then(|index| self.navigation.id(index)); + } + KeyCode::Up | KeyCode::Down if key.modifiers.is_empty() => { + let position = matches + .iter() + .position(|index| Some(*index) == current) + .unwrap_or(0); + let position = if key.code == KeyCode::Up { + position.saturating_sub(1) + } else { + (position + 1).min(matches.len().saturating_sub(1)) + }; + self.navigation.dialog.as_mut().unwrap().selected = matches + .get(position) + .and_then(|index| self.navigation.id(*index)); + } + KeyCode::Tab | KeyCode::BackTab => { + let dialog = self.navigation.dialog.as_mut().unwrap(); + dialog.role = dialog.role.cycle( + key.code == KeyCode::BackTab || key.modifiers.contains(KeyModifiers::SHIFT), + ); + } + KeyCode::Backspace if key.modifiers.is_empty() => { + self.navigation.dialog.as_mut().unwrap().backspace() + } + KeyCode::Char(character) + if !key.modifiers.intersects( + KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER, + ) => + { + self.navigation + .dialog + .as_mut() + .unwrap() + .insert(&character.to_string()); + } + _ => {} + } + self.sync_navigation(); + Action::None + } + + /// Resolve only after the wrapping cache has current-width prefix offsets. + pub(super) fn apply_navigation_reveal(&mut self, viewport_changed: bool) { + // Resuming follow supersedes an old anchor, but not an explicit reveal. + if self.follow && !self.navigation.reveal_pending { + self.navigation.anchored = false; + return; + } + if !(self.navigation.reveal_pending || viewport_changed && self.navigation.anchored) { + return; + } + if let Some(index) = self + .navigation + .revealed + .and_then(|id| self.navigation.index(id)) + && let Some(prefix) = self.transcript_prefixes.get(index) + { + self.follow = false; + self.scroll = prefix + usize::from(*prefix > 0); + } + self.navigation.reveal_pending = false; + } + + /// Applies a key press using its terminal receipt time, not dispatch time. + /// Synchronous rendering/search must not change inter-key paste gaps. + pub fn handle_key_at(&mut self, key: KeyEvent, received_at: Instant) -> Action { if key.kind != KeyEventKind::Press { return Action::None; } @@ -2924,11 +3091,20 @@ impl App { } return Action::Redraw; } + if self.navigation.dialog.is_some() { + let pasted = self + .last_key + .is_some_and(|last| received_at.saturating_duration_since(last) < PASTE_GAP); + self.last_key = Some(received_at); + return self.handle_navigation_key(key, pasted); + } if self.session_dialog.is_some() { // Terminals without bracketed paste deliver a paste as a key burst, so // the arrival gap is the only thing separating it from typing. - let pasted = self.last_key.is_some_and(|last| last.elapsed() < PASTE_GAP); - self.last_key = Some(Instant::now()); + let pasted = self + .last_key + .is_some_and(|last| received_at.saturating_duration_since(last) < PASTE_GAP); + self.last_key = Some(received_at); return self.handle_session_key(key, pasted); } if self.model_dialog.is_some() { @@ -2937,6 +3113,10 @@ impl App { if self.effort_dialog.is_some() { return self.handle_effort_key(key); } + if key.code == KeyCode::F(3) && key.modifiers.is_empty() { + self.open_navigation(); + return Action::None; + } if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { self.file_picker = None; // A turn that will not stop must still be escapable: the second @@ -3035,8 +3215,10 @@ impl App { } // Terminals without bracketed paste deliver a paste as a key burst, so // the arrival gap is the only thing separating it from typing. - let pasted = self.last_key.is_some_and(|last| last.elapsed() < PASTE_GAP); - self.last_key = Some(Instant::now()); + let pasted = self + .last_key + .is_some_and(|last| received_at.saturating_duration_since(last) < PASTE_GAP); + self.last_key = Some(received_at); let control = key.modifiers.contains(KeyModifiers::CONTROL); let alt = key.modifiers.contains(KeyModifiers::ALT); let shift = key.modifiers.contains(KeyModifiers::SHIFT); @@ -3137,6 +3319,15 @@ impl App { self.toast = None; } KeyCode::Enter if key.modifiers.is_empty() && !pasted => { + // Local and read-only, even while streaming or editing a steer. + // Do not submit/clear the parked composer or its attachments. + if matches!( + parse(self.editor.text(), !self.auth_methods.is_empty()), + Parsed::Transcript + ) { + self.open_navigation(); + return Action::None; + } if self.editor.is_empty() { return Action::None; } @@ -3208,6 +3399,10 @@ impl App { Action::ListSessions } } + Parsed::Transcript => { + self.open_navigation(); + Action::None + } Parsed::Close => Action::Close, Parsed::Agents => { self.toggle_agents(); @@ -3384,7 +3579,7 @@ impl App { } pub fn handle_mouse(&mut self, mouse: MouseEvent) -> Action { - if self.model_switch.is_some() { + if self.model_switch.is_some() || self.navigation.dialog.is_some() { return Action::None; } match mouse.kind { @@ -3737,6 +3932,12 @@ mod tests { }; use crate::{events::RuntimeEvent, file_search::FileMatch, tui::wrap::LinkHit}; + impl App { + pub(crate) fn handle_key(&mut self, key: KeyEvent) -> Action { + self.handle_key_at(key, Instant::now()) + } + } + fn press(code: KeyCode) -> KeyEvent { modified_press(code, KeyModifiers::NONE) } @@ -3896,6 +4097,270 @@ mod tests { assert!(app.model_switch.is_none()); } + #[test] + fn transcript_navigation_receipt_times_survive_slow_processing() { + for deliberate in [false, true] { + for key in [KeyCode::Tab, KeyCode::Enter] { + let mut app = app(); + app.push_block(Block::Agent("matching text".into())); + app.paste("parked draft"); + app.open_navigation(); + let first = Instant::now(); + app.handle_key_at(press(KeyCode::Char('m')), first); + // These are input receipt times, not dispatch times. Both events + // can be queued while the UI is busy rendering or searching. + let gap = if deliberate { + super::PASTE_GAP * 3 + } else { + Duration::ZERO + }; + let received_at = first + gap; + std::thread::sleep(super::PASTE_GAP * 4); + assert!(matches!( + app.handle_key_at(press(key), received_at), + Action::None + )); + assert_eq!(app.editor.text(), "parked draft"); + if deliberate && key == KeyCode::Enter { + assert!(app.navigation.dialog.is_none()); + assert!(app.navigation.revealed.is_some()); + } else { + let dialog = app.navigation.dialog.as_ref().unwrap(); + assert_eq!(dialog.role == super::Role::All, !deliberate); + assert!(app.navigation.revealed.is_none()); + } + if !deliberate { + // A second buffered newline must not submit the parked draft. + assert!(matches!( + app.handle_key_at(press(KeyCode::Enter), received_at), + Action::None + )); + assert!(app.navigation.dialog.is_some()); + assert_eq!(app.editor.text(), "parked draft"); + } + } + } + } + + #[test] + fn transcript_navigation_preserves_streaming_draft_and_attachments() { + let mut app = app(); + app.push_block(Block::Agent("visible history".into())); + app.paste("draft e\u{301} 👩‍💻"); + app.attach( + PathBuf::from("/tmp/image.png"), + "image/png", + AttachmentKind::Image, + 12, + ); + app.editor.move_left(); + let draft = app.editor.text().to_owned(); + let cursor = app.editor.cursor(); + app.phase = Phase::Working; + app.follow = false; + app.scroll = 7; + assert!(matches!(app.handle_key(press(KeyCode::F(3))), Action::None)); + for key in [ + modified_press(KeyCode::Char('c'), KeyModifiers::CONTROL), + modified_press(KeyCode::Char('b'), KeyModifiers::SUPER), + modified_press(KeyCode::Char('k'), KeyModifiers::CONTROL), + ] { + assert!(matches!(app.handle_key(key), Action::None)); + } + app.paste("history"); + assert_eq!(app.sync_navigation(), vec![0]); + assert!(matches!(app.handle_key(press(KeyCode::Esc)), Action::None)); + assert_eq!(app.editor.text(), draft); + assert_eq!(app.editor.cursor(), cursor); + assert_eq!(app.attachments.len(), 1); + assert_eq!(app.scroll, 7); + assert!(!app.follow); + assert!(app.working()); + assert!(app.navigation.revealed.is_none()); + } + + #[test] + fn transcript_command_is_read_only_while_streaming() { + let mut app = app(); + app.phase = Phase::Working; + app.paste("/transcript"); + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::None + )); + assert!(app.navigation.dialog.is_some()); + assert_eq!(app.editor.text(), "/transcript"); + assert!(app.working()); + assert!(app.pending_steers.is_empty()); + assert!(matches!(app.handle_key(press(KeyCode::Esc)), Action::None)); + assert_eq!(app.editor.text(), "/transcript"); + } + + #[test] + fn transcript_navigation_filters_unicode_and_jumps_between_user_prompts() { + let mut app = app(); + app.push_block(Block::User("ÉCOLE first".to_string().into())); + app.push_block(Block::Agent("école answer".into())); + app.push_block(Block::Thought { + text: "école thought".into(), + started: Instant::now(), + millis: Some(1), + }); + app.push_block(Block::User("école last".to_string().into())); + app.push_block(Block::Notice("école not a message".into())); + app.open_navigation(); + app.paste("École"); + assert_eq!(app.sync_navigation(), vec![0, 1, 2, 3]); + app.handle_key(press(KeyCode::Down)); + app.handle_key(modified_press(KeyCode::Down, KeyModifiers::CONTROL)); + let dialog = app.navigation.dialog.as_ref().unwrap(); + assert_eq!(dialog.role, super::Role::User); + assert_eq!(dialog.query, "École"); + assert_eq!(dialog.selected, app.navigation.id(3)); + app.handle_key(modified_press(KeyCode::Up, KeyModifiers::CONTROL)); + assert_eq!( + app.navigation.dialog.as_ref().unwrap().selected, + app.navigation.id(0) + ); + app.last_key = None; // Intentional key, outside a paste burst. + app.handle_key(press(KeyCode::Tab)); + assert_eq!(app.sync_navigation(), vec![1]); + app.last_key = None; // Intentional key, outside a paste burst. + app.handle_key(press(KeyCode::Tab)); + assert_eq!(app.sync_navigation(), vec![2]); + assert!(!app.show_thoughts); + app.last_key = None; // Intentional key, outside a paste burst. + app.handle_key(press(KeyCode::Tab)); + assert!(app.sync_navigation().is_empty()); + assert!(app.navigation.dialog.as_ref().unwrap().selected.is_none()); + app.last_key = None; // Intentional key, outside a paste burst. + app.handle_key(press(KeyCode::Enter)); + assert!(app.navigation.dialog.is_some()); + app.handle_key(press(KeyCode::BackTab)); + assert_eq!(app.sync_navigation(), vec![2]); + } + + #[test] + fn transcript_navigation_keeps_identity_across_live_and_replay_upserts() { + let mut app = app(); + app.start_session("session".into()); + app.apply(Update::AgentMessage { + id: "acp-message".into(), + text: "original".into(), + append: false, + }); + app.open_navigation(); + let selected = app.navigation.dialog.as_ref().unwrap().selected; + // Replay replacement uses the same ACP ID; it is not a fork address. + app.apply(Update::AgentMessage { + id: "acp-message".into(), + text: "replacement".into(), + append: false, + }); + app.apply(Update::AgentMessage { + id: "acp-message".into(), + text: " live tail".into(), + append: true, + }); + app.apply(Update::AgentMessage { + id: "different-message".into(), + text: "later".into(), + append: false, + }); + app.sync_navigation(); + assert_eq!(app.navigation.dialog.as_ref().unwrap().selected, selected); + assert_eq!(app.blocks.len(), 2); + app.handle_key(press(KeyCode::Enter)); + assert_eq!(app.navigation.revealed, selected); + app.start_session("session".into()); // same ID, fresh activation + assert!(app.navigation.dialog.is_none()); + assert!(app.navigation.revealed.is_none()); + app.apply(Update::AgentMessage { + id: "acp-message".into(), + text: "replacement".into(), + append: false, + }); + app.open_navigation(); + assert_ne!(app.navigation.dialog.as_ref().unwrap().selected, selected); + } + + #[test] + fn transcript_navigation_search_updates_and_excludes_pending_and_image_bytes() { + let mut app = app(); + app.apply(Update::UserMessage { + id: "user".into(), + text: "[Image #1]".into(), + images: vec![UserImage::new("c2VjcmV0".into(), "image/png".into(), 0).unwrap()], + append: false, + }); + app.apply(Update::SteerAccepted { + id: "pending".into(), + text: "undelivered secret".into(), + editable: true, + }); + app.apply(Update::ToolStarted { + id: "tool".into(), + title: "shell".into(), + kind: ToolKind::Other, + script: Some("return readable_script".into()), + backgrounded: false, + }); + let tool_focus = app.transcript_focus_index; + app.open_navigation(); + app.paste("c2VjcmV0"); + assert!(app.sync_navigation().is_empty()); + app.navigation.dialog.as_mut().unwrap().query = "undelivered".into(); + assert!(app.sync_navigation().is_empty()); + app.navigation.dialog.as_mut().unwrap().query = "READABLE_SCRIPT".into(); + assert_eq!(app.sync_navigation(), vec![1]); + app.handle_key(press(KeyCode::Enter)); + assert_eq!(app.transcript_focus_index, tool_focus); + app.open_navigation(); + app.paste("future text"); + assert!(app.sync_navigation().is_empty()); + app.apply(Update::AgentMessage { + id: "future".into(), + text: "future text".into(), + append: true, + }); + assert_eq!(app.sync_navigation(), vec![2]); + app.apply(Update::AgentMessage { + id: "future".into(), + text: "no longer matches".into(), + append: false, + }); + assert!(app.sync_navigation().is_empty()); + assert!(app.navigation.dialog.as_ref().unwrap().selected.is_none()); + } + + #[test] + fn transcript_navigation_restored_history_and_empty_session_are_safe() { + let mut app = app(); + app.open_navigation(); + for key in [KeyCode::Up, KeyCode::Down, KeyCode::Enter, KeyCode::BackTab] { + assert!(matches!(app.handle_key(press(key)), Action::None)); + } + assert!(app.navigation.dialog.as_ref().unwrap().selected.is_none()); + app.start_session("restored".into()); + app.apply(Update::UserMessage { + id: "replayed-user".into(), + text: "replayed user".into(), + images: Vec::new(), + append: false, + }); + app.apply(Update::AgentMessage { + id: "replayed-agent".into(), + text: "replayed assistant".into(), + append: false, + }); + app.open_navigation(); + assert_eq!(app.sync_navigation(), vec![0, 1]); + app.handle_key(press(KeyCode::Down)); + app.last_key = None; // Intentional key, outside a paste burst. + app.handle_key(press(KeyCode::Enter)); + assert_eq!(app.navigation.revealed, app.navigation.id(1)); + } + #[test] fn typed_eligible_at_opens_picker_but_paste_and_email_do_not() { let mut typed = app(); diff --git a/src/tui/command.rs b/src/tui/command.rs index 55e0802..0fe2440 100644 --- a/src/tui/command.rs +++ b/src/tui/command.rs @@ -14,6 +14,7 @@ enum Kind { Model, Effort, Agents, + Transcript, Login, } @@ -83,6 +84,11 @@ const LOCAL_COMMANDS: &[Spec] = &[ description: "Show the agent roster", kind: Kind::Agents, }, + Spec { + token: "/transcript", + description: "Browse the transcript (F3)", + kind: Kind::Transcript, + }, Spec { token: "/login", description: "Authenticate with the agent", @@ -99,6 +105,7 @@ pub enum Parsed<'a> { Model { query: Option<&'a str> }, Effort { value: Option<&'a str> }, Agents, + Transcript, Login { method_id: Option<&'a str> }, Prompt(&'a str), } @@ -134,7 +141,8 @@ pub fn parse(input: &str, login_available: bool) -> Parsed<'_> { Kind::Model => Parsed::Model { query: prompt }, Kind::Effort => Parsed::Effort { value: prompt }, Kind::Agents if prompt.is_none() => Parsed::Agents, - Kind::Agents => Parsed::Prompt(input), + Kind::Transcript if prompt.is_none() => Parsed::Transcript, + Kind::Agents | Kind::Transcript => Parsed::Prompt(input), Kind::Login => Parsed::Login { method_id: prompt.map(str::trim), }, @@ -148,7 +156,9 @@ pub fn known_token( login_available: bool, ) -> Option> { if let Some((spec, token_end)) = recognized_local(input, login_available) { - if matches!(spec.kind, Kind::Agents) && !input[token_end..].trim().is_empty() { + if matches!(spec.kind, Kind::Agents | Kind::Transcript) + && !input[token_end..].trim().is_empty() + { return None; } return Some(0..token_end); @@ -292,6 +302,47 @@ mod tests { ); } + #[test] + fn transcript_is_an_exact_highlighted_local_command_without_arguments() { + for login_available in [false, true] { + for input in ["/transcript", "/transcript ", "/transcript\t\n"] { + assert_eq!(parse_command(input, login_available), Parsed::Transcript); + assert_eq!(find_known_token(input, &[], login_available), Some(0..11)); + } + for input in [ + "/transcript query", + "/transcript\tquery", + "/transcriptx", + "/transcript/path", + " /transcript", + ] { + assert_eq!(parse_command(input, login_available), Parsed::Prompt(input)); + assert_eq!( + find_known_token( + input, + &[Command::new("transcript", "Agent transcript")], + login_available, + ), + None + ); + } + } + } + + #[test] + fn transcript_completion_advertises_f3_with_local_precedence() { + let advertised = [Command::new("/transcript", "Agent transcript")]; + for login_available in [false, true] { + for input in ["/tr", "/transcript"] { + assert_eq!( + complete_commands(input, input.len(), &advertised, login_available), + [Command::new("/transcript", "Browse the transcript (F3)")] + ); + } + } + assert!(completions("/transcript query", 11, &advertised).is_empty()); + } + #[test] fn advertised_commands_are_discovered_but_not_parsed_locally() { let advertised = vec![Command::new("compact", ""), Command::new("new", "")]; @@ -353,6 +404,7 @@ mod tests { "/model", "/effort", "/agents", + "/transcript", "/compact", ] ); diff --git a/src/tui/input.rs b/src/tui/input.rs new file mode 100644 index 0000000..7f36e0e --- /dev/null +++ b/src/tui/input.rs @@ -0,0 +1,865 @@ +//! Terminal input captured independently of the TUI's processing and rendering. + +use std::{ + collections::VecDeque, + io, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use tokio::sync::Notify; + +const MAX_QUEUED_EVENTS: usize = 1024; +const MAX_QUEUED_BYTES: usize = 256 * 1024; +const RECOVERY_QUIET: Duration = Duration::from_millis(100); + +#[derive(Debug)] +pub struct ReceivedEvent { + pub event: Event, + pub received_at: Instant, +} + +#[derive(Debug)] +pub enum InputEvent { + Event(ReceivedEvent), + Overflow, + RecoveryReady(bool), + Resumed(Instant), +} + +#[derive(Default)] +enum Recovery { + #[default] + Normal, + DropUntilQuiet(Instant), + AwaitEsc, + AfterEsc { + last_input: Instant, + acknowledged_at: Instant, + }, +} + +/// Bounded payload storage with constant-sized, out-of-band recovery metadata. +#[derive(Default)] +struct InputQueue { + events: VecDeque, + queued_bytes: usize, + overflow: bool, + // Coalesce readiness changes: activity must revoke even an unconsumed ready. + readiness: Option, + resumed: Option, + recovery: Recovery, + error: Option, + closed: bool, +} + +impl InputQueue { + fn payload_bytes(received: &ReceivedEvent) -> usize { + match &received.event { + Event::Paste(text) => text.capacity(), + _ => 0, + } + } + + fn push(&mut self, received: ReceivedEvent) -> bool { + if self.closed { + return false; + } + match &mut self.recovery { + Recovery::DropUntilQuiet(last_input) => { + *last_input = received.received_at; + return false; + } + Recovery::AwaitEsc => { + self.recovery = if matches!( + &received.event, + Event::Key(key) + if key.code == KeyCode::Esc + && key.modifiers == KeyModifiers::NONE + && key.kind == KeyEventKind::Press + ) { + Recovery::AfterEsc { + last_input: received.received_at, + acknowledged_at: received.received_at, + } + } else { + Recovery::DropUntilQuiet(received.received_at) + }; + self.readiness = Some(false); + return true; + } + Recovery::AfterEsc { last_input, .. } => { + *last_input = received.received_at; + return false; + } + Recovery::Normal => {} + } + + let bytes = Self::payload_bytes(&received); + if self.events.len() >= MAX_QUEUED_EVENTS || bytes > MAX_QUEUED_BYTES - self.queued_bytes { + self.events.clear(); + self.queued_bytes = 0; + self.overflow = true; + self.readiness = None; + self.resumed = None; + self.recovery = Recovery::DropUntilQuiet(received.received_at); + return true; + } + self.queued_bytes += bytes; + self.events.push_back(received); + true + } + + /// Only an unsuccessful terminal poll can establish quiet; event timestamps + /// alone cannot prove that the terminal's input backlog has drained. + fn observe_quiet(&mut self, now: Instant) -> bool { + if self.closed { + return false; + } + match self.recovery { + Recovery::DropUntilQuiet(last_input) + if now.saturating_duration_since(last_input) >= RECOVERY_QUIET => + { + self.recovery = Recovery::AwaitEsc; + self.readiness = Some(true); + return true; + } + Recovery::AfterEsc { + last_input, + acknowledged_at, + } if now.saturating_duration_since(last_input) >= RECOVERY_QUIET => { + self.readiness = None; + self.resumed = Some(acknowledged_at); + self.recovery = Recovery::Normal; + return true; + } + _ => {} + } + false + } + + fn pop(&mut self) -> Option> { + if self.overflow { + self.overflow = false; + return Some(Ok(InputEvent::Overflow)); + } + if let Some(ready) = self.readiness.take() { + return Some(Ok(InputEvent::RecoveryReady(ready))); + } + if let Some(acknowledged_at) = self.resumed.take() { + return Some(Ok(InputEvent::Resumed(acknowledged_at))); + } + if let Some(received) = self.events.pop_front() { + self.queued_bytes -= Self::payload_bytes(&received); + return Some(Ok(InputEvent::Event(received))); + } + self.error.take().map(Err) + } + + fn finish(&mut self, error: Option) { + if !self.closed { + self.error = error; + self.closed = true; + } + } +} + +/// Owns the terminal's sole event reader. +/// +/// Do not use `EventStream`, `event::poll`, or `event::read` alongside this reader. +/// Crossterm requires polling and reading on the same thread; its EventStream +/// worker only wakes the consumer, which would timestamp delayed processing +/// rather than receipt. Drop this reader before handing the terminal to a child. +pub struct InputEvents { + events: Arc>, + ready: Arc, + stopped: Arc, + reader: Option>, +} + +impl InputEvents { + pub fn new() -> Self { + let events = Arc::new(Mutex::new(InputQueue::default())); + let ready = Arc::new(Notify::new()); + let stopped = Arc::new(AtomicBool::new(false)); + let reader_events = Arc::clone(&events); + let reader_ready = Arc::clone(&ready); + let reader_stopped = Arc::clone(&stopped); + let reader = thread::Builder::new() + .name("terminal-input".into()) + .spawn(move || { + let mut error = None; + while !reader_stopped.load(Ordering::Relaxed) { + let received = match event::poll(Duration::from_millis(25)) { + Ok(false) => { + if reader_events.lock().unwrap().observe_quiet(Instant::now()) { + reader_ready.notify_one(); + } + continue; + } + Ok(true) => event::read().map(|event| ReceivedEvent { + event, + received_at: Instant::now(), + }), + Err(error) => Err(error), + }; + let ready = match received { + Ok(received) => reader_events.lock().unwrap().push(received), + Err(failed) => { + error = Some(failed); + break; + } + }; + // A full queue never waits for the consumer: it quarantines + // and keeps draining terminal input instead. Discarded input + // and idle polls need not wake the UI. + if ready { + reader_ready.notify_one(); + } + } + reader_events.lock().unwrap().finish(error); + reader_ready.notify_one(); + }) + .expect("failed to spawn terminal input reader"); + Self { + events, + ready, + stopped, + reader: Some(reader), + } + } + + pub async fn next(&mut self) -> Option> { + loop { + // There is one consumer. notify_one retains a permit if the reader + // signals between this queue check and polling the notification. + let notified = self.ready.notified(); + { + let mut queue = self.events.lock().unwrap(); + if let Some(event) = queue.pop() { + return Some(event); + } + if queue.closed { + return None; + } + } + notified.await; + } + } + + pub fn try_next(&mut self) -> Option> { + self.events.lock().unwrap().pop() + } +} + +impl Drop for InputEvents { + fn drop(&mut self) { + self.stopped.store(true, Ordering::Relaxed); + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use crate::tui::{ + app::{Action, App, AttachmentKind, Block}, + transcript::Role, + }; + use crossterm::{ + event::{KeyCode, KeyEvent, KeyModifiers}, + terminal, + }; + use std::{ + fs::File, + io::{BufRead, BufReader, Read, Seek, Write}, + os::{ + fd::{AsRawFd, FromRawFd}, + unix::{net::UnixStream, process::CommandExt}, + }, + process::{Child, Command, Stdio}, + sync::mpsc as sync_mpsc, + }; + + const CHILD_ENV: &str = "KIT_INPUT_RECEIPT_TEST_CHILD"; + const CONTROL_ENV: &str = "KIT_INPUT_RECEIPT_TEST_CONTROL_FD"; + + // Always reap the isolated test, including when a handshake times out. + struct ChildGuard(Child); + + impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + fn mark(message: &str) { + println!("INPUT_TEST:{message}"); + io::stdout().flush().unwrap(); + } + + fn assert_key(received: &ReceivedEvent, expected: char) { + assert!( + matches!(&received.event, Event::Key(key) if key.code == KeyCode::Char(expected)), + "expected {expected:?}, got {received:?}" + ); + } + + fn received(item: io::Result) -> ReceivedEvent { + match item.unwrap() { + InputEvent::Event(event) => event, + other => panic!("expected terminal event, got {other:?}"), + } + } + + fn key(code: KeyCode, received_at: Instant) -> ReceivedEvent { + ReceivedEvent { + event: Event::Key(KeyEvent::new(code, KeyModifiers::NONE)), + received_at, + } + } + + #[test] + fn queue_event_and_payload_budgets_release_capacity_on_consumption() { + let now = Instant::now(); + let mut queue = InputQueue::default(); + for _ in 0..MAX_QUEUED_EVENTS { + queue.push(key(KeyCode::Char('x'), now)); + } + for _ in 0..MAX_QUEUED_EVENTS { + assert_key(&received(queue.pop().unwrap()), 'x'); + } + assert!(queue.pop().is_none()); + for _ in 0..2 { + queue.push(ReceivedEvent { + event: Event::Paste("x".repeat(MAX_QUEUED_BYTES / 2)), + received_at: now, + }); + } + let mut bytes = 0; + while let Some(item) = queue.pop() { + let Event::Paste(text) = received(item).event else { + panic!("expected paste") + }; + bytes += text.capacity(); + } + assert_eq!(bytes, MAX_QUEUED_BYTES); + queue.push(ReceivedEvent { + event: Event::Paste("y".repeat(MAX_QUEUED_BYTES)), + received_at: now, + }); + assert!(matches!( + received(queue.pop().unwrap()).event, + Event::Paste(_) + )); + assert!(queue.pop().is_none()); + } + + #[test] + fn overload_discards_pending_controls_and_requires_quiet_acknowledgement() { + for payload in [false, true] { + let now = Instant::now(); + let mut queue = InputQueue::default(); + if payload { + // Repeated bracketed-paste payloads saturate the byte budget + // long before the event count. Both old payloads must be freed. + for _ in 0..3 { + queue.push(ReceivedEvent { + event: Event::Paste("x".repeat(MAX_QUEUED_BYTES / 2)), + received_at: now, + }); + } + } else { + for _ in 0..MAX_QUEUED_EVENTS { + queue.push(key(KeyCode::Char('x'), now)); + } + queue.push(key(KeyCode::Enter, now)); + } + assert!(matches!( + queue.pop().unwrap().unwrap(), + InputEvent::Overflow + )); + assert!(queue.pop().is_none()); + // A large timestamp gap alone does not prove the terminal is quiet. + let later = now + RECOVERY_QUIET * 10; + for code in [KeyCode::Esc, KeyCode::Tab, KeyCode::Enter] { + queue.push(key(code, later)); + } + assert!(queue.pop().is_none()); + queue.observe_quiet(later + RECOVERY_QUIET); + // Even after quiet, Enter is not acknowledgement and remains inert. + queue.push(key(KeyCode::Enter, later + RECOVERY_QUIET)); + // Unconsumed readiness is revoked, not left as a stale ready event. + assert!(matches!( + queue.pop().unwrap().unwrap(), + InputEvent::RecoveryReady(false) + )); + assert!(queue.pop().is_none()); + queue.observe_quiet(later + RECOVERY_QUIET * 2); + queue.push(key(KeyCode::Esc, later + RECOVERY_QUIET * 2)); + // A tail after Esc is also discarded, not leaked into the composer. + queue.push(key(KeyCode::Tab, later + RECOVERY_QUIET * 3)); + queue.push(key(KeyCode::Enter, later + RECOVERY_QUIET * 3)); + queue.observe_quiet(later + RECOVERY_QUIET * 3); + assert!(matches!( + queue.pop().unwrap().unwrap(), + InputEvent::RecoveryReady(false) + )); + assert!(queue.pop().is_none()); + queue.observe_quiet(later + RECOVERY_QUIET * 4); + assert!(matches!( + queue.pop().unwrap().unwrap(), + InputEvent::Resumed(_) + )); + assert!(queue.pop().is_none()); + queue.push(key(KeyCode::Char('z'), later + RECOVERY_QUIET * 5)); + assert_key(&received(queue.pop().unwrap()), 'z'); + } + } + + #[test] + fn composer_partial_paste_cannot_submit_until_overload_is_acknowledged() { + let mut app = App::new( + "/tmp".into(), + "provider".into(), + "model".into(), + "a2a".into(), + ); + app.paste("existing draft"); + let now = Instant::now(); + let mut queue = InputQueue::default(); + queue.push(key(KeyCode::Char('x'), now)); + assert!(matches!( + crate::tui::handle_input(&mut app, queue.pop().unwrap().unwrap()), + Action::None + )); + let partial = app.editor.text().to_owned(); + for _ in 0..MAX_QUEUED_EVENTS { + queue.push(key(KeyCode::Char('x'), now)); + } + queue.push(key(KeyCode::Enter, now)); + assert!(matches!( + crate::tui::handle_input(&mut app, queue.pop().unwrap().unwrap()), + Action::Redraw + )); + queue.observe_quiet(now + RECOVERY_QUIET); + queue.push(key(KeyCode::Enter, now + RECOVERY_QUIET)); + assert!(matches!( + crate::tui::handle_input(&mut app, queue.pop().unwrap().unwrap()), + Action::Redraw + )); + assert!(!app.input_recovery_ready); + assert!(queue.pop().is_none()); + assert_eq!(app.editor.text(), partial); + assert!(app.input_overflow); + queue.observe_quiet(now + RECOVERY_QUIET * 2); + queue.push(key(KeyCode::Esc, now + RECOVERY_QUIET * 2)); + queue.push(key(KeyCode::Enter, now + RECOVERY_QUIET * 2)); + queue.observe_quiet(now + RECOVERY_QUIET * 3); + assert!(matches!( + crate::tui::handle_input(&mut app, queue.pop().unwrap().unwrap()), + Action::Redraw + )); + assert_eq!(app.editor.text(), partial); + assert!(!app.input_overflow); + // Fresh input works after acknowledgement; an accompanying pasted + // newline still inserts text, rather than submitting a partial draft. + for code in [KeyCode::Char('z'), KeyCode::Enter] { + queue.push(key(code, now + RECOVERY_QUIET * 4)); + assert!(matches!( + crate::tui::handle_input(&mut app, queue.pop().unwrap().unwrap()), + Action::None + )); + } + assert!(app.editor.text().starts_with(&partial)); + assert!(app.editor.text().ends_with("z\n")); + } + + #[test] + fn oversized_paste_spare_capacity_is_not_retained() { + let mut queue = InputQueue::default(); + let mut text = String::with_capacity(MAX_QUEUED_BYTES + 1); + text.push('x'); + queue.push(ReceivedEvent { + event: Event::Paste(text), + received_at: Instant::now(), + }); + assert!(matches!( + queue.pop().unwrap().unwrap(), + InputEvent::Overflow + )); + for _ in 0..20 { + queue.push(ReceivedEvent { + event: Event::Paste("x".repeat(MAX_QUEUED_BYTES + 1)), + received_at: Instant::now(), + }); + } + assert!(queue.pop().is_none()); + queue.finish(None); + assert!(queue.closed); + } + + #[test] + fn recovery_readiness_is_coalesced_and_reported_only_on_transitions() { + let mut queue = InputQueue::default(); + let mut now = Instant::now(); + queue.push(ReceivedEvent { + event: Event::Paste("x".repeat(MAX_QUEUED_BYTES + 1)), + received_at: now, + }); + assert!(matches!( + queue.pop().unwrap().unwrap(), + InputEvent::Overflow + )); + queue.observe_quiet(now + RECOVERY_QUIET); + assert!(matches!( + queue.pop().unwrap().unwrap(), + InputEvent::RecoveryReady(true) + )); + queue.observe_quiet(now + RECOVERY_QUIET * 2); + assert!(queue.pop().is_none()); // No idle notification loop. + now += RECOVERY_QUIET * 2; + for _ in 0..100 { + now += RECOVERY_QUIET; + queue.push(key(KeyCode::Enter, now)); + queue.observe_quiet(now + RECOVERY_QUIET); + } + // Only the latest state survives slow UI consumption, not 200 messages. + assert!(matches!( + queue.pop().unwrap().unwrap(), + InputEvent::RecoveryReady(true) + )); + assert!(queue.pop().is_none()); + queue.push(key(KeyCode::Tab, now + RECOVERY_QUIET)); + assert!(matches!( + queue.pop().unwrap().unwrap(), + InputEvent::RecoveryReady(false) + )); + assert!(queue.pop().is_none()); + queue.finish(None); + queue.observe_quiet(now + RECOVERY_QUIET * 3); + assert!(queue.pop().is_none()); + } + + async fn await_recovery(input: &mut InputEvents, app: &mut App, phase: &str, resume: bool) { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let event = input.next().await.expect("reader remains open").unwrap(); + let complete = match &event { + InputEvent::RecoveryReady(ready) => *ready && !resume, + InputEvent::Resumed(_) if resume => true, + other => panic!("{phase}: unexpected recovery event {other:?}"), + }; + crate::tui::handle_input(app, event); + if complete { + break; + } + } + }) + .await + .unwrap_or_else(|_| panic!("{phase}: reader did not report recovery (resume={resume})")); + } + + fn run_child() { + // SAFETY: only the isolated child executes this function; pre_exec gives + // it ownership of this test-control descriptor, separate from the PTY. + let control_fd = std::env::var(CONTROL_ENV).unwrap().parse().unwrap(); + let mut control = unsafe { File::from_raw_fd(control_fd) }; + terminal::enable_raw_mode().unwrap(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + let mut input = InputEvents::new(); + mark("ready"); + let mut app = App::new( + "/tmp".into(), + "provider".into(), + "model".into(), + "a2a".into(), + ); + app.blocks + .push(Block::User("ab matching history".to_owned().into())); + app.sync_transcript_cache(); + app.paste("parked draft"); + app.attach( + "/tmp/parked.png".into(), + "image/png", + AttachmentKind::Image, + 12, + ); + let draft = app.editor.text().to_owned(); + let attachments = app.attachments.clone(); + app.open_navigation(); + let first = received(input.next().await.unwrap()); + assert_key(&first, 'a'); + assert!(matches!(crate::tui::handle(&mut app, first), Action::None)); + mark("processing"); + + // Block even the async executor: only the production reader can + // capture the subsequent bytes while the TUI is busy. + thread::sleep(Duration::from_millis(500)); + let resumed_at = Instant::now(); + let b = received(input.try_next().expect("b captured while busy")); + let tab = received(input.try_next().expect("Tab captured while busy")); + let pasted_enter = + received(input.try_next().expect("paste Enter captured while busy")); + let second_enter = received( + input + .try_next() + .expect("second paste Enter captured while busy"), + ); + let deliberate_enter = received( + input + .try_next() + .expect("deliberate Enter captured while busy"), + ); + assert_key(&b, 'b'); + assert!( + resumed_at.duration_since(deliberate_enter.received_at) + > Duration::from_millis(100) + ); + assert!(tab.received_at.duration_since(b.received_at) > Duration::from_millis(8)); + assert!( + pasted_enter.received_at.duration_since(tab.received_at) + < Duration::from_millis(8) + ); + assert!(matches!(crate::tui::handle(&mut app, b), Action::None)); + assert!(matches!(crate::tui::handle(&mut app, tab), Action::None)); + assert_eq!(app.navigation.dialog.as_ref().unwrap().role, Role::User); + for event in [pasted_enter, second_enter] { + assert!(matches!(crate::tui::handle(&mut app, event), Action::None)); + assert!(app.navigation.dialog.is_some()); + assert_eq!(app.editor.text(), draft); + } + assert!(matches!( + crate::tui::handle(&mut app, deliberate_enter), + Action::None + )); + assert!(app.navigation.dialog.is_none()); + assert!(app.navigation.revealed.is_some()); + assert_eq!(app.editor.text(), draft); + assert!(input.try_next().is_none()); + + for kind in ["keys", "bytes"] { + app.open_navigation(); + mark(&format!("flood-{kind}")); + // A separate test-transport pipe keeps the consumer blocked + // until the parent finishes writing the entire PTY flood. + // This does not assume a fixed terminal-decoding throughput. + control.read_exact(&mut [0]).unwrap(); + thread::sleep(Duration::from_millis(100)); + let overflow = input.next().await.unwrap().unwrap(); + assert!(matches!(overflow, InputEvent::Overflow)); + assert!(matches!( + crate::tui::handle_input(&mut app, overflow), + Action::Redraw + )); + assert!(app.input_overflow); + assert!(app.navigation.dialog.is_some()); + assert_eq!(app.editor.text(), draft); + assert_eq!(app.attachments, attachments); + await_recovery(&mut input, &mut app, &format!("flood-{kind}"), false).await; + assert!(app.input_recovery_ready); + mark(&format!("blocked-{kind}")); + // The parent sends controls and a premature Esc. Wait for + // the reader's next quiet transition, not a scheduling delay. + await_recovery(&mut input, &mut app, &format!("blocked-{kind}"), false).await; + assert!(app.input_overflow); + assert!(app.input_recovery_ready); + mark(&format!("ack-{kind}")); + await_recovery(&mut input, &mut app, &format!("ack-{kind}"), true).await; + assert!(!app.input_overflow); + assert!(app.navigation.dialog.is_some()); // Esc did not close it. + assert_eq!(app.editor.text(), draft); + assert_eq!(app.attachments, attachments); + mark(&format!("recover-{kind}")); + for _ in 0..3 { + let event = input.next().await.unwrap().unwrap(); + assert!(matches!( + crate::tui::handle_input(&mut app, event), + Action::None + )); + } + assert!(app.navigation.dialog.is_none()); + assert!(app.navigation.revealed.is_some()); + assert_eq!(app.editor.text(), draft); + assert_eq!(app.attachments, attachments); + } + + let dropping_at = Instant::now(); + drop(input); + // Allow scheduling headroom, but catch a blocking read on an + // idle terminal instead of the bounded poll. + assert!(dropping_at.elapsed() < Duration::from_secs(1)); + let mut input = InputEvents::new(); + mark("restarted"); + assert_key(&received(input.next().await.unwrap()), 'e'); + mark("fill-before-drop"); + thread::sleep(Duration::from_millis(500)); + let dropping_at = Instant::now(); + drop(input); + assert!(dropping_at.elapsed() < Duration::from_secs(1)); + }); + terminal::disable_raw_mode().unwrap(); + mark("done"); + } + + #[test] + fn pty_captures_receipt_times_while_busy_and_restarts() { + run_pty_test(false); + } + + // Exercise the Linux polling backend's /dev/tty fallback. The unchanged + // macOS MIO backend cannot initialize with redirected stdin on all systems. + #[cfg(target_os = "linux")] + #[test] + fn pty_uses_controlling_terminal_with_redirected_stdin() { + run_pty_test(true); + } + + fn run_pty_test(redirect_stdin: bool) { + if std::env::var_os(CHILD_ENV).is_some() { + run_child(); + return; + } + + let (mut master_fd, mut slave_fd) = (-1, -1); + // SAFETY: openpty initializes both descriptors; optional settings are + // null, and ownership transfers exactly once to the File values below. + assert_eq!( + unsafe { + libc::openpty( + &mut master_fd, + &mut slave_fd, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }, + 0, + "openpty: {}", + io::Error::last_os_error() + ); + let mut master = unsafe { File::from_raw_fd(master_fd) }; + let slave = unsafe { File::from_raw_fd(slave_fd) }; + let (mut control, child_control) = UnixStream::pair().unwrap(); + let mut redirected = redirect_stdin.then(|| { + let mut file = tempfile::tempfile().unwrap(); + file.write_all(b"not terminal input\r\t\r").unwrap(); + file.rewind().unwrap(); + file + }); + let redirected_fd = redirected.as_ref().map(AsRawFd::as_raw_fd); + let mut command = Command::new(std::env::current_exe().unwrap()); + let test_name = thread::current().name().unwrap().to_owned(); + command + .args(["--exact", &test_name, "--nocapture"]) + .env(CHILD_ENV, "1") + .env(CONTROL_ENV, child_control.as_raw_fd().to_string()) + .stdin(Stdio::from(slave)) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + // SAFETY: only async-signal-safe terminal setup runs between fork and + // exec. Stdin initially supplies the controlling PTY, not the developer's + // terminal. Redirecting it afterwards exercises the /dev/tty fallback. + // The extra descriptor is only a test-transport completion signal. + unsafe { + command.pre_exec(move || { + if libc::setsid() == -1 + || libc::ioctl(libc::STDIN_FILENO, libc::TIOCSCTTY as _, 0) == -1 + || libc::fcntl(child_control.as_raw_fd(), libc::F_SETFD, 0) == -1 + { + return Err(io::Error::last_os_error()); + } + if let Some(fd) = redirected_fd + && libc::dup2(fd, libc::STDIN_FILENO) == -1 + { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = ChildGuard(command.spawn().unwrap()); + drop(command); + let stdout = child.0.stdout.take().unwrap(); + let (sender, messages) = sync_mpsc::channel(); + let output_reader = thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let line = line.unwrap(); + if let Some((_, message)) = line.split_once("INPUT_TEST:") { + let _ = sender.send(message.to_owned()); + } + } + }); + let wait_for = |expected: &str| { + assert_eq!( + messages.recv_timeout(Duration::from_secs(10)).unwrap(), + expected + ); + }; + wait_for("ready"); + master.write_all(b"a").unwrap(); + wait_for("processing"); + master.write_all(b"b").unwrap(); + thread::sleep(Duration::from_millis(80)); + master.write_all(b"\t\r\r").unwrap(); + thread::sleep(Duration::from_millis(80)); + master.write_all(b"\r").unwrap(); + for kind in ["keys", "bytes"] { + wait_for(&format!("flood-{kind}")); + if kind == "keys" { + master + .write_all(&vec![b'x'; MAX_QUEUED_EVENTS * 8]) + .unwrap(); + } else { + for _ in 0..4 { + master.write_all(b"\x1b[200~").unwrap(); + master.write_all(&vec![b'x'; MAX_QUEUED_BYTES / 2]).unwrap(); + master.write_all(b"\x1b[201~").unwrap(); + } + } + master.write_all(b"\r\t\r").unwrap(); + control.write_all(b"!").unwrap(); + wait_for(&format!("blocked-{kind}")); + master.write_all(b"\r\t\r\x1b").unwrap(); + wait_for(&format!("ack-{kind}")); + master.write_all(b"\x1b").unwrap(); + wait_for(&format!("recover-{kind}")); + master.write_all(b"ab").unwrap(); + thread::sleep(Duration::from_millis(80)); + master.write_all(b"\r").unwrap(); + } + wait_for("restarted"); + master.write_all(b"e").unwrap(); + wait_for("fill-before-drop"); + master.write_all(&vec![b'x'; MAX_QUEUED_EVENTS]).unwrap(); + wait_for("done"); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(status) = child.0.try_wait().unwrap() { + assert!(status.success(), "PTY child failed: {status}"); + break; + } + assert!(Instant::now() < deadline, "PTY child failed to exit"); + thread::sleep(Duration::from_millis(10)); + } + output_reader.join().unwrap(); + if let Some(file) = redirected.as_mut() { + assert_eq!( + file.stream_position().unwrap(), + 0, + "redirected input consumed" + ); + } + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 311ac89..59a954b 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -10,9 +10,11 @@ mod app; mod command; mod editor; mod image; +mod input; mod markdown; mod plan; mod theme; +mod transcript; mod ui; mod wrap; @@ -34,14 +36,13 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use crossterm::{ event::{ DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, - Event, EventStream, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, - PushKeyboardEnhancementFlags, + Event, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }, execute, style::Print, terminal::EnterAlternateScreen, }; -use futures_util::StreamExt; +use input::{InputEvent, InputEvents, ReceivedEvent}; use ratatui::DefaultTerminal; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -1057,7 +1058,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( initialized.capabilities.session.as_ref().and_then(|session| session.inject.as_ref()), ); app.auth_methods = auth_methods; - let mut events = EventStream::new(); + let mut events = InputEvents::new(); let mut ticker = tokio::time::interval(TICK); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -1078,7 +1079,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( tokio::select! { event = events.next() => { let action = match event { - Some(Ok(event)) => handle(&mut app, event), + Some(Ok(event)) => handle_input(&mut app, event), Some(Err(_)) | None => { leave(&mut terminal); return Ok(()); @@ -1160,7 +1161,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( images = resume_terminal(&mut terminal).map_err( agent_client_protocol::Error::into_internal_error, )?; - events = EventStream::new(); + events = InputEvents::new(); break session; } Err(error) if authentication_required( @@ -1185,7 +1186,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( images = resume_terminal(&mut terminal).map_err( agent_client_protocol::Error::into_internal_error, )?; - events = EventStream::new(); + events = InputEvents::new(); } Action::None | Action::Redraw => {} _ => app.note("authenticate before starting a session"), @@ -1237,27 +1238,13 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( // bracket it. Applying everything the terminal has // already buffered keeps a paste to a single redraw // instead of one frame per character. - let mut next = terminal_event; - let mut action = Action::None; - for _ in 0..MAX_BURST { - match next { - Some(Ok(event)) => action = handle(&mut app, event), - Some(Err(_)) | None => return Ok(()), - } - if !matches!(action, Action::None) { - break; - } - // `EventStream::next().now_or_never()` polls with a noop - // waker. If no event is ready, crossterm's background reader - // retains that waker and cannot wake this select loop when the - // next key arrives. Check synchronously before polling the - // stream so an empty burst cannot make the TUI unresponsive. - if crossterm::event::poll(Duration::ZERO).unwrap_or(false) { - next = events.next().await; - } else { - break; - } - } + let Some(first) = terminal_event else { return Ok(()); }; + let burst = std::iter::once(first) + .chain(std::iter::from_fn(|| events.try_next())); + let action = match handle_input_burst(&mut app, burst) { + Ok(action) => action, + Err(_) => return Ok(()), + }; match action { Action::Quit => return Ok(()), Action::Submit { prompt, inject } => { @@ -1681,7 +1668,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( images = resume_terminal(&mut terminal).map_err( agent_client_protocol::Error::into_internal_error, )?; - events = EventStream::new(); + events = InputEvents::new(); } Action::Copy(text) => { execute!(terminal.backend_mut(), Print(osc52(&text))) @@ -2035,15 +2022,61 @@ fn pending_message_unavailable(error: &agent_client_protocol::Error) -> bool { ) } +/// Dispatch at most one frame's input without dequeuing beyond its budget or +/// the first action. In particular, an overflow warning must remain queued if +/// a concurrently replenished burst reaches the frame limit before it. +fn handle_input_burst( + app: &mut App, + events: impl Iterator>, +) -> std::io::Result { + for event in events.take(MAX_BURST) { + let action = handle_input(app, event?); + if !matches!(action, Action::None) { + return Ok(action); + } + } + Ok(Action::None) +} + +/// Queue control messages never enter modal/composer key handling. +fn handle_input(app: &mut App, input: InputEvent) -> Action { + match input { + InputEvent::Event(received) => handle(app, received), + InputEvent::Overflow => { + app.input_overflow = true; + app.input_recovery_ready = false; + Action::Redraw + } + InputEvent::RecoveryReady(ready) => { + app.input_recovery_ready = ready; + Action::Redraw + } + InputEvent::Resumed(received_at) => { + app.input_overflow = false; + app.input_recovery_ready = false; + app.last_key = Some(received_at); + app.toast = Some(( + "Input resumed; check draft before sending".into(), + std::time::Instant::now(), + )); + Action::Redraw + } + } +} + /// Applies one terminal event, returning the work it asks for. -fn handle(app: &mut App, event: Event) -> Action { - match event { - Event::Key(key) => app.handle_key(key), +fn handle(app: &mut App, received: ReceivedEvent) -> Action { + match received.event { + Event::Key(key) => app.handle_key_at(key, received.received_at), Event::Mouse(mouse) => app.handle_mouse(mouse), Event::Paste(text) => { if app.model_switch.is_some() { return Action::None; } + if app.navigation.dialog.is_some() { + app.paste(&text); + return Action::None; + } if app.queue_focused && !app.session_rename_active() { return Action::None; } @@ -2739,7 +2772,7 @@ mod tests { active_config_matches, active_session_config, agent_command_for_launch, apply_pending_updates, attachments_from_paste, authentication_required, client_capabilities, command, credential_storage_for_launch, current_model_choice, - detach_from_controlling_terminal, durable_session_id, effort_state, error_detail, handle, + detach_from_controlling_terminal, durable_session_id, effort_state, error_detail, message_of, osc52, previous_session_for_resume, prompt_blocks, readable, refresh_config_state, refresh_session_after_auth, save_effort_default_to, save_model_defaults_to, terminal_auth_command, transition_route, translate, @@ -2751,6 +2784,106 @@ mod tests { tui::app::{Action, App, SessionDialog, SessionRename, SubmittedPrompt, Update}, }; + fn handle(app: &mut App, event: Event) -> Action { + super::handle( + app, + super::ReceivedEvent { + event, + received_at: std::time::Instant::now(), + }, + ) + } + + #[test] + fn replenished_input_burst_leaves_overflow_queued_and_displays_warning() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::{Terminal, backend::TestBackend}; + use std::{ + sync::mpsc, + thread, + time::{Duration, Instant}, + }; + + let mut app = App::new( + "/tmp".into(), + "provider".into(), + "model".into(), + "a2a".into(), + ); + // A one-slot transport must be replenished throughout the frame. Unlike + // a preloaded large collection, it also models a bounded reader queue. + let (sender, receiver) = mpsc::sync_channel(1); + let producer = thread::spawn(move || { + let received_at = Instant::now(); + for _ in 0..MAX_BURST { + sender + .send(Ok(super::InputEvent::Event(super::ReceivedEvent { + event: Event::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)), + received_at, + }))) + .unwrap(); + } + sender.send(Ok(super::InputEvent::Overflow)).unwrap(); + }); + // Blocking receipt keeps this test's burst continuous regardless of + // producer scheduling; production uses the same dispatcher with try_next. + assert!(matches!( + super::handle_input_burst(&mut app, receiver.iter()).unwrap(), + Action::None + )); + assert_eq!(app.editor.text(), "a".repeat(MAX_BURST)); + assert!(!app.input_overflow); + producer.join().unwrap(); + let pending = receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(matches!(pending, Ok(super::InputEvent::Overflow))); + assert!(matches!( + super::handle_input_burst(&mut app, std::iter::once(pending)).unwrap(), + Action::Redraw + )); + let mut terminal = Terminal::new(TestBackend::new(60, 14)).unwrap(); + let mut images = crate::tui::image::ImageRuntime::disabled(); + terminal + .draw(|frame| super::ui::draw(frame, &mut app, &mut images)) + .unwrap(); + let output: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect(); + assert!( + output.contains("Input overflow: input discarded"), + "{output}" + ); + assert!(output.contains("Waiting for quiet input"), "{output}"); + assert_eq!(app.editor.text(), "a".repeat(MAX_BURST)); + } + + #[test] + fn input_burst_stops_at_action_without_consuming_the_next_event() { + let mut app = App::new( + "/tmp".into(), + "provider".into(), + "model".into(), + "a2a".into(), + ); + let mut events = [ + Ok(super::InputEvent::Overflow), + Ok(super::InputEvent::Resumed(std::time::Instant::now())), + ] + .into_iter(); + assert!(matches!( + super::handle_input_burst(&mut app, events.by_ref()).unwrap(), + Action::Redraw + )); + assert!(app.input_overflow); + assert!(matches!( + events.next(), + Some(Ok(super::InputEvent::Resumed(_))) + )); + } + fn command_args(command: &tokio::process::Command) -> Vec { command .as_std() @@ -3822,6 +3955,204 @@ mod tests { )); } + #[test] + fn transcript_navigation_tool_media_search_uses_labels_not_payloads() { + let content = serde_json::from_value::(json!({ + "type": "content", + "content": { + "type": "image", + "data": "c2VjcmV0", + "mimeType": "image/png", + "uri": "data:image/png;base64,c2VjcmV0" + } + })) + .unwrap(); + let mut app = App::new( + PathBuf::from("/tmp"), + "provider".into(), + "model".into(), + "a2a".into(), + ); + app.apply(Update::ToolStarted { + id: "image-tool".into(), + title: "media".into(), + kind: wire::ToolKind::Other, + script: None, + backgrounded: false, + }); + for update in translate_for_session( + UpdateSessionNotification::new( + "session", + SessionUpdate::ToolCallUpdate( + wire::ToolCallUpdate::new("image-tool").content(vec![content]), + ), + ), + "session", + ) { + app.apply(update); + } + app.open_navigation(); + app.paste("c2VjcmV0"); + assert!(app.sync_navigation().is_empty()); + app.navigation.dialog.as_mut().unwrap().query = "base64".into(); + assert!(app.sync_navigation().is_empty()); + app.navigation.dialog.as_mut().unwrap().query = "image".into(); + assert_eq!(app.sync_navigation(), vec![0]); + } + + fn assert_navigation_key_paste_preserves_draft(working: bool) { + use crate::tui::{ + app::{AttachmentKind, Phase}, + transcript::Role, + }; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use std::time::{Duration, Instant}; + + for suffix in ["\n\n", "\t\n\n"] { + let mut app = App::new( + PathBuf::from("/tmp"), + "provider".into(), + "model".into(), + "a2a".into(), + ); + app.apply(Update::AgentMessage { + id: "answer".into(), + text: "matching text".into(), + append: false, + }); + app.paste("parked draft"); + app.attach( + PathBuf::from("/tmp/parked.png"), + "image/png", + AttachmentKind::Image, + 12, + ); + app.editor.move_left(); + let draft = app.editor.text().to_owned(); + let cursor = app.editor.cursor(); + let attachments = app.attachments.clone(); + app.phase = if working { Phase::Working } else { Phase::Idle }; + app.can_steer = true; + app.follow = false; + app.scroll = 7; + app.last_key = None; + assert!(matches!( + handle( + &mut app, + Event::Key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)) + ), + Action::None + )); + for character in format!("matching text{suffix}").chars() { + let code = match character { + '\n' => KeyCode::Enter, + '\t' => KeyCode::Tab, + character => KeyCode::Char(character), + }; + // Feed the real rapid sequence: do not repair the app's clock + // between keys, which would mask slow-processing regressions. + assert!(matches!( + handle( + &mut app, + Event::Key(KeyEvent::new(code, KeyModifiers::NONE)) + ), + Action::None + )); + assert!( + app.navigation.dialog.is_some(), + "paste must not close navigator: {code:?}" + ); + assert_eq!(app.editor.text(), draft); + assert_eq!(app.editor.cursor(), cursor); + assert_eq!(app.attachments, attachments); + } + let dialog = app.navigation.dialog.as_ref().unwrap(); + // Single-line queries discard control whitespace in both paste modes. + assert_eq!(dialog.query, "matching text"); + assert_eq!(dialog.role, Role::All); + assert!(app.navigation.revealed.is_none()); + assert!(app.pending_steers.is_empty()); + assert_eq!(app.scroll, 7); + assert!(!app.follow); + assert_eq!(app.working(), working); + + // An intentional Enter after the burst still reveals the selection. + app.last_key = Some(Instant::now() - Duration::from_millis(500)); + assert!(matches!( + handle( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + ), + Action::None + )); + assert!(app.navigation.dialog.is_none()); + assert!(app.navigation.revealed.is_some()); + assert_eq!(app.editor.text(), draft); + assert_eq!(app.editor.cursor(), cursor); + assert_eq!(app.attachments, attachments); + assert!(app.pending_steers.is_empty()); + assert_eq!(app.working(), working); + } + } + + #[test] + fn transcript_navigation_key_paste_cannot_send_parked_draft() { + assert_navigation_key_paste_preserves_draft(false); + } + + #[test] + fn transcript_navigation_key_paste_cannot_steer_parked_draft() { + assert_navigation_key_paste_preserves_draft(true); + } + + #[test] + fn transcript_navigation_paste_and_mouse_do_not_touch_the_composer() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; + let directory = tempfile::tempdir().unwrap(); + let image = directory.path().join("image.png"); + std::fs::write(&image, b"png").unwrap(); + let mut app = App::new( + directory.path().to_path_buf(), + "provider".into(), + "model".into(), + "a2a".into(), + ); + app.paste("parked draft"); + app.scroll = 7; + app.follow = false; + handle( + &mut app, + Event::Key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)), + ); + let query = image.display().to_string(); + assert!(matches!( + handle(&mut app, Event::Paste(query.clone())), + Action::None + )); + assert_eq!(app.navigation.dialog.as_ref().unwrap().query, query); + assert!(app.attachments.is_empty()); + assert_eq!(app.editor.text(), "parked draft"); + assert!(matches!( + handle( + &mut app, + Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE + }) + ), + Action::None + )); + handle( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + ); + assert_eq!(app.scroll, 7); + assert!(!app.follow); + assert_eq!(app.editor.text(), "parked draft"); + } + #[test] fn multiple_dropped_paths_become_attachments() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/tui/transcript.rs b/src/tui/transcript.rs new file mode 100644 index 0000000..4b5ca1f --- /dev/null +++ b/src/tui/transcript.rs @@ -0,0 +1,412 @@ +//! Read-only navigation of the currently rendered history, never fork addresses. + +use unicode_segmentation::UnicodeSegmentation; + +use super::app::Block; + +/// Process-local block identity. It survives content replacement, not activation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct BlockId(u64); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) enum Role { + #[default] + All, + User, + Assistant, + Thought, + Tool, +} + +impl Role { + pub fn label(self) -> &'static str { + match self { + Self::All => "All", + Self::User => "User", + Self::Assistant => "Assistant", + Self::Thought => "Thought", + Self::Tool => "Tool", + } + } + + pub fn cycle(self, backwards: bool) -> Self { + let roles = [ + Self::All, + Self::User, + Self::Assistant, + Self::Thought, + Self::Tool, + ]; + let index = roles.iter().position(|role| *role == self).unwrap_or(0); + roles[(index + if backwards { 4 } else { 1 }) % roles.len()] + } +} + +/// Bounds navigator matching, rendering, and grapheme-aware backspace work. +pub(super) const MAX_QUERY_BYTES: usize = 4096; + +#[derive(Default)] +pub(super) struct Navigator { + pub query: String, + pub role: Role, + pub selected: Option, +} + +#[derive(Default)] +pub(super) struct Navigation { + ids: Vec, + next_id: u64, + pub dialog: Option, + pub revealed: Option, + pub reveal_pending: bool, + pub anchored: bool, + search_query: String, + search_matches: Vec>, +} + +impl Navigation { + pub fn push(&mut self) { + self.next_id += 1; + self.ids.push(BlockId(self.next_id)); + } + + pub fn reset(&mut self) { + self.ids.clear(); + self.search_matches.clear(); + self.dialog = None; + self.revealed = None; + self.reveal_pending = false; + self.anchored = false; + } + + /// Direct block setup is used by render tests; production appends via push. + pub fn sync(&mut self, len: usize) { + self.ids.truncate(len); + while self.ids.len() < len { + self.push(); + } + } + + pub fn id(&self, index: usize) -> Option { + self.ids.get(index).copied() + } + + pub fn index(&self, id: BlockId) -> Option { + self.ids.iter().position(|candidate| *candidate == id) + } + + pub fn is_revealed(&self, index: usize) -> bool { + self.revealed.is_some() && self.id(index) == self.revealed + } + + pub fn matches(&mut self, blocks: &[Block], revisions: &[u64]) -> Vec { + let Some(dialog) = &self.dialog else { + return Vec::new(); + }; + let query = dialog.query.to_lowercase(); + if self.search_query != query { + self.search_query = query; + self.search_matches.clear(); + } + self.search_matches.resize(blocks.len(), None); + blocks + .iter() + .enumerate() + .filter_map(|(index, block)| { + let role = role(block)?; + if dialog.role != Role::All && dialog.role != role { + return None; + } + if self.search_query.is_empty() { + return Some(index); + } + // Animated frames and role changes reuse the text match. Only a + // changed query or block revision rescans potentially large output. + if let Some((revision, matched)) = self.search_matches[index] + && revisions.get(index) == Some(&revision) + { + return matched.then_some(index); + } + let matched = + text_parts(block).any(|text| text.to_lowercase().contains(&self.search_query)); + self.search_matches[index] = + revisions.get(index).map(|revision| (*revision, matched)); + matched.then_some(index) + }) + .collect() + } + + pub fn reconcile(&mut self, matches: &[usize]) { + let Some(dialog) = &mut self.dialog else { + return; + }; + if !matches + .iter() + .any(|index| self.ids.get(*index).copied() == dialog.selected) + { + dialog.selected = matches + .first() + .and_then(|index| self.ids.get(*index)) + .copied(); + } + } +} + +pub(super) fn role(block: &Block) -> Option { + match block { + Block::User(_) => Some(Role::User), + Block::Agent(_) => Some(Role::Assistant), + Block::Thought { .. } => Some(Role::Thought), + Block::Tool(_) => Some(Role::Tool), + Block::TurnDuration(_) | Block::Notice(_) | Block::Error(_) => None, + } +} + +// Only already-decoded display text participates. Never inspect image.data, +// raw ACP input, or binary media payloads for a search or preview. +fn text_parts(block: &Block) -> impl Iterator { + let (main, script, intent, output) = match block { + Block::User(message) => (message.text.as_str(), None, None, [].as_slice()), + Block::Agent(text) | Block::Thought { text, .. } => { + (text.as_str(), None, None, [].as_slice()) + } + Block::Tool(call) => ( + call.title.as_str(), + Some(call.script.as_str()), + call.intent.as_deref(), + call.output.as_slice(), + ), + _ => ("", None, None, [].as_slice()), + }; + std::iter::once(main) + .chain(intent) + .chain(script) + .chain(output.iter().map(String::as_str)) +} + +// Shared across text parts, including grapheme lookahead and control filtering. +const MAX_PREVIEW_BYTES: usize = 4096; + +pub(super) fn preview(block: &Block) -> String { + let mut remaining = MAX_PREVIEW_BYTES; + let mut count = 0; + let mut preview = String::new(); + 'parts: for text in text_parts(block) { + // Bound source bytes BEFORE segmentation: even one grapheme (including + // the 97th, used as lookahead) can contain arbitrarily many codepoints. + let end = text.floor_char_boundary(text.len().min(remaining)); + remaining -= end; + let truncated = end < text.len(); + for (index, grapheme) in text[..end].grapheme_indices(true) { + // The final cluster may continue outside the bounded source slice. + // Omit it rather than scan beyond the budget or display a fragment. + if count == 96 || (truncated && index + grapheme.len() == end) { + preview.push('…'); + break 'parts; + } + preview.push_str(if grapheme.chars().any(char::is_control) { + " " + } else { + grapheme + }); + count += 1; + } + if truncated || count == 96 { + preview.push('…'); + break; + } + preview.push(' '); + count += 1; + } + preview.split_whitespace().collect::>().join(" ") +} + +impl Navigator { + pub fn insert(&mut self, text: &str) { + let remaining = MAX_QUERY_BYTES.saturating_sub(self.query.len()); + if remaining == 0 { + return; + } + // Limit source characters BEFORE filtering or segmenting: even a paste + // made entirely of controls or one huge combining cluster is bounded. + // One lookahead character lets a full ASCII prefix use the byte budget. + let mut consumed = 0; + let candidate: String = text + .chars() + .take(remaining + 1) + .filter(|character| { + consumed += character.len_utf8(); + !character.is_control() + }) + .collect(); + for (index, grapheme) in candidate.grapheme_indices(true) { + // The last cluster may continue beyond our bounded source prefix. + // Never retain that potentially incomplete cluster. + if consumed < text.len() && index + grapheme.len() == candidate.len() { + break; + } + if self.query.len() + grapheme.len() > MAX_QUERY_BYTES { + break; + } + self.query.push_str(grapheme); + } + } + + pub fn backspace(&mut self) { + if let Some((index, _)) = self.query.grapheme_indices(true).next_back() { + self.query.truncate(index); + } + } +} + +#[cfg(test)] +mod tests { + use super::{Navigation, Navigator, Role, preview}; + use crate::tui::app::Block; + + #[test] + fn query_backspace_removes_whole_unicode_graphemes() { + let mut dialog = Navigator::default(); + dialog.insert("École e\u{301}👩‍💻\n\u{1b}"); + assert_eq!(dialog.query, "École e\u{301}👩‍💻"); + dialog.backspace(); + assert_eq!(dialog.query, "École e\u{301}"); + dialog.backspace(); + assert_eq!(dialog.query, "École "); + dialog.query.clear(); + dialog.backspace(); + assert!(dialog.query.is_empty()); + } + + #[test] + fn query_large_pastes_bound_source_work_and_stored_bytes() { + for (paste, expected) in [ + ("a".repeat(1_000_000), "a".repeat(super::MAX_QUERY_BYTES)), + ("\n".repeat(1_000_000), String::new()), + ( + format!("{}unvisited", "\n".repeat(super::MAX_QUERY_BYTES + 1)), + String::new(), + ), + (format!("e{}", "\u{301}".repeat(1_000_000)), String::new()), + ] { + let mut dialog = Navigator::default(); + dialog.insert(&paste); + assert_eq!(dialog.query, expected); + } + + let mut dialog = Navigator { + query: "a".repeat(super::MAX_QUERY_BYTES), + ..Navigator::default() + }; + dialog.insert(&"b".repeat(1_000_000)); + assert_eq!(dialog.query, "a".repeat(super::MAX_QUERY_BYTES)); + dialog.backspace(); + dialog.insert("z"); + assert!(dialog.query.ends_with('z')); + assert_eq!(dialog.query.len(), super::MAX_QUERY_BYTES); + } + + #[test] + fn query_byte_cap_preserves_whole_graphemes() { + for grapheme in ["é", "e\u{301}", "👩‍💻", "🇺🇸"] { + let prefix = "x".repeat(super::MAX_QUERY_BYTES - grapheme.len()); + let mut dialog = Navigator { + query: prefix.clone(), + ..Navigator::default() + }; + dialog.insert(&format!("{grapheme}overflow")); + assert_eq!(dialog.query, format!("{prefix}{grapheme}")); + dialog.backspace(); + assert_eq!(dialog.query, prefix); + + dialog.insert("x"); + let before = dialog.query.clone(); + dialog.insert(grapheme); + assert_eq!(dialog.query, before, "must not retain a partial {grapheme}"); + } + // A combining cluster that continues beyond the source-work budget is + // rejected even when its bounded prefix alone would fit the byte cap. + let mut dialog = Navigator { + query: "x".repeat(super::MAX_QUERY_BYTES - 1), + ..Navigator::default() + }; + dialog.insert("e\n\u{301}"); + assert_eq!(dialog.query.len(), super::MAX_QUERY_BYTES - 1); + } + + #[test] + fn previews_are_compact_and_do_not_split_graphemes() { + let block = Block::Agent(format!("{}👩‍💻end\nnext", "é".repeat(95))); + let rendered = preview(&block); + assert_eq!(rendered, format!("{}👩‍💻…", "é".repeat(95))); + assert_eq!( + preview(&Block::Agent("first\n\tsecond".into())), + "first second" + ); + } + + #[test] + fn previews_omit_huge_clusters_at_the_prefix_limit_and_lookahead() { + for prefix_len in [0, 1, 95, 96] { + let prefix = "x".repeat(prefix_len); + let block = Block::Agent(format!("{prefix}e{}unvisited", "\u{301}".repeat(1_000_000))); + assert_eq!(preview(&block), format!("{prefix}…")); + } + } + + #[test] + fn previews_omit_clusters_cut_at_byte_or_joiner_boundaries() { + for (remaining, suffix) in [(3, "e\u{301}tail"), (7, "👩‍💻tail"), (5, "🇺🇸tail")] + { + // A complete first cluster uses most of the byte budget. The next + // cluster is cut after a combining mark/ZWJ or inside a codepoint. + let prefix = format!( + "e{}", + "\u{301}".repeat((super::MAX_PREVIEW_BYTES - remaining - 1) / 2) + ); + assert_eq!(prefix.len() + remaining, super::MAX_PREVIEW_BYTES); + assert_eq!( + preview(&Block::Agent(format!("{prefix}{suffix}"))), + format!("{prefix}…") + ); + } + } + + #[test] + fn previews_preserve_complete_clusters_at_the_source_boundary() { + let cluster = format!("e{}", "\u{301}".repeat((super::MAX_PREVIEW_BYTES - 2) / 2)); + assert_eq!( + preview(&Block::Agent(format!("{cluster}x"))), + format!("{cluster}x") + ); + // With more source available, the last bounded cluster is uncertain. + assert_eq!( + preview(&Block::Agent(format!("{cluster}xunvisited"))), + format!("{cluster}…") + ); + } + + #[test] + fn search_cache_tracks_query_and_block_revisions_not_animation() { + let mut navigation = Navigation::default(); + let mut blocks = vec![Block::Agent("ÉCOLE".into())]; + navigation.sync(blocks.len()); + navigation.dialog = Some(Navigator { + query: "école".into(), + ..Navigator::default() + }); + assert_eq!(navigation.matches(&blocks, &[1]), vec![0]); + assert_eq!(navigation.search_matches, vec![Some((1, true))]); + navigation.dialog.as_mut().unwrap().role = Role::User; + assert!(navigation.matches(&blocks, &[1]).is_empty()); + navigation.dialog.as_mut().unwrap().role = Role::All; + assert_eq!(navigation.matches(&blocks, &[1]), vec![0]); + blocks[0] = Block::Agent("changed".into()); + assert!(navigation.matches(&blocks, &[2]).is_empty()); + assert_eq!(navigation.search_matches, vec![Some((2, false))]); + navigation.dialog.as_mut().unwrap().query = "CHANGED".into(); + assert_eq!(navigation.matches(&blocks, &[2]), vec![0]); + navigation.reset(); + assert!(navigation.search_matches.is_empty()); + } +} diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 97600ce..28809b8 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -28,7 +28,7 @@ use super::{ image::{ImageRuntime, RESERVED_ROWS}, markdown, plan::PlanKind, - theme, + theme, transcript, wrap::{LinkedLine, LinkedSpan, wrap_linked_tagged}, }; @@ -49,6 +49,7 @@ type TranscriptTag = (Option, Option, Option); type TaggedTranscriptLine = (LinkedLine, TranscriptTag); pub fn draw(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRuntime) { + let navigation_matches = app.sync_navigation(); // Two border columns plus the `›` gutter; the prompt grows as the wrapped // text needs more rows, up to the cap. let start_width = frame @@ -124,6 +125,31 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRuntime) { } else if app.effort_dialog.is_some() { draw_effort_dialog(frame, app); } + if app.navigation.dialog.is_some() { + draw_navigation(frame, app, &navigation_matches); + } + // This warning must not expire or disappear behind a modal while the reader + // discards input. Esc acknowledgement is handled by the reader, not the UI. + if app.input_overflow { + let area = frame.area(); + let height = area.height.min(3); + let warning = Rect::new( + area.x, + area.bottom().saturating_sub(height), + area.width, + height, + ); + frame.render_widget(Clear, warning); + frame.render_widget( + Paragraph::new(if app.input_recovery_ready { + "Input overflow: input discarded\nPress Esc to resume\nCheck draft before sending" + } else { + "Input overflow: input discarded\nWaiting for quiet input...\nCheck draft before sending" + }) + .style(Style::default().fg(Color::Yellow)), + warning, + ); + } // Durability stays visible on the start screen and over session pickers. // Pending data belongs to the process, not the currently selected session. if app.storage_pending || app.storage_exhausted { @@ -387,14 +413,18 @@ fn model_dialog_viewport( } fn visible_query_tail(query: &str, width: usize) -> &str { - let mut tail = query; - while UnicodeWidthStr::width(tail) > width { - let Some((index, _)) = tail.grapheme_indices(true).nth(1) else { - return ""; - }; - tail = &tail[index..]; + // Match the renderer's per-grapheme cell widths. Two forward passes keep + // this linear, including long regional-indicator runs; repeatedly measuring + // the remaining string (or finding boundaries backwards) can be quadratic. + let grapheme_width = |grapheme: &str| UnicodeWidthStr::width(grapheme); + let mut remaining: usize = query.graphemes(true).map(grapheme_width).sum(); + for (index, grapheme) in query.grapheme_indices(true) { + if remaining <= width { + return &query[index..]; + } + remaining -= grapheme_width(grapheme); } - tail + "" } fn draw_session_dialog(frame: &mut Frame<'_>, app: &App) { @@ -631,6 +661,120 @@ fn draw_effort_dialog(frame: &mut Frame<'_>, app: &App) { ); } +/// A read-only index of display text. Only visible entries build previews. +fn draw_navigation(frame: &mut Frame<'_>, app: &App, matches: &[usize]) { + let outer = frame.area(); + let width = if outer.width > 20 { + outer.width.saturating_sub(4).min(80) + } else { + outer.width + }; + let height = if outer.height > 8 { + outer.height.saturating_sub(4).min(22) + } else { + outer.height + }; + let area = Rect::new( + outer.x + outer.width.saturating_sub(width) / 2, + outer.y + outer.height.saturating_sub(height) / 2, + width, + height, + ); + let dialog = app.navigation.dialog.as_ref().expect("checked above"); + let selected = matches + .iter() + .position(|index| app.navigation.id(*index) == dialog.selected); + let panel = Panel::bordered().title(format!( + " transcript {}/{} ", + selected.map_or(0, |index| index + 1), + matches.len() + )); + let inner = panel.inner(area); + let footer_rows = if inner.height >= 5 { + 2 + } else { + u16::from(inner.height >= 3) + }; + let [search, list, footer] = Layout::vertical([ + Constraint::Length(u16::from(inner.height >= 2)), + Constraint::Min(0), + Constraint::Length(footer_rows), + ]) + .areas(inner); + let visible = list.height as usize; + let start = selected + .unwrap_or(0) + .saturating_sub(visible.saturating_sub(1)); + let lines = if matches.is_empty() { + vec![Line::from(Span::styled( + "No matching messages", + theme::dim(), + ))] + } else { + matches + .iter() + .enumerate() + .skip(start) + .take(visible) + .map(|(position, index)| { + let marker = if Some(position) == selected { + "› " + } else { + " " + }; + let style = if Some(position) == selected { + theme::accent().add_modifier(Modifier::BOLD) + } else { + theme::text() + }; + let role = transcript::role(&app.blocks[*index]) + .expect("matched message") + .label(); + Line::from(Span::styled( + format!( + "{marker}{role}: {}", + transcript::preview(&app.blocks[*index]) + ), + style, + )) + }) + .collect() + }; + frame.render_widget(Clear, area); + frame.render_widget(panel, area); + let prefix = format!("{} / ", dialog.role.label()); + let prefix_width = UnicodeWidthStr::width(prefix.as_str()).min(search.width as usize); + let query = visible_query_tail( + &dialog.query, + (search.width as usize).saturating_sub(prefix_width + 1), + ); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(prefix, theme::dim()), + Span::styled(query.to_owned(), theme::text()), + ])), + search, + ); + frame.render_widget(Paragraph::new(lines), list); + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled( + "↑/↓ select · Enter reveal · Esc/F3 close", + theme::dim(), + )), + Line::from(Span::styled( + "type to search · Tab roles · Ctrl+↑/↓ User jumps", + theme::dim(), + )), + ]), + footer, + ); + if search.width > 0 && search.height > 0 { + let column = (prefix_width + UnicodeWidthStr::width(query)).min(search.width as usize - 1); + frame.set_cursor_position(Position::new(search.x + column as u16, search.y)); + } +} + fn draw_model_dialog(frame: &mut Frame<'_>, app: &App) { let outer = frame.area(); let width = if outer.width > 20 { @@ -870,7 +1014,10 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti } let width = inner.width.max(1) as usize; + let viewport_changed = + app.transcript_cache_width != width || app.viewport != inner.height as usize; refresh_transcript_cache_with_images(app, images, width); + app.apply_navigation_reveal(viewport_changed); let working_rows = if app.working() { wrap_linked_tagged( &[(LinkedLine::plain(working_line(app)), (None, None, None))], @@ -910,8 +1057,13 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti String::new(), ); let mut visible_images: Vec<(usize, usize, i16)> = Vec::new(); - let mut materialize = |row: &crate::tui::app::CachedTranscriptRow| { - visible.push(row.0.clone()); + let mut materialize = |row: &crate::tui::app::CachedTranscriptRow, revealed: bool| { + let mut line = row.0.clone(); + if revealed { + // Navigation is independent of tool-card focus and cached styles. + line.style = line.style.add_modifier(Modifier::REVERSED); + } + visible.push(line); app.row_calls.push(row.1.0.clone()); app.row_code.push(row.1.1.clone()); app.row_links.push(row.2.clone()); @@ -929,7 +1081,7 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti } let separator_rows = usize::from(span_start > 0); if separator_rows > 0 && offset <= span_start && span_start < end { - materialize(&separator); + materialize(&separator, false); } let content_start = span_start + separator_rows; if let Some(block) = &app.transcript_cache[block_index] { @@ -939,7 +1091,7 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti if absolute >= end { break; } - materialize(row); + materialize(row, app.navigation.is_revealed(block_index)); } for placement in &block.images { let image_start = content_start + placement.row; @@ -960,7 +1112,7 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti if !working_rows.is_empty() && transcript_rows < end { let separator_row = transcript_rows; if offset <= separator_row && separator_row < end { - materialize(&separator); + materialize(&separator, false); } let content_start = separator_row + 1; let first_row = offset.saturating_sub(content_start); @@ -968,7 +1120,7 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti if content_start + row_index >= end { break; } - materialize(row); + materialize(row, false); } } let row_widths: Vec = visible.iter().map(ratatui::text::Line::width).collect(); @@ -1197,6 +1349,7 @@ fn transcript_block_lines( text, started.elapsed().as_millis(), *millis, + app.navigation.is_revealed(block_index), ))), None, ), @@ -1271,9 +1424,10 @@ fn thought_lines( text: &str, running_millis: u128, millis: Option, + revealed: bool, ) -> Vec> { let elapsed = millis.unwrap_or(u64::try_from(running_millis).unwrap_or(u64::MAX)); - if millis.is_some() && !app.show_thoughts { + if millis.is_some() && !app.show_thoughts && !revealed { return vec![Line::from(Span::styled( format!("⋮ thought for {} · ^t to read", theme::duration(elapsed)), theme::faint(), @@ -1281,7 +1435,7 @@ fn thought_lines( } let style = theme::dim().add_modifier(Modifier::ITALIC); let all: Vec<&str> = text.split('\n').collect(); - let shown = if app.show_thoughts { + let shown = if app.show_thoughts || revealed { all.as_slice() } else { &all[all.len().saturating_sub(4)..] @@ -1762,7 +1916,7 @@ fn working_line(app: &App) -> Line<'static> { format!(" {}", theme::duration(app.elapsed())), theme::dim(), ), - Span::styled(" esc interrupts", theme::faint()), + Span::styled(" esc interrupts · F3 transcript", theme::faint()), ]) } @@ -2307,7 +2461,7 @@ fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) { } else if !app.pending_steers.is_empty() { "F2 queue ⏎ send ⇧⏎ newline " } else { - "⏎ send ⇧⏎ newline ^l log ^c quit " + "F3 transcript ⏎ send ⇧⏎ newline ^l log ^c quit " }; let used: usize = left.iter().map(|span| span.content.chars().count()).sum(); let gap = (area.width as usize) @@ -2339,7 +2493,7 @@ fn compact(value: u64) -> String { #[cfg(test)] mod tests { use agent_client_protocol::schema::v2::{ - IdleStateUpdate, RunningStateUpdate, StateUpdate, StopReason, + IdleStateUpdate, RequiresActionStateUpdate, RunningStateUpdate, StateUpdate, StopReason, }; use std::path::PathBuf; @@ -2940,6 +3094,410 @@ mod tests { app } + fn navigation_app(blocks: Vec) -> App { + let mut app = App::new( + PathBuf::from("/tmp/kit"), + "openai-subscription".into(), + "gpt-5.4".into(), + "0:0".into(), + ); + app.blocks = blocks; + app + } + + fn reveal(app: &mut App, index: usize) { + app.open_navigation(); + app.navigation.dialog.as_mut().unwrap().selected = app.navigation.id(index); + app.last_key = None; // Deliberate reveal, not an unbracketed paste burst. + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + } + + #[test] + fn navigation_reveal_uses_wrapped_prefix_and_reanchors_on_resize() { + let mut app = navigation_app(vec![ + Block::Agent("earlier wrapped words ".repeat(40)), + Block::Agent("selected message".into()), + Block::Agent("later message\n".repeat(40)), + ]); + render(&mut app, 60, 14); + reveal(&mut app, 1); + let wide = render(&mut app, 60, 14); + let wide_prefix = app.transcript_prefixes[1]; + assert_eq!(app.scroll, wide_prefix + 1); + assert!(!app.follow); + assert!(wide.contains("selected message"), "{wide}"); + + let narrow = render(&mut app, 30, 14); + assert!(app.transcript_prefixes[1] > wide_prefix); + assert_eq!(app.scroll, app.transcript_prefixes[1] + 1); + assert!(narrow.contains("selected message"), "{narrow}"); + assert!(!app.navigation.reveal_pending); + + app.scroll_by(-1); + let manual_offset = app.scroll; + render(&mut app, 50, 14); + assert!(!app.navigation.anchored); + assert_eq!( + app.scroll, + manual_offset.min(app.total_lines.saturating_sub(app.viewport)) + ); + } + + #[test] + fn input_overflow_warning_stays_visible_over_navigation_without_changing_draft() { + let mut app = navigation_app(vec![Block::Agent("matching history".into())]); + app.paste("parked draft"); + app.open_navigation(); + crate::tui::handle_input(&mut app, crate::tui::input::InputEvent::Overflow); + app.tick(); + let output = render(&mut app, 60, 14); + assert!( + output.contains("Input overflow: input discarded"), + "{output}" + ); + assert!(output.contains("Waiting for quiet input"), "{output}"); + assert!(app.navigation.dialog.is_some()); + assert_eq!(app.editor.text(), "parked draft"); + for ready in [true, false, true] { + crate::tui::handle_input( + &mut app, + crate::tui::input::InputEvent::RecoveryReady(ready), + ); + let output = render(&mut app, 60, 14); + assert_eq!(output.contains("Press Esc to resume"), ready, "{output}"); + assert_eq!( + output.contains("Waiting for quiet input"), + !ready, + "{output}" + ); + assert!(app.navigation.dialog.is_some()); + assert_eq!(app.editor.text(), "parked draft"); + } + crate::tui::handle_input( + &mut app, + crate::tui::input::InputEvent::Resumed(std::time::Instant::now()), + ); + let output = render(&mut app, 60, 14); + assert!( + !output.contains("Input overflow: input discarded"), + "{output}" + ); + assert!(app.navigation.dialog.is_some()); + assert_eq!(app.editor.text(), "parked draft"); + } + + #[test] + fn navigation_reveal_then_state_update_keeps_following_on_resize() { + for state in [ + StateUpdate::Running(RunningStateUpdate::new()), + StateUpdate::RequiresAction(RequiresActionStateUpdate::new()), + ] { + for (width, height) in [(30, 14), (60, 9)] { + let mut app = navigation_app(vec![ + Block::Agent("earlier wrapped words ".repeat(40)), + Block::Agent("selected message".into()), + Block::Agent("later message\n".repeat(40)), + ]); + render(&mut app, 60, 14); + reveal(&mut app, 1); + assert!(render(&mut app, 60, 14).contains("selected message")); + assert!(!app.follow); + assert!(!app.navigation.reveal_pending); + + app.apply(Update::State(state.clone())); + let output = render(&mut app, width, height); + assert!(app.follow); + assert!(!app.navigation.anchored); + assert_eq!(app.scroll, app.total_lines.saturating_sub(app.viewport)); + assert!(!output.contains("selected message"), "{output}"); + } + } + } + + #[test] + fn navigation_pending_reveal_survives_state_update_and_resize() { + for state in [ + StateUpdate::Running(RunningStateUpdate::new()), + StateUpdate::RequiresAction(RequiresActionStateUpdate::new()), + ] { + let mut app = navigation_app(vec![ + Block::Agent("earlier wrapped words ".repeat(40)), + Block::Agent("selected message".into()), + Block::Agent("later message\n".repeat(40)), + ]); + render(&mut app, 60, 14); + reveal(&mut app, 1); + app.apply(Update::State(state)); + assert!(app.follow); + assert!(app.navigation.reveal_pending); + + let output = render(&mut app, 30, 14); + assert!(!app.follow); + assert!(app.navigation.anchored); + assert!(!app.navigation.reveal_pending); + assert_eq!(app.scroll, app.transcript_prefixes[1] + 1); + assert!(output.contains("selected message"), "{output}"); + } + } + + #[test] + fn navigation_reveal_remains_visible_when_only_height_shrinks() { + let mut app = navigation_app(vec![ + Block::Agent("earlier lines\n".repeat(40)), + Block::Agent("selected last message".into()), + ]); + render(&mut app, 60, 24); + reveal(&mut app, 1); + assert!(render(&mut app, 60, 24).contains("selected last message")); + let shorter = render(&mut app, 60, 9); + assert!(shorter.contains("selected last message"), "{shorter}"); + assert!(!app.follow); + } + + #[test] + fn navigation_highlight_is_materialized_without_changing_cached_styles() { + let mut app = navigation_app(vec![ + Block::Agent("selected message".into()), + Block::Agent("ordinary message".into()), + ]); + render(&mut app, 50, 14); + reveal(&mut app, 0); + let mut terminal = Terminal::new(TestBackend::new(50, 14)).unwrap(); + let mut images = crate::tui::image::ImageRuntime::disabled(); + terminal + .draw(|frame| draw(frame, &mut app, &mut images)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let x = app.transcript_left as u16; + let y = app.transcript_top as u16; + assert!( + buffer[(x, y)] + .modifier + .contains(ratatui::style::Modifier::REVERSED) + ); + assert!( + !buffer[(x, y + 2)] + .modifier + .contains(ratatui::style::Modifier::REVERSED) + ); + assert!( + app.transcript_cache + .iter() + .flatten() + .all(|block| block.rows.iter().all(|row| !row + .0 + .style + .add_modifier + .contains(ratatui::style::Modifier::REVERSED))) + ); + // Moving the reveal removes the old highlight, even at the same viewport offset. + reveal(&mut app, 1); + terminal + .draw(|frame| draw(frame, &mut app, &mut images)) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert!( + !buffer[(x, y)] + .modifier + .contains(ratatui::style::Modifier::REVERSED) + ); + assert!( + buffer[(x, y + 2)] + .modifier + .contains(ratatui::style::Modifier::REVERSED) + ); + } + + #[test] + fn navigation_reveals_hidden_thoughts_without_changing_preference() { + let mut app = navigation_app(vec![ + Block::Thought { + text: "first private line\nsecond private line\nthird private line".into(), + started: std::time::Instant::now(), + millis: Some(1000), + }, + Block::Agent("ordinary answer".into()), + ]); + app.show_thoughts = false; + assert!(!render(&mut app, 60, 14).contains("first private line")); + reveal(&mut app, 0); + assert!(render(&mut app, 60, 14).contains("first private line")); + assert_eq!(app.transcript_cache[0].as_ref().unwrap().rows.len(), 3); + assert!(!app.show_thoughts); + reveal(&mut app, 1); + assert!(!render(&mut app, 60, 14).contains("first private line")); + assert_eq!(app.transcript_cache[0].as_ref().unwrap().rows.len(), 1); + assert!(!app.show_thoughts); + + let running = "one\ntwo\nthree\nfour\nfive\nsix"; + assert_eq!(super::thought_lines(&app, running, 0, None, false).len(), 4); + assert_eq!(super::thought_lines(&app, running, 0, None, true).len(), 6); + } + + #[test] + fn navigation_overlay_shows_only_visible_chronological_results() { + let mut app = navigation_app( + (0..10_000) + .map(|index| Block::Agent(format!("message-{index:05}"))) + .collect(), + ); + app.open_navigation(); + app.navigation.dialog.as_mut().unwrap().selected = app.navigation.id(9_999); + let matches = app.sync_navigation(); + let mut terminal = Terminal::new(TestBackend::new(60, 12)).unwrap(); + terminal + .draw(|frame| super::draw_navigation(frame, &app, &matches)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let rows = (0..12) + .map(|row| buffer_row(buffer, row)) + .collect::>(); + let output = rows.join("\n"); + assert!(output.contains("10000/10000"), "{output}"); + assert_eq!(output.matches("message-").count(), 3, "{output}"); + assert!(output.contains("› Assistant: message-09999"), "{output}"); + let first = output.find("message-09997").expect("first visible preview"); + let second = output + .find("message-09998") + .expect("second visible preview"); + let third = output.find("message-09999").expect("selected preview"); + assert!(first < second && second < third); + assert!(!output.contains("message-09996"), "{output}"); + } + + #[test] + fn visible_query_tail_preserves_graphemes_and_cell_budget() { + for (query, width, expected) in [ + ("", 0, ""), + ("abc", 0, ""), + ("abc", 2, "bc"), + ("abc", 3, "abc"), + ("a界", 1, ""), + ("a界", 2, "界"), + ("ae\u{301}", 1, "e\u{301}"), + ("a👩‍💻", 1, ""), + ("a👩‍💻", 2, "👩‍💻"), + ("a🇺🇸", 2, "🇺🇸"), + ("\u{301}", 0, "\u{301}"), + ("a\u{301}\u{301}z", 1, "z"), + ] { + assert_eq!(super::visible_query_tail(query, width), expected); + } + } + + #[test] + fn visible_query_tail_preserves_expected_suffix_for_large_inputs() { + for (query, expected) in [ + ("a".repeat(100_000), "a"), + ("🇺🇸".repeat(25_000), ""), + (format!("e{}z", "\u{301}".repeat(100_000)), "z"), + ] { + let tail = super::visible_query_tail(&query, 1); + assert_eq!(tail, expected); + assert!(query.ends_with(tail)); + assert!(UnicodeWidthStr::width(tail) <= 1); + } + } + + #[test] + fn navigation_large_paste_renders_truncated_query_and_empty_results() { + let mut app = navigation_app(vec![Block::Agent("answer".into())]); + app.open_navigation(); + app.paste(&"x".repeat(1_000_000)); + assert_eq!( + app.navigation.dialog.as_ref().unwrap().query.len(), + super::transcript::MAX_QUERY_BYTES + ); + let matches = app.sync_navigation(); + let mut terminal = Terminal::new(TestBackend::new(60, 12)).unwrap(); + terminal + .draw(|frame| super::draw_navigation(frame, &app, &matches)) + .unwrap(); + let output = (0..12) + .map(|row| buffer_row(terminal.backend().buffer(), row)) + .collect::>() + .join("\n"); + assert!(output.contains("All / xxxxx"), "{output}"); + assert!(output.contains("No matching messages"), "{output}"); + } + + #[test] + fn navigation_overlay_handles_empty_results_and_narrow_terminals() { + let mut app = navigation_app(vec![Block::Agent("answer".into())]); + app.open_navigation(); + app.navigation.dialog.as_mut().unwrap().query = "no such message".into(); + let output = render(&mut app, 60, 14); + assert!(output.contains("transcript 0/0"), "{output}"); + assert!(output.contains("No matching messages"), "{output}"); + assert!(output.contains("All / no such message"), "{output}"); + assert!(output.contains("Tab roles"), "{output}"); + assert!(app.navigation.dialog.as_ref().unwrap().selected.is_none()); + app.navigation.dialog.as_mut().unwrap().query.clear(); + let narrow = render(&mut app, 20, 3); + assert!(narrow.contains("› Assistant: ans"), "{narrow}"); + for (width, height) in [(1, 1), (2, 2), (5, 3), (16, 4)] { + render(&mut app, width, height); + } + let mut empty = navigation_app(Vec::new()); + empty.open_navigation(); + assert!(render(&mut empty, 60, 14).contains("No matching messages")); + } + + #[test] + fn navigation_overlay_takes_priority_over_file_picker_and_reconciles_on_draw() { + let mut app = navigation_app(Vec::new()); + app.apply(Update::AgentMessage { + id: "answer".into(), + text: "first answer".into(), + append: false, + }); + app.file_picker = Some(FilePickerDialog { + query_range: 0..0, + revision: 1, + activation: 1, + selected: 0, + matches: Vec::new(), + status: FilePickerStatus::Loading, + }); + app.open_navigation(); + let output = render(&mut app, 60, 14); + assert!(output.contains("› Assistant: first answer"), "{output}"); + assert!(app.file_picker.is_some()); + app.navigation.dialog.as_mut().unwrap().query = "replacement".into(); + assert!(render(&mut app, 60, 14).contains("No matching messages")); + app.apply(Update::AgentMessage { + id: "answer".into(), + text: "replacement answer".into(), + append: false, + }); + let output = render(&mut app, 60, 14); + assert!( + output.contains("› Assistant: replacement answer"), + "{output}" + ); + app.push_user("replacement prompt".into()); + let output = render(&mut app, 60, 14); + assert!(output.contains("transcript 1/2"), "{output}"); + assert!(output.contains("User: replacement prompt"), "{output}"); + app.navigation.dialog.as_mut().unwrap().role = crate::tui::transcript::Role::User; + let output = render(&mut app, 60, 14); + assert!(output.contains("transcript 1/1"), "{output}"); + assert!(output.contains("User / replacement"), "{output}"); + assert!(output.contains("› User: replacement prompt"), "{output}"); + } + + #[test] + fn navigation_shortcut_is_visible_in_idle_and_streaming_hints() { + let mut app = navigation_app(vec![Block::Agent("answer".into())]); + assert!(render(&mut app, 100, 14).contains("F3 transcript")); + app.phase = Phase::Working; + let output = render(&mut app, 100, 14); + assert!( + output.contains("esc interrupts · F3 transcript"), + "{output}" + ); + } + fn render(app: &mut App, width: u16, height: u16) -> String { let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); let mut images = crate::tui::image::ImageRuntime::disabled();