From 783e07cbaf6289af5527f30df617159bcea71e8e Mon Sep 17 00:00:00 2001 From: daniel Date: Sat, 5 Sep 2026 13:53:10 +0100 Subject: [PATCH 1/4] feat: edit and remove queued messages --- Cargo.lock | 4 +- Cargo.toml | 4 +- docs/user/tui-and-sessions.md | 18 +- src/protocols/acp/v2.rs | 75 +++ src/tui/app.rs | 950 +++++++++++++++++++++++++++++++++- src/tui/mod.rs | 354 ++++++++++++- src/tui/ui.rs | 156 +++++- 7 files changed, 1508 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ab25b7c..cda357f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,7 +134,7 @@ dependencies = [ [[package]] name = "agentkit-acp" version = "0.10.11" -source = "git+https://github.com/danielkov/agentkit.git?rev=513e92bb00465a86e5177084f33a130e85705bad#513e92bb00465a86e5177084f33a130e85705bad" +source = "git+https://github.com/danielkov/agentkit.git?rev=8e4ee26434a3f847e3613da5bb073ae63a262243#8e4ee26434a3f847e3613da5bb073ae63a262243" dependencies = [ "agent-client-protocol", "agentkit-core", @@ -241,7 +241,7 @@ dependencies = [ [[package]] name = "agentkit-loop" version = "0.10.11" -source = "git+https://github.com/danielkov/agentkit.git?rev=513e92bb00465a86e5177084f33a130e85705bad#513e92bb00465a86e5177084f33a130e85705bad" +source = "git+https://github.com/danielkov/agentkit.git?rev=8e4ee26434a3f847e3613da5bb073ae63a262243#8e4ee26434a3f847e3613da5bb073ae63a262243" dependencies = [ "agentkit-core", "agentkit-task-manager", diff --git a/Cargo.toml b/Cargo.toml index cca0bae..ba398ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,8 +90,8 @@ tempfile = "=3.27.0" tokio = { version = "=1.53.1", features = ["test-util"] } [patch.crates-io] -agentkit-loop = { git = "https://github.com/danielkov/agentkit.git", rev = "513e92bb00465a86e5177084f33a130e85705bad" } -agentkit-acp = { git = "https://github.com/danielkov/agentkit.git", rev = "513e92bb00465a86e5177084f33a130e85705bad" } +agentkit-loop = { git = "https://github.com/danielkov/agentkit.git", rev = "8e4ee26434a3f847e3613da5bb073ae63a262243" } +agentkit-acp = { git = "https://github.com/danielkov/agentkit.git", rev = "8e4ee26434a3f847e3613da5bb073ae63a262243" } agent-client-protocol = { git = "https://github.com/danielkov/rust-sdk.git", rev = "2f039993d1d6ed8da35b38c31f54a7cbb7338c70" } agent-client-protocol-http = { git = "https://github.com/danielkov/rust-sdk.git", rev = "2f039993d1d6ed8da35b38c31f54a7cbb7338c70" } diff --git a/docs/user/tui-and-sessions.md b/docs/user/tui-and-sessions.md index 0a0baa8..f763a47 100644 --- a/docs/user/tui-and-sessions.md +++ b/docs/user/tui-and-sessions.md @@ -41,7 +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` | Interrupt a running turn; dismiss a notice when idle | +| `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 | +| `F2` | Focus the pending-message queue (or return to the composer) | +| `Up` / `Down`, `Enter`, `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 | | `Ctrl+D` | Quit when the prompt is empty | @@ -63,7 +65,19 @@ 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 appears in the transcript 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`. Local commands and agent-advertised session commands are available only while idle. + +### Edit or remove a pending message + +Press `F2` to focus the queue, then `Up` or `Down` to select a message. The selected row stays visible even when the queue is long. Press `Enter` to edit its text in the composer, or `Delete` to request removal. `Esc` or `F2` returns from the selector without changing your draft. If delivery empties the queue while it is selected, focus stays there until you leave; a stale `Enter` or `Delete` cannot send or alter your draft. + +While editing, plain `Enter` saves the replacement with the **same message ID and queue position**. `Esc` cancels the edit. Either a successful save or cancellation restores the previous composer draft, cursor, and attachments. Queue edits do not run slash commands. Editing is available only when the agent advertises ACP `session.inject.pending.replace`; removing a pending injection does not require this optional capability. + +Save and removal requests run without blocking the UI; updates and `Ctrl+C` cancellation remain available, including when delivery empties the selected queue. Only one change per message can be in flight. You can continue typing or press `Esc` while a save is in progress; `Esc` restores your draft but does not undo a request already sent. A save response never closes a reopened edit or discards text typed after that save began. + +If a request fails, Kit retains the replacement text and the parked original draft. You can retry, or press `Esc` to restore the original. If the message has already been delivered or no longer exists, it cannot be changed or removed: the stale queue entry disappears and any in-progress edit stays available to copy before you press `Esc`. A late response never adds that entry back to the queue. Starting or resuming another session clears the queue, selection, and edit state. + +Active steering continues to support media attachments. Pending-message **edits are text-only**: messages accepted with image, audio, or other non-text content cannot be edited in the queue, but can still be removed. Kit determines editability from the content actually sent, not stale attachments left in the composer. Media paths pasted during a queue edit are ordinary text, not new attachments. Attachments in the parked original draft remain unchanged when you save or cancel. ### Attach local images and audio diff --git a/src/protocols/acp/v2.rs b/src/protocols/acp/v2.rs index 2d501b0..eb923cb 100644 --- a/src/protocols/acp/v2.rs +++ b/src/protocols/acp/v2.rs @@ -2071,6 +2071,19 @@ pub(crate) fn component( }, agent_client_protocol::on_receive_request!(), ) + .on_receive_request( + { + let integration = Arc::clone(&state.integration); + async move |request: wire::ReplaceInjectSessionRequest, responder, cx| { + let integration = Arc::clone(&integration); + cx.spawn(async move { + responder.respond_with_result(integration.replace_inject(request).await) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) .on_receive_request( { let state = Arc::clone(&state); @@ -4060,6 +4073,68 @@ mod tests { } } + #[tokio::test] + async fn v2_router_advertises_and_routes_pending_injection_replacement() { + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::new_with_provider_and_credentials( + root.path(), + "gpt-5.4", + crate::ProviderKind::OpenAiSubscription, + crate::credentials::CredentialStorage::Memory, + ) + .unwrap(); + let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex(); + let router = v2_router(runtime, SessionRegistry::new()).unwrap(); + let server = tokio::spawn(async move { router.connect_to(agent_transport).await }); + let client = + agent_client_protocol::Client + .v2() + .connect_with(client_transport, async move |cx| { + let initialized = cx + .send_request(wire::InitializeRequest::new( + wire::ProtocolVersion::V2, + wire::Implementation::new("replacement-test", "0"), + )) + .block_task() + .await?; + let pending = initialized + .capabilities + .session + .expect("session capabilities") + .inject + .expect("injection capabilities") + .pending + .expect("pending injection capabilities"); + assert_eq!(pending.replace, Some(true)); + + // A domain error, rather than method-not-found, proves that Kit's + // production router forwards replacement requests to AgentKit. + let error = cx + .send_request(wire::ReplaceInjectSessionRequest::new( + "missing-session", + "pending-message", + vec![wire::ContentBlock::Text(wire::TextContent::new( + "replacement", + ))], + )) + .block_task() + .await + .expect_err("replacement must reject an unknown session"); + assert_eq!( + i32::from(error.code), + i32::from(wire::Error::resource_not_found(None).code) + ); + assert_eq!(error.data, Some(json!({ "sessionId": "missing-session" }))); + Ok(()) + }); + let result = timeout(Duration::from_secs(2), client).await; + server.abort(); + let _ = server.await; + result + .expect("replacement client timed out") + .expect("replacement client failed"); + } + #[test] fn initialize_negotiates_v2_and_advertises_injection_and_authentication() { let root = tempfile::tempdir().unwrap(); diff --git a/src/tui/app.rs b/src/tui/app.rs index 2b16543..a8f06e9 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -61,7 +61,16 @@ pub enum Update { result: Result, String>, }, /// A steer was accepted but has not been delivered into the transcript yet. - SteerAccepted { id: String, text: String }, + SteerAccepted { + id: String, + text: String, + editable: bool, + }, + SteerMutationFinished { + id: String, + token: u64, + result: Result<(), SteerMutationError>, + }, /// A user message delivered or replayed by the agent. UserMessage { id: String, @@ -267,6 +276,28 @@ pub struct SubmittedPrompt { pub(super) struct PendingSteer { pub id: String, pub text: String, + /// False if the accepted injection included any non-text content blocks. + pub editable: bool, +} + +#[derive(Debug)] +pub struct SteerMutationError { + pub message: String, + pub unavailable: bool, +} + +struct SteerMutation { + token: u64, + edit_token: Option, + text: Option, +} + +struct SteerEdit { + id: String, + token: u64, + draft: Editor, + attachments: Vec, + next_attachment: usize, } pub enum Action { @@ -276,6 +307,13 @@ pub enum Action { prompt: SubmittedPrompt, inject: bool, }, + ReplaceSteer { + id: String, + text: String, + }, + RevokeSteer { + id: String, + }, New(Option), ListSessions, RenameSession { @@ -635,6 +673,13 @@ pub struct App { pub phase: Phase, pub turn_started: Option, pub can_steer: bool, + pub can_replace_steer: bool, + pub(super) selected_steer: Option, + pub(super) queue_focused: bool, + steer_edit: Option, + retired_steers: HashSet, + steer_mutations: HashMap, + next_steer_token: u64, pub(super) pending_steers: VecDeque, message_blocks: HashMap, /// The previous assistant stream ended; the next text starts a new block. @@ -930,6 +975,13 @@ impl App { phase: Phase::Idle, turn_started: None, can_steer: false, + can_replace_steer: false, + selected_steer: None, + queue_focused: false, + steer_edit: None, + retired_steers: HashSet::new(), + steer_mutations: HashMap::new(), + next_steer_token: 0, pending_steers: VecDeque::new(), message_blocks: HashMap::new(), agent_stream_sealed: false, @@ -975,6 +1027,9 @@ impl App { } fn command_completion_prefix(&self) -> Option<&str> { + if self.editing_steer() || self.queue_focused { + return None; + } command::completion_prefix(self.editor.text(), self.editor.cursor()) } @@ -1696,7 +1751,9 @@ impl App { } fn finish_turn_with_outcome(&mut self, successful: bool, notice: Option) { - self.pending_steers.clear(); + self.retired_steers + .extend(self.pending_steers.drain(..).map(|pending| pending.id)); + self.selected_steer = None; if self.phase == Phase::Idle { self.agent_stream_sealed = true; return; @@ -1813,8 +1870,11 @@ impl App { self.sync_command_completion(); } } - Update::SteerAccepted { id, text } => { - if self.message_blocks.contains_key(&id) { + Update::SteerMutationFinished { id, token, result } => { + self.finish_steer_mutation(&id, token, result); + } + Update::SteerAccepted { id, text, editable } => { + if self.message_blocks.contains_key(&id) || self.retired_steers.contains(&id) { return; } if let Some(pending) = self @@ -1823,8 +1883,13 @@ impl App { .find(|pending| pending.id == id) { pending.text = text; + pending.editable = editable; } else { - self.pending_steers.push_back(PendingSteer { id, text }); + if self.queue_focused && self.selected_steer.is_none() { + self.selected_steer = Some(id.clone()); + } + self.pending_steers + .push_back(PendingSteer { id, text, editable }); } } Update::UserMessage { @@ -1833,7 +1898,7 @@ impl App { images, append, } => { - self.pending_steers.retain(|pending| pending.id != id); + self.remove_pending_steer(&id); self.apply_message(id, text, images, append, MessageRole::User); } Update::AgentMessage { id, text, append } => { @@ -2127,6 +2192,11 @@ impl App { /// history and diagnostics remain useful, while transcript-derived state /// starts empty. pub fn start_session(&mut self, session_id: String) { + self.cancel_steer_edit(); + self.selected_steer = None; + self.queue_focused = false; + self.retired_steers.clear(); + self.steer_mutations.clear(); self.session_catalog_pending = false; self.session_id = Some(session_id); self.file_picker = None; @@ -2383,6 +2453,10 @@ impl App { kind: AttachmentKind, size: u64, ) { + if self.editing_steer() { + self.toast("pending-message edits are text-only"); + return; + } self.next_attachment += 1; let label = match kind { AttachmentKind::Image => "Image", @@ -2826,6 +2900,212 @@ impl App { Action::None } + pub fn editing_steer(&self) -> bool { + self.steer_edit.is_some() + } + + fn cancel_steer_edit(&mut self) { + if let Some(edit) = self.steer_edit.take() { + self.editor = edit.draft; + self.attachments = edit.attachments; + self.next_attachment = edit.next_attachment; + self.file_picker = None; + self.sync_command_completion(); + } + } + + fn remove_pending_steer(&mut self, id: &str) { + self.retired_steers.insert(id.to_owned()); + let index = self + .pending_steers + .iter() + .position(|pending| pending.id == id); + self.pending_steers.retain(|pending| pending.id != id); + if self.selected_steer.as_deref() == Some(id) { + self.selected_steer = index.and_then(|index| { + self.pending_steers + .get(index.min(self.pending_steers.len().saturating_sub(1))) + .map(|pending| pending.id.clone()) + }); + } + } + + pub fn begin_steer_mutation(&mut self, id: &str, text: Option) -> Option { + if self.steer_mutations.contains_key(id) { + self.toast("a change to this pending message is still in progress"); + return None; + } + if !self.pending_steers.iter().any(|pending| pending.id == id) { + self.toast("message is no longer pending"); + return None; + } + if text.is_some() + && self + .pending_steers + .iter() + .any(|pending| pending.id == id && !pending.editable) + { + self.toast("pending messages with media cannot be edited; removal is still available"); + return None; + } + self.next_steer_token += 1; + let token = self.next_steer_token; + let edit_token = self + .steer_edit + .as_ref() + .filter(|edit| edit.id == id) + .map(|edit| edit.token); + self.steer_mutations.insert( + id.to_owned(), + SteerMutation { + token, + edit_token, + text, + }, + ); + self.toast(if self.steer_mutations[id].text.is_some() { + "saving pending message…" + } else { + "removing pending message…" + }); + Some(token) + } + + fn finish_steer_mutation( + &mut self, + id: &str, + token: u64, + result: Result<(), SteerMutationError>, + ) { + if self + .steer_mutations + .get(id) + .is_none_or(|mutation| mutation.token != token) + { + return; + } + let mutation = self.steer_mutations.remove(id).expect("matched mutation"); + match result { + Ok(()) => { + if let Some(text) = mutation.text { + // Delivery may have won the race: update only an existing entry. + if let Some(pending) = self + .pending_steers + .iter_mut() + .find(|pending| pending.id == id) + { + pending.text = text.clone(); + } + // Esc/reopen can create another edit for the same ID while this + // request waits. Neither that edit nor newly typed text belongs + // to this completion. + if self + .steer_edit + .as_ref() + .is_some_and(|edit| Some(edit.token) == mutation.edit_token) + { + if self.editor.text() == text { + self.cancel_steer_edit(); + } else { + self.toast("earlier revision saved; current edit is still unsaved"); + } + } + } else { + self.steer_revoked(id); + } + } + Err(error) => self.steer_mutation_failed(id, error.message, error.unavailable), + } + } + + pub fn steer_revoked(&mut self, id: &str) { + self.remove_pending_steer(id); + } + + pub fn steer_mutation_failed(&mut self, id: &str, error: String, unavailable: bool) { + if unavailable { + self.remove_pending_steer(id); + self.toast(if self.editing_steer() { + "message is no longer pending; copy your edit or Esc to restore draft" + } else { + "message is no longer pending; it cannot be removed" + }); + return; + } + // Keep the replacement composer and its parked draft intact for retry/copy/Esc. + self.toast(error); + } + + fn handle_steer_selection(&mut self, key: KeyEvent) -> Action { + if matches!(key.code, KeyCode::Esc | KeyCode::F(2)) { + self.selected_steer = None; + self.queue_focused = false; + return Action::None; + } + // Keep focus even if delivery drained the queue. A stale Enter/Delete + // must not unexpectedly send or modify the parked composer draft. + let Some(index) = self + .pending_steers + .iter() + .position(|pending| Some(&pending.id) == self.selected_steer.as_ref()) + else { + self.selected_steer = None; + return Action::None; + }; + match key.code { + KeyCode::Up | KeyCode::Down => { + let next = if key.code == KeyCode::Up { + index.saturating_sub(1) + } else { + (index + 1).min(self.pending_steers.len() - 1) + }; + self.selected_steer = Some(self.pending_steers[next].id.clone()); + } + KeyCode::Delete => { + if self + .steer_mutations + .contains_key(&self.pending_steers[index].id) + { + self.toast("a change to this pending message is still in progress"); + return Action::None; + } + return Action::RevokeSteer { + id: self.pending_steers[index].id.clone(), + }; + } + KeyCode::Enter => { + if !self.can_replace_steer { + self.toast("this agent does not support editing pending messages"); + return Action::None; + } + let pending = &self.pending_steers[index]; + if !pending.editable { + self.toast( + "pending messages with media cannot be edited; removal is still available", + ); + return Action::None; + } + let mut editor = Editor::default(); + editor.insert_str(&pending.text); + self.next_steer_token += 1; + self.steer_edit = Some(SteerEdit { + id: pending.id.clone(), + token: self.next_steer_token, + draft: std::mem::replace(&mut self.editor, editor), + attachments: std::mem::take(&mut self.attachments), + next_attachment: self.next_attachment, + }); + self.next_attachment = 0; + self.selected_steer = None; + self.queue_focused = false; + self.file_picker = None; + self.last_key = None; + } + _ => {} + } + Action::None + } + /// Applies a key press, returning work for the event loop. pub fn handle_key(&mut self, key: KeyEvent) -> Action { if key.kind != KeyEventKind::Press { @@ -2844,6 +3124,68 @@ impl App { if self.effort_dialog.is_some() { return self.handle_effort_key(key); } + 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 + // ctrl+c leaves, which takes the agent process with it. + if self.phase == Phase::Cancelling { + return Action::Quit; + } + if self.working() { + return self.request_cancel(); + } + // A stray ctrl+c should not throw away a half-written prompt. + if self.editor.text().is_empty() { + return Action::Quit; + } + self.editor.clear(); + self.toast("prompt cleared — ctrl+c again to quit"); + return Action::None; + } + if key.code == KeyCode::Char('d') + && key.modifiers.contains(KeyModifiers::CONTROL) + && self.editor.is_empty() + { + self.file_picker = None; + return Action::Quit; + } + // Queue focus owns composer keys, not global task, view, or copy actions. + // Ctrl+K is global only when it cancels background work; otherwise it + // must not fall through and delete text from the parked composer. + let global_key = match key.code { + KeyCode::Char('b') => key.modifiers == KeyModifiers::SUPER, + KeyCode::Char('k') => { + key.modifiers.contains(KeyModifiers::CONTROL) + && self + .focus_call() + .is_some_and(|call| call.backgrounded && call.running()) + } + KeyCode::Char('y' | 'r' | 'l' | 'o' | 't') | KeyCode::Home | KeyCode::End => { + key.modifiers.contains(KeyModifiers::CONTROL) + } + KeyCode::Up | KeyCode::Down => key.modifiers.contains(KeyModifiers::SHIFT), + KeyCode::PageUp | KeyCode::PageDown => true, + _ => false, + }; + if self.queue_focused && !global_key { + return self.handle_steer_selection(key); + } + if key.code == KeyCode::F(2) && !self.editing_steer() { + self.queue_focused = true; + self.selected_steer = self + .pending_steers + .front() + .map(|pending| pending.id.clone()); + if self.selected_steer.is_none() { + self.toast("no pending messages"); + } + self.file_picker = None; + return Action::None; + } + if key.code == KeyCode::Esc && self.editing_steer() { + self.cancel_steer_edit(); + return Action::None; + } // 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); @@ -2917,22 +3259,6 @@ impl App { }; return Action::DetachCompose(call_id); } - KeyCode::Char('c') if control => { - // A turn that will not stop must still be escapable: the second - // ctrl+c leaves, which takes the agent process with it. - if self.phase == Phase::Cancelling { - return Action::Quit; - } - if self.working() { - return self.request_cancel(); - } - // A stray ctrl+c should not throw away a half-written prompt. - if self.editor.text().is_empty() { - return Action::Quit; - } - self.editor.clear(); - self.toast("prompt cleared — ctrl+c again to quit"); - } KeyCode::Char('k') if control && self @@ -2945,7 +3271,6 @@ impl App { .unwrap_or_default(), ); } - KeyCode::Char('d') if control && self.editor.is_empty() => return Action::Quit, KeyCode::Char('y') if control => { if let Some(text) = self.selection_text() { self.toast("copied selection"); @@ -2968,6 +3293,30 @@ impl App { if self.editor.is_empty() { return Action::None; } + if let Some(edit) = &self.steer_edit { + if self.steer_mutations.contains_key(&edit.id) { + self.toast("a change to this pending message is still in progress"); + return Action::None; + } + if !self + .pending_steers + .iter() + .any(|pending| pending.id == edit.id) + { + self.toast( + "message is no longer pending; copy your edit or Esc to restore draft", + ); + return Action::None; + } + if !self.can_replace_steer { + self.toast("this agent does not support editing pending messages"); + return Action::None; + } + return Action::ReplaceSteer { + id: edit.id.clone(), + text: self.editor.text().to_owned(), + }; + } let inject = self.working(); if inject { if self.phase != Phase::Working { @@ -3157,7 +3506,7 @@ impl App { .next_back() .is_some_and(char::is_whitespace); self.editor.insert_char('@'); - if eligible { + if eligible && !self.editing_steer() { return self.start_file_picker(at..at + 1); } } @@ -4657,6 +5006,7 @@ mod tests { ); app.apply(Update::SteerAccepted { + editable: true, id: "injected-1".into(), text: prompt.text, }); @@ -4680,6 +5030,558 @@ mod tests { assert!(app.working()); } + fn queued_app() -> App { + let mut app = app(); + app.can_steer = true; + app.can_replace_steer = true; + app.apply(Update::State(StateUpdate::Running( + RunningStateUpdate::new(), + ))); + for id in ["a", "b", "c"] { + app.apply(Update::SteerAccepted { + editable: true, + id: id.into(), + text: format!("pending {id}"), + }); + } + app + } + + fn begin_steer_edit(app: &mut App) { + app.handle_key(press(KeyCode::F(2))); + app.handle_key(press(KeyCode::Enter)); + assert!(app.editing_steer()); + } + + #[test] + fn queued_focus_preserves_global_task_view_and_copy_shortcuts() { + for drained in [false, true] { + let mut app = queued_app(); + app.paste("draft"); + app.latest_agent_source = "answer".into(); + app.handle_key(press(KeyCode::F(2))); + if drained { + app.pending_steers.clear(); + app.selected_steer = None; + } + // Without background work Ctrl+K is an editor key, so suppress it. + app.handle_key(modified_press(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert_eq!(app.editor.text(), "draft"); + app.handle_key(modified_press(KeyCode::Char('l'), KeyModifiers::CONTROL)); + assert!(app.show_logs); + assert!(matches!( + app.handle_key(modified_press(KeyCode::Char('y'), KeyModifiers::CONTROL)), + Action::Copy(text) if text == "answer" + )); + app.apply(Update::ToolStarted { + id: "foreground".into(), + title: "compose".into(), + kind: ToolKind::Other, + script: None, + backgrounded: false, + }); + assert!(matches!( + app.handle_key(modified_press(KeyCode::Char('b'), KeyModifiers::SUPER)), + Action::DetachCompose(id) if id == "foreground" + )); + app.apply(Update::ToolStarted { + id: "background".into(), + title: "compose".into(), + kind: ToolKind::Other, + script: None, + backgrounded: true, + }); + assert!(matches!( + app.handle_key(modified_press(KeyCode::Char('k'), KeyModifiers::CONTROL)), + Action::CancelBackground(id) if id == "background" + )); + assert!(app.queue_focused); + assert_eq!(app.editor.text(), "draft"); + } + } + + #[test] + fn queued_selector_navigation_and_revoke_preserve_order_and_draft() { + let mut app = queued_app(); + app.paste("draft"); + app.handle_key(press(KeyCode::F(2))); + app.handle_key(press(KeyCode::Up)); + assert_eq!(app.selected_steer.as_deref(), Some("a")); + app.handle_key(press(KeyCode::Down)); + let Action::RevokeSteer { id } = app.handle_key(press(KeyCode::Delete)) else { + panic!("expected revoke"); + }; + assert_eq!(id, "b"); + assert_eq!( + app.pending_steers.len(), + 3, + "wait for server acknowledgment" + ); + app.steer_mutation_failed(&id, "temporary failure".into(), false); + assert_eq!(app.pending_steers.len(), 3); + assert_eq!(app.selected_steer.as_deref(), Some("b")); + app.steer_revoked(&id); + assert_eq!( + app.pending_steers + .iter() + .map(|p| p.id.as_str()) + .collect::>(), + ["a", "c"] + ); + assert_eq!(app.selected_steer.as_deref(), Some("c")); + app.handle_key(press(KeyCode::Down)); + assert_eq!(app.selected_steer.as_deref(), Some("c")); + app.handle_key(press(KeyCode::Esc)); + assert!(app.selected_steer.is_none()); + assert!(app.working(), "Esc in selector must not cancel the turn"); + assert_eq!(app.editor.text(), "draft"); + app.apply(Update::SteerAccepted { + editable: true, + id, + text: "late acceptance".into(), + }); + assert_eq!(app.pending_steers.len(), 2, "revoked IDs stay retired"); + } + + #[test] + fn queued_edit_cancel_restores_draft_cursor_and_attachments() { + let mut app = queued_app(); + app.paste("draft"); + app.attach( + PathBuf::from("image.png"), + "image/png", + AttachmentKind::Image, + 3, + ); + app.editor.move_left(); + let draft = app.editor.text().to_owned(); + let cursor = app.editor.cursor(); + let attachments = app.attachments.clone(); + let sequence = app.next_attachment; + begin_steer_edit(&mut app); + assert_eq!(app.editor.text(), "pending a"); + assert!(app.attachments.is_empty()); + app.paste(" revised"); + app.attach( + PathBuf::from("other.png"), + "image/png", + AttachmentKind::Image, + 4, + ); + assert!(app.attachments.is_empty(), "replacement is text-only"); + app.handle_key(press(KeyCode::Esc)); + assert!(!app.editing_steer()); + assert_eq!(app.editor.text(), draft); + assert_eq!(app.editor.cursor(), cursor); + assert_eq!(app.attachments, attachments); + assert_eq!(app.next_attachment, sequence); + assert_eq!(app.pending_steers[0].text, "pending a"); + assert!(app.working()); + } + + #[test] + fn queued_edit_save_keeps_id_order_and_restores_draft() { + let mut app = queued_app(); + app.paste("next draft"); + app.handle_key(press(KeyCode::F(2))); + app.handle_key(press(KeyCode::Down)); + app.handle_key(press(KeyCode::Enter)); + app.paste(" revised"); + let Action::ReplaceSteer { id, text } = app.handle_key(press(KeyCode::Enter)) else { + panic!("expected replacement"); + }; + assert_eq!(id, "b"); + assert_eq!(text, "pending b revised"); + assert_eq!(app.pending_steers[1].text, "pending b"); + assert_eq!(app.editor.text(), text, "keep edit until acknowledged"); + let token = app.begin_steer_mutation(&id, Some(text)).unwrap(); + app.apply(Update::SteerMutationFinished { + id: id.clone(), + token, + result: Ok(()), + }); + assert_eq!( + app.pending_steers + .iter() + .map(|p| p.id.as_str()) + .collect::>(), + ["a", "b", "c"] + ); + assert_eq!(app.pending_steers[1].text, "pending b revised"); + assert_eq!(app.editor.text(), "next draft"); + assert!(!app.editing_steer()); + } + + #[test] + fn queued_edit_failure_keeps_both_drafts_and_allows_retry() { + let mut app = queued_app(); + app.paste("original draft"); + begin_steer_edit(&mut app); + app.paste(" revision"); + app.steer_mutation_failed("a", "temporary failure".into(), false); + assert_eq!(app.editor.text(), "pending a revision"); + assert!(app.editing_steer()); + assert_eq!(app.pending_steers[0].text, "pending a"); + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::ReplaceSteer { .. } + )); + app.handle_key(press(KeyCode::Esc)); + assert_eq!(app.editor.text(), "original draft"); + } + + #[test] + fn queued_edit_delivery_races_never_resurrect_messages() { + for unavailable_error in [false, true] { + let mut app = queued_app(); + app.paste("original draft"); + begin_steer_edit(&mut app); + app.paste(" revision"); + let token = app + .begin_steer_mutation("a", Some(app.editor.text().to_owned())) + .unwrap(); + if unavailable_error { + app.steer_mutation_failed("a", "unknown message".into(), true); + } else { + app.apply(Update::UserMessage { + id: "a".into(), + text: "pending a".into(), + images: Vec::new(), + append: false, + }); + } + assert_eq!(app.editor.text(), "pending a revision"); + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::None + )); + assert!(!app.pending_steers.iter().any(|pending| pending.id == "a")); + app.apply(Update::SteerMutationFinished { + id: "a".into(), + token, + result: Ok(()), + }); + app.apply(Update::SteerAccepted { + editable: true, + id: "a".into(), + text: "late acceptance".into(), + }); + assert!(!app.pending_steers.iter().any(|pending| pending.id == "a")); + assert_eq!(app.editor.text(), "original draft"); + } + } + + #[test] + fn queued_delivery_reselects_neighbor_and_session_switch_clears_edit() { + let mut app = queued_app(); + app.paste("draft"); + app.handle_key(press(KeyCode::F(2))); + app.apply(Update::UserMessage { + id: "a".into(), + text: "pending a".into(), + images: Vec::new(), + append: false, + }); + assert_eq!(app.selected_steer.as_deref(), Some("b")); + app.handle_key(press(KeyCode::Enter)); + app.start_session("other-session".into()); + assert!(!app.editing_steer()); + assert!(app.selected_steer.is_none()); + assert!(app.pending_steers.is_empty()); + assert!(app.retired_steers.is_empty()); + assert_eq!(app.editor.text(), "draft"); + } + + #[test] + fn queued_edit_capability_is_required_but_revoke_is_always_available() { + let mut app = queued_app(); + app.can_replace_steer = false; + app.paste("draft"); + app.handle_key(press(KeyCode::F(2))); + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::None + )); + assert!(!app.editing_steer()); + assert_eq!(app.editor.text(), "draft"); + assert!(matches!( + app.handle_key(press(KeyCode::Delete)), + Action::RevokeSteer { .. } + )); + app.can_replace_steer = true; + app.handle_key(press(KeyCode::Enter)); + app.can_replace_steer = false; + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::None + )); + assert_eq!(app.editor.text(), "pending a"); + app.handle_key(press(KeyCode::Esc)); + assert_eq!(app.editor.text(), "draft"); + } + + #[test] + fn queued_delivery_of_last_selection_never_sends_or_deletes_the_draft() { + let mut app = queued_app(); + app.paste("draft"); + app.handle_key(press(KeyCode::F(2))); + for id in ["a", "b", "c"] { + app.apply(Update::UserMessage { + id: id.into(), + text: id.into(), + images: Vec::new(), + append: false, + }); + } + assert!(app.queue_focused); + assert!(app.selected_steer.is_none()); + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::None + )); + assert!(matches!( + app.handle_key(press(KeyCode::Delete)), + Action::None + )); + assert_eq!(app.editor.text(), "draft"); + app.handle_key(press(KeyCode::Esc)); + assert!(!app.queue_focused); + assert!(app.working()); + } + + #[test] + fn queued_edit_stays_recoverable_when_turn_finishes() { + let mut app = queued_app(); + app.paste("draft"); + begin_steer_edit(&mut app); + app.paste(" revised"); + app.apply(Update::State(StateUpdate::Idle( + IdleStateUpdate::new().stop_reason(StopReason::EndTurn), + ))); + assert!(app.pending_steers.is_empty()); + assert!(app.editing_steer()); + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::None + )); + assert_eq!(app.editor.text(), "pending a revised"); + app.handle_key(press(KeyCode::Esc)); + assert_eq!(app.editor.text(), "draft"); + } + + #[test] + fn queued_ctrl_c_cancels_then_quits_even_after_delivery_empties_selection() { + for empty in [false, true] { + let mut app = queued_app(); + app.paste("draft"); + app.handle_key(press(KeyCode::F(2))); + if empty { + for id in ["a", "b", "c"] { + app.steer_revoked(id); + } + } + assert!(matches!( + app.handle_key(modified_press(KeyCode::Char('c'), KeyModifiers::CONTROL)), + Action::Cancel + )); + assert_eq!(app.editor.text(), "draft"); + assert!(matches!( + app.handle_key(modified_press(KeyCode::Char('c'), KeyModifiers::CONTROL)), + Action::Quit + )); + } + } + + #[test] + fn queued_ctrl_c_retains_idle_clear_then_quit_semantics() { + let mut app = app(); + app.paste("draft"); + app.handle_key(press(KeyCode::F(2))); + assert!(matches!( + app.handle_key(modified_press(KeyCode::Char('c'), KeyModifiers::CONTROL)), + Action::None + )); + assert!(app.editor.is_empty()); + assert!(matches!( + app.handle_key(modified_press(KeyCode::Char('c'), KeyModifiers::CONTROL)), + Action::Quit + )); + } + + #[test] + fn queued_ctrl_d_keeps_empty_composer_exit_semantics() { + let mut app = queued_app(); + app.handle_key(press(KeyCode::F(2))); + assert!(matches!( + app.handle_key(modified_press(KeyCode::Char('d'), KeyModifiers::CONTROL)), + Action::Quit + )); + app.paste("draft"); + assert!(matches!( + app.handle_key(modified_press(KeyCode::Char('d'), KeyModifiers::CONTROL)), + Action::None + )); + assert_eq!(app.editor.text(), "draft"); + } + + #[test] + fn queued_inflight_mutations_reject_duplicates_and_keep_newer_edit_text() { + let mut app = queued_app(); + app.paste("draft"); + begin_steer_edit(&mut app); + let text = app.editor.text().to_owned(); + let token = app.begin_steer_mutation("a", Some(text.clone())).unwrap(); + assert!(app.begin_steer_mutation("a", Some(text.clone())).is_none()); + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::None + )); + app.paste(" newer revision"); + app.apply(Update::SteerMutationFinished { + id: "a".into(), + token, + result: Ok(()), + }); + assert_eq!(app.pending_steers[0].text, text); + assert_eq!(app.editor.text(), "pending a newer revision"); + assert!(app.editing_steer()); + app.handle_key(press(KeyCode::Esc)); + assert_eq!(app.editor.text(), "draft"); + app.handle_key(press(KeyCode::F(2))); + app.begin_steer_mutation("a", None).unwrap(); + assert!(matches!( + app.handle_key(press(KeyCode::Delete)), + Action::None + )); + } + + #[test] + fn queued_old_completion_cannot_close_reopened_same_id_edit_or_new_request() { + let mut app = queued_app(); + app.paste("draft"); + begin_steer_edit(&mut app); + let token = app + .begin_steer_mutation("a", Some(app.editor.text().to_owned())) + .unwrap(); + app.handle_key(press(KeyCode::Esc)); + begin_steer_edit(&mut app); // Identical text and ID, but a different edit. + app.apply(Update::SteerMutationFinished { + id: "a".into(), + token, + result: Ok(()), + }); + assert!(app.editing_steer()); + let next = app + .begin_steer_mutation("a", Some("replacement".into())) + .unwrap(); + app.apply(Update::SteerMutationFinished { + id: "a".into(), + token, + result: Ok(()), + }); + assert_eq!(app.steer_mutations["a"].token, next); + assert_eq!(app.pending_steers[0].text, "pending a"); + app.handle_key(press(KeyCode::Esc)); + assert_eq!(app.editor.text(), "draft"); + } + + #[test] + fn queued_async_error_unlocks_retry_without_losing_either_draft() { + let mut app = queued_app(); + app.paste("draft"); + begin_steer_edit(&mut app); + app.paste(" revision"); + let token = app + .begin_steer_mutation("a", Some(app.editor.text().to_owned())) + .unwrap(); + app.apply(Update::SteerMutationFinished { + id: "a".into(), + token, + result: Err(super::SteerMutationError { + message: "retry".into(), + unavailable: false, + }), + }); + assert!(app.steer_mutations.is_empty()); + assert_eq!(app.editor.text(), "pending a revision"); + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::ReplaceSteer { .. } + )); + app.handle_key(press(KeyCode::Esc)); + assert_eq!(app.editor.text(), "draft"); + } + + #[test] + fn queued_session_switch_clears_inflight_state_and_rejects_old_completion() { + let mut app = queued_app(); + app.paste("draft"); + begin_steer_edit(&mut app); + let token = app + .begin_steer_mutation("a", Some("old replacement".into())) + .unwrap(); + app.start_session("new-session".into()); + assert!(app.steer_mutations.is_empty()); + app.apply(Update::SteerAccepted { + editable: true, + id: "a".into(), + text: "new session message".into(), + }); + begin_steer_edit(&mut app); + let next = app + .begin_steer_mutation("a", Some("new replacement".into())) + .unwrap(); + app.apply(Update::SteerMutationFinished { + id: "a".into(), + token, + result: Ok(()), + }); + assert_eq!(app.steer_mutations["a"].token, next); + assert_eq!(app.pending_steers[0].text, "new session message"); + assert_eq!(app.editor.text(), "new session message"); + } + + #[test] + fn queued_media_steering_submits_but_only_allows_removal() { + let mut app = queued_app(); + app.attach( + PathBuf::from("image.png"), + "image/png", + AttachmentKind::Image, + 3, + ); + let Action::Submit { prompt, inject } = app.handle_key(press(KeyCode::Enter)) else { + panic!("expected media steer submission"); + }; + assert!(inject); + assert_eq!(prompt.attachments.len(), 1); + app.apply(Update::SteerAccepted { + id: "media".into(), + text: prompt.text, + editable: false, + }); + app.selected_steer = Some("media".into()); + app.queue_focused = true; + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::None + )); + assert!(!app.editing_steer()); + assert!( + app.toast_text() + .unwrap() + .contains("with media cannot be edited") + ); + assert!( + app.begin_steer_mutation("media", Some("text replacement".into())) + .is_none() + ); + assert!( + matches!(app.handle_key(press(KeyCode::Delete)), Action::RevokeSteer { id } if id == "media") + ); + assert!(app.begin_steer_mutation("media", None).is_some()); + } + #[test] fn active_text_is_preserved_when_steering_is_not_advertised() { let mut app = app(); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index dfe45c4..730e84f 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -968,6 +968,9 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( a2a, ); app.can_steer = can_steer; + app.can_replace_steer = supports_pending_replace( + initialized.capabilities.session.as_ref().and_then(|session| session.inject.as_ref()), + ); app.auth_methods = auth_methods; let mut events = EventStream::new(); let mut ticker = tokio::time::interval(TICK); @@ -1183,6 +1186,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( continue; } }; + let editable = pending_steer_is_editable(&blocks); app.clear_attachments(); let outcome = if inject { connection @@ -1205,6 +1209,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( Ok(Some(message_id)) => app.apply(Update::SteerAccepted { id: message_id.to_string(), text: prompt.text, + editable, }), Ok(None) => {} Err(error) => { @@ -1214,6 +1219,48 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( } } } + Action::ReplaceSteer { id, text } => { + let Ok(route) = transition_session.lock() else { + app.note("could not start pending-message edit"); + continue; + }; + let generation = route.generation; + drop(route); + let Some(token) = app.begin_steer_mutation(&id, Some(text.clone())) else { continue; }; + let connection = connection.clone(); + let session_id = session_id.clone(); + let request_id = id.clone(); + spawn_steer_mutation(generation, id, token, updates_tx.clone(), async move { + connection + .send_request(wire::ReplaceInjectSessionRequest::new( + session_id, + request_id, + vec![wire::ContentBlock::Text(wire::TextContent::new(text))], + )) + .block_task() + .await + .map(|_| ()) + }); + } + Action::RevokeSteer { id } => { + let Ok(route) = transition_session.lock() else { + app.note("could not start pending-message removal"); + continue; + }; + let generation = route.generation; + drop(route); + let Some(token) = app.begin_steer_mutation(&id, None) else { continue; }; + let connection = connection.clone(); + let session_id = session_id.clone(); + let request_id = id.clone(); + spawn_steer_mutation(generation, id, token, updates_tx.clone(), async move { + connection + .send_request(wire::RevokeInjectSessionRequest::new(session_id, request_id)) + .block_task() + .await + .map(|_| ()) + }); + } Action::New(first_prompt) => { connection .send_request(CloseSessionRequest::new(session_id.clone())) @@ -1845,13 +1892,59 @@ fn osc52(text: &str) -> String { format!("\x1b]52;c;{}\x07", STANDARD.encode(text)) } +/// Await delivery-sensitive ACP mutations off the terminal event loop. Both +/// session generation and the app's mutation token must match at completion. +fn spawn_steer_mutation( + generation: u64, + id: String, + token: u64, + updates: mpsc::UnboundedSender, + request: impl std::future::Future> + + Send + + 'static, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let result = request.await.map_err(|error| app::SteerMutationError { + unavailable: pending_message_unavailable(&error), + message: format!( + "pending-message change failed: {}; retry or Esc to restore draft", + error.message + ), + }); + let _ = updates.send(QueuedUpdate::for_session( + generation, + Update::SteerMutationFinished { id, token, result }, + )); + }) +} + +fn supports_pending_replace(inject: Option<&wire::SessionInjectCapabilities>) -> bool { + inject + .and_then(|inject| inject.pending.as_ref()) + .is_some_and(|pending| pending.replace == Some(true)) +} + +fn pending_message_unavailable(error: &agent_client_protocol::Error) -> bool { + matches!( + error + .data + .as_ref() + .and_then(|data| data.get("reason")) + .and_then(serde_json::Value::as_str), + Some("already_delivered" | "unknown_message_id") + ) +} + /// 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), Event::Mouse(mouse) => app.handle_mouse(mouse), Event::Paste(text) => { - if app.session_rename_active() { + if app.queue_focused { + return Action::None; + } + if app.session_rename_active() || app.editing_steer() { app.paste(&text); } else if let Some(attachments) = attachments_from_paste(&app.root, &text) { app.prune_attachments(); @@ -1934,6 +2027,12 @@ fn media_attachment(root: &Path, value: &str) -> Option { }) } +fn pending_steer_is_editable(blocks: &[ContentBlock]) -> bool { + blocks + .iter() + .all(|block| matches!(block, ContentBlock::Text(_))) +} + fn prompt_blocks(prompt: &SubmittedPrompt) -> Result, String> { let mut total = 0_u64; let mut media = Vec::with_capacity(prompt.attachments.len()); @@ -2546,7 +2645,7 @@ mod tests { }; use crate::{ tools::mcp::CredentialStorage, - tui::app::{App, SessionDialog, SessionRename, SubmittedPrompt, Update}, + tui::app::{Action, App, SessionDialog, SessionRename, SubmittedPrompt, Update}, }; fn command_args(command: &tokio::process::Command) -> Vec { @@ -3114,6 +3213,224 @@ mod tests { assert_eq!(attachments[0].mime_type, "image/png"); } + #[tokio::test] + async fn queued_mutation_wait_does_not_block_delivery_updates_or_cancel() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let mut app = App::new( + PathBuf::from("/tmp"), + "provider".into(), + "model".into(), + "a2a".into(), + ); + app.apply(Update::State(StateUpdate::Running( + RunningStateUpdate::new(), + ))); + app.apply(Update::SteerAccepted { + editable: true, + id: "a".into(), + text: "pending".into(), + }); + app.paste("draft"); + app.handle_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + let token = app.begin_steer_mutation("a", None).unwrap(); + let route = Arc::new(Mutex::new(super::ActiveSessionRoute { + id: "session".into(), + generation: 1, + })); + let (updates, mut receiver) = tokio::sync::mpsc::unbounded_channel(); + let (release, waiting) = tokio::sync::oneshot::channel(); + let task = super::spawn_steer_mutation(1, "a".into(), token, updates.clone(), async move { + waiting.await.unwrap(); + Ok(()) + }); + tokio::task::yield_now().await; + assert!(!task.is_finished()); + updates + .send(QueuedUpdate::for_session( + 1, + Update::UserMessage { + id: "a".into(), + text: "delivered".into(), + images: vec![], + append: false, + }, + )) + .unwrap(); + let first = receiver.recv().await.unwrap(); + apply_pending_updates(&mut app, &route, &mut receiver, first); + assert!(app.pending_steers.is_empty()); + assert!(matches!( + app.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + Action::Cancel + )); + assert_eq!(app.editor.text(), "draft"); + assert!(!task.is_finished()); + release.send(()).unwrap(); + task.await.unwrap(); + let completion = receiver.recv().await.unwrap(); + apply_pending_updates(&mut app, &route, &mut receiver, completion); + assert!(app.pending_steers.is_empty()); + assert_eq!(app.editor.text(), "draft"); + } + + #[tokio::test] + async fn queued_mutation_completion_is_scoped_to_its_starting_session_generation() { + let route = Arc::new(Mutex::new(super::ActiveSessionRoute { + id: "session".into(), + generation: 1, + })); + let (updates, mut receiver) = tokio::sync::mpsc::unbounded_channel(); + let (release, waiting) = tokio::sync::oneshot::channel(); + let task = super::spawn_steer_mutation(1, "a".into(), 7, updates, async move { + waiting.await.unwrap(); + Err(agent_client_protocol::Error::invalid_params() + .data(json!({"reason": "already_delivered"}))) + }); + super::transition_route(&route, "other-session".into()); + release.send(()).unwrap(); + task.await.unwrap(); + let completion = receiver.recv().await.unwrap(); + assert_eq!(completion.generation, Some(1)); + assert!( + matches!(&completion.update, Update::SteerMutationFinished { token: 7, result: Err(error), .. } if error.unavailable) + ); + assert!(accept_queued_update(&route, completion).is_none()); + } + + #[test] + fn queued_media_editability_uses_actual_submitted_content() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + for (name, mime, kind) in [ + ("image.png", "image/png", super::AttachmentKind::Image), + ("audio.mp3", "audio/mpeg", super::AttachmentKind::Audio), + ] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(name); + std::fs::write(&path, b"media").unwrap(); + let mut app = App::new( + directory.path().into(), + "provider".into(), + "model".into(), + "a2a".into(), + ); + app.can_steer = true; + app.can_replace_steer = true; + app.apply(Update::State(StateUpdate::Running( + RunningStateUpdate::new(), + ))); + app.attach(path, mime, kind, 5); + let Action::Submit { prompt, inject } = + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + else { + panic!("media steer must remain supported"); + }; + assert!(inject); + let blocks = prompt_blocks(&prompt).unwrap(); + assert_eq!(blocks.len(), 2); + let editable = super::pending_steer_is_editable(&blocks); + assert!(!editable); + app.clear_attachments(); + app.apply(Update::SteerAccepted { + id: "media".into(), + text: prompt.text, + editable, + }); + app.handle_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + assert!(matches!( + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Action::None + )); + assert!(!app.editing_steer()); + assert!(matches!( + app.handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)), + Action::RevokeSteer { .. } + )); + } + } + + #[test] + fn queued_stale_attachment_metadata_does_not_disable_plain_text_editing() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let mut app = App::new( + PathBuf::from("/tmp"), + "provider".into(), + "model".into(), + "a2a".into(), + ); + app.can_steer = true; + app.can_replace_steer = true; + app.apply(Update::State(StateUpdate::Running( + RunningStateUpdate::new(), + ))); + app.attach( + PathBuf::from("nonexistent.png"), + "image/png", + super::AttachmentKind::Image, + 5, + ); + app.editor.clear(); // Remove the attachment placeholder, leaving stale metadata. + app.paste("plain steer"); + assert_eq!(app.attachments.len(), 1); + let Action::Submit { prompt, inject } = + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + else { + panic!("expected plain steer"); + }; + assert!(inject); + assert!(prompt.attachments.is_empty()); + let blocks = prompt_blocks(&prompt).unwrap(); + let editable = super::pending_steer_is_editable(&blocks); + assert!(editable); + app.clear_attachments(); + app.apply(Update::SteerAccepted { + id: "plain".into(), + text: prompt.text, + editable, + }); + app.handle_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(app.editing_steer()); + } + + #[test] + fn queued_replace_capability_requires_explicit_true() { + use agent_client_protocol::schema::v2::{ + SessionInjectCapabilities, SessionInjectPendingCapabilities, + }; + assert!(!super::supports_pending_replace(None)); + for pending in [ + None, + Some(SessionInjectPendingCapabilities::new()), + Some(SessionInjectPendingCapabilities::new().replace(false)), + ] { + let inject = SessionInjectCapabilities::new(vec![]).pending(pending); + assert!(!super::supports_pending_replace(Some(&inject))); + } + let inject = SessionInjectCapabilities::new(vec![]) + .pending(SessionInjectPendingCapabilities::new().replace(true)); + assert!(super::supports_pending_replace(Some(&inject))); + } + + #[test] + fn queued_mutation_errors_retire_only_known_missing_or_delivered_ids() { + for reason in [ + "already_delivered", + "unknown_message_id", + "replace_not_supported", + "temporary_failure", + ] { + let error = agent_client_protocol::Error::invalid_params() + .data(json!({"reason": reason, "messageId": "a"})); + assert_eq!( + super::pending_message_unavailable(&error), + matches!(reason, "already_delivered" | "unknown_message_id") + ); + } + assert!(!super::pending_message_unavailable( + &agent_client_protocol::Error::invalid_params() + )); + } + #[test] fn multiple_dropped_paths_become_attachments() { let directory = tempfile::tempdir().unwrap(); @@ -3162,6 +3479,39 @@ mod tests { assert_eq!(app.attachments.len(), MAX_ATTACHMENTS); } + #[test] + fn queued_edit_media_paste_remains_text_and_preserves_original_attachments() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("image.png"); + std::fs::write(&path, b"png").unwrap(); + let mut app = App::new( + directory.path().into(), + "provider".into(), + "model".into(), + "a2a".into(), + ); + app.can_replace_steer = true; + app.apply(Update::SteerAccepted { + editable: true, + id: "a".into(), + text: "pending".into(), + }); + handle(&mut app, Event::Paste(path.display().to_string())); + let draft = app.editor.text().to_owned(); + let attachments = app.attachments.clone(); + app.handle_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + handle(&mut app, Event::Paste("ignored while selecting".into())); + assert_eq!(app.editor.text(), draft); + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + handle(&mut app, Event::Paste(path.display().to_string())); + assert!(app.attachments.is_empty()); + assert!(app.editor.text().contains(path.to_str().unwrap())); + app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(app.attachments, attachments); + assert_eq!(app.editor.text(), draft); + } + #[test] fn session_rename_paste_is_not_interpreted_as_an_attachment() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 0c3e90f..031075c 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -78,6 +78,8 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRuntime) { && available_start_prompt_rows >= START_MIN_PROMPT_ROWS && app.blocks.is_empty() && app.pending_steers.is_empty() + && !app.editing_steer() + && !app.queue_focused && !app.show_logs; let (prompt_area, prompt_viewport, picker_below) = if show_start { @@ -1936,7 +1938,13 @@ fn draw_pending_steers(frame: &mut Frame<'_>, app: &App, area: Rect) { } let mut lines = Vec::with_capacity(visible); - let skip = if app.pending_steers.len() > visible && visible > 1 { + let selected = app + .pending_steers + .iter() + .position(|pending| app.selected_steer.as_deref() == Some(pending.id.as_str())); + let skip = if let Some(selected) = selected { + selected.saturating_sub(visible - 1) + } else if app.pending_steers.len() > visible && visible > 1 { let hidden = app.pending_steers.len() - (visible - 1); lines.push(Line::from(Span::styled( format!(" … {hidden} earlier pending"), @@ -1946,18 +1954,42 @@ fn draw_pending_steers(frame: &mut Frame<'_>, app: &App, area: Rect) { } else { app.pending_steers.len().saturating_sub(visible) }; - lines.extend(app.pending_steers.iter().skip(skip).map(|pending| { - let text = pending - .text - .split_whitespace() - .collect::>() - .join(" "); - Line::from(vec![ - Span::styled(" › ", theme::bold(theme::user_color())), - Span::styled(text, theme::bold(theme::text_color())), - Span::styled(" · pending", theme::faint()), - ]) - })); + lines.extend( + app.pending_steers + .iter() + .skip(skip) + .take(visible) + .map(|pending| { + let text = pending + .text + .split_whitespace() + .collect::>() + .join(" "); + Line::from(vec![ + Span::styled( + if app.selected_steer.as_deref() == Some(pending.id.as_str()) { + " ▶ " + } else { + " › " + }, + theme::bold(theme::user_color()), + ), + Span::styled(text, theme::bold(theme::text_color())), + Span::styled( + if app.selected_steer.as_deref() == Some(pending.id.as_str()) { + format!( + " · pending [{}/{}]", + selected.unwrap_or(0) + 1, + app.pending_steers.len() + ) + } else { + " · pending".to_owned() + }, + theme::faint(), + ), + ]) + }), + ); frame.render_widget(Paragraph::new(lines), area); } @@ -2023,6 +2055,11 @@ fn draw_prompt(frame: &mut Frame<'_>, app: &App, area: Rect) -> PromptViewport { theme::faint() }; let block = Panel::bordered() + .title(if app.editing_steer() { + " editing pending · Enter save · Esc cancel " + } else { + "" + }) .border_type(BorderType::Rounded) .border_style(border); let inner = block.inner(area); @@ -2101,13 +2138,15 @@ fn draw_prompt_editor( .collect() }; frame.render_widget(Paragraph::new(lines), field); - frame.set_cursor_position(Position::new( - field.x - + u16::try_from(cursor_column) - .unwrap_or(0) - .min(field.width.saturating_sub(1)), - field.y + u16::try_from(cursor_row - first).unwrap_or(0), - )); + if !app.queue_focused { + frame.set_cursor_position(Position::new( + field.x + + u16::try_from(cursor_column) + .unwrap_or(0) + .min(field.width.saturating_sub(1)), + field.y + u16::try_from(cursor_row - first).unwrap_or(0), + )); + } PromptViewport { field, first_row: first, @@ -2208,7 +2247,25 @@ fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) { Style::default().fg(theme::warn_color()), )); } - let hints = "⏎ send ⇧⏎ newline ^l log ^c quit "; + let hints = if app.editing_steer() { + "⏎ save edit esc restore draft " + } else if app.queue_focused { + if app.pending_steers.is_empty() { + "queue empty · esc back " + } else if app.can_replace_steer + && app.pending_steers.iter().any(|pending| { + app.selected_steer.as_deref() == Some(pending.id.as_str()) && pending.editable + }) + { + "↑/↓ select ⏎ edit del remove esc back " + } else { + "↑/↓ select del remove esc back · edit unavailable " + } + } else if !app.pending_steers.is_empty() { + "F2 queue ⏎ send ⇧⏎ newline " + } else { + "⏎ send ⇧⏎ newline ^l log ^c quit " + }; let used: usize = left.iter().map(|span| span.content.chars().count()).sum(); let gap = (area.width as usize) .saturating_sub(used + hints.chars().count()) @@ -2996,10 +3053,12 @@ mod tests { RunningStateUpdate::new(), ))); app.apply(Update::SteerAccepted { + editable: true, id: "first".into(), text: "first pending".into(), }); app.apply(Update::SteerAccepted { + editable: true, id: "second".into(), text: "second pending".into(), }); @@ -3030,6 +3089,61 @@ mod tests { ); } + #[test] + fn queued_controls_are_discoverable_and_selection_scrolls_into_view() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let mut app = App::new( + PathBuf::from("/tmp"), + "provider".into(), + "model".into(), + "a2a".into(), + ); + app.can_replace_steer = true; + for index in 0..8 { + app.apply(Update::SteerAccepted { + editable: true, + id: index.to_string(), + text: format!("queued text {index}"), + }); + } + assert!(render(&mut app, 100, 18).contains("F2 queue")); + app.handle_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + let first = render(&mut app, 100, 18); + assert!(first.contains("▶ queued text 0"), "{first}"); + assert!(first.contains("⏎ edit"), "{first}"); + assert!(first.contains("del remove"), "{first}"); + for _ in 0..7 { + app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + } + let last = render(&mut app, 100, 18); + assert!(last.contains("▶ queued text 7"), "{last}"); + assert!(last.contains("[8/8]"), "{last}"); + app.pending_steers[7].editable = false; + assert!(render(&mut app, 100, 18).contains("edit unavailable")); + app.pending_steers[7].editable = true; + app.can_replace_steer = false; + assert!(render(&mut app, 100, 18).contains("edit unavailable")); + app.can_replace_steer = true; + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let editing = render(&mut app, 100, 18); + assert!(editing.contains("editing pending"), "{editing}"); + assert!(editing.contains("esc restore draft"), "{editing}"); + } + + #[test] + fn queued_empty_selector_keeps_its_exit_hint() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let mut app = App::new( + PathBuf::from("/tmp"), + "provider".into(), + "model".into(), + "a2a".into(), + ); + app.handle_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + let frame = render(&mut app, 100, 18); + assert!(frame.contains("queue empty · esc back"), "{frame}"); + } + #[test] fn completed_turn_duration_is_rendered() { let mut app = App::new( From 35a507c1fc8c2be5189a5adb3ddf9d6a7b121d81 Mon Sep 17 00:00:00 2001 From: daniel Date: Sat, 5 Sep 2026 14:16:52 +0100 Subject: [PATCH 2/4] fix(tui): prioritize session rename paste over queue focus --- src/tui/mod.rs | 62 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 730e84f..274d693 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1941,7 +1941,7 @@ fn handle(app: &mut App, event: Event) -> Action { Event::Key(key) => app.handle_key(key), Event::Mouse(mouse) => app.handle_mouse(mouse), Event::Paste(text) => { - if app.queue_focused { + if app.queue_focused && !app.session_rename_active() { return Action::None; } if app.session_rename_active() || app.editing_steer() { @@ -3512,6 +3512,66 @@ mod tests { assert_eq!(app.editor.text(), draft); } + #[test] + fn async_session_catalog_rename_paste_takes_precedence_over_queue_focus() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("image.png"); + std::fs::write(&path, b"png").unwrap(); + for populated in [false, true] { + let mut app = App::new( + directory.path().into(), + "provider".into(), + "model".into(), + "a2a".into(), + ); + app.paste("/sessions"); + assert!(matches!( + handle( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + ), + Action::ListSessions + )); + app.paste("parked draft"); + if populated { + app.apply(Update::SteerAccepted { + id: "pending".into(), + text: "queued text".into(), + editable: true, + }); + } + handle( + &mut app, + Event::Key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)), + ); + assert!(app.queue_focused); + app.apply(Update::SessionCatalog(Ok(vec![ + crate::session::CatalogEntry { + id: "saved".into(), + title: Some("Saved".into()), + preview: None, + is_subagent: false, + updated_at: 0, + }, + ]))); + handle( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), + ); + assert!(app.session_rename_active()); + handle(&mut app, Event::Paste(path.display().to_string())); + assert!(matches!( + app.session_dialog.as_ref().unwrap().rename.as_ref(), + Some(SessionRename::Editing(input)) if input == path.to_str().unwrap() + )); + assert!(app.attachments.is_empty()); + assert!(app.queue_focused); + assert_eq!(app.editor.text(), "parked draft"); + } + } + #[test] fn session_rename_paste_is_not_interpreted_as_an_attachment() { let directory = tempfile::tempdir().unwrap(); From fb50f01ea200141289a2c3064b9e4a2052e4c8d7 Mon Sep 17 00:00:00 2001 From: daniel Date: Sat, 5 Sep 2026 14:36:45 +0100 Subject: [PATCH 3/4] fix(tui): make queue focus and removal intuitive --- docs/user/tui-and-sessions.md | 4 +- src/tui/app.rs | 124 ++++++++++++++++++++++++---------- src/tui/mod.rs | 4 +- src/tui/ui.rs | 17 ++--- 4 files changed, 100 insertions(+), 49 deletions(-) diff --git a/docs/user/tui-and-sessions.md b/docs/user/tui-and-sessions.md index f763a47..a1c1ba2 100644 --- a/docs/user/tui-and-sessions.md +++ b/docs/user/tui-and-sessions.md @@ -43,7 +43,7 @@ Use the `artifact` tool to read spilled output, including memory-only artifacts; | `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 | | `F2` | Focus the pending-message queue (or return to the composer) | -| `Up` / `Down`, `Enter`, `Delete` in the queue | Select a pending message, edit it if supported, or remove it | +| `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 | | `Ctrl+D` | Quit when the prompt is empty | @@ -69,7 +69,7 @@ When the session is idle, `Enter` starts a normal prompt. While the agent is act ### Edit or remove a pending message -Press `F2` to focus the queue, then `Up` or `Down` to select a message. The selected row stays visible even when the queue is long. Press `Enter` to edit its text in the composer, or `Delete` to request removal. `Esc` or `F2` returns from the selector without changing your draft. If delivery empties the queue while it is selected, focus stays there until you leave; a stale `Enter` or `Delete` cannot send or alter your draft. +Press `F2` to focus the queue, then `Up` or `Down` to select a message. The queue opens only when at least one message is pending; otherwise Kit shows `no pending messages` and leaves you in the composer. The selected row stays visible even when the queue is long. Press `Enter` to edit its text in the composer, or `Backspace` / `Delete` to request removal (the normal Mac Delete key works). `Esc` or `F2` returns from the selector without changing your draft. When the last message is delivered or removed, or the turn finishes, the selector closes automatically and keyboard focus returns to the composer. An in-progress text edit remains available to copy or cancel rather than being discarded. While editing, plain `Enter` saves the replacement with the **same message ID and queue position**. `Esc` cancels the edit. Either a successful save or cancellation restores the previous composer draft, cursor, and attachments. Queue edits do not run slash commands. Editing is available only when the agent advertises ACP `session.inject.pending.replace`; removing a pending injection does not require this optional capability. diff --git a/src/tui/app.rs b/src/tui/app.rs index a8f06e9..26e7adf 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1754,6 +1754,7 @@ impl App { self.retired_steers .extend(self.pending_steers.drain(..).map(|pending| pending.id)); self.selected_steer = None; + self.queue_focused = false; if self.phase == Phase::Idle { self.agent_stream_sealed = true; return; @@ -2921,7 +2922,10 @@ impl App { .iter() .position(|pending| pending.id == id); self.pending_steers.retain(|pending| pending.id != id); - if self.selected_steer.as_deref() == Some(id) { + if self.pending_steers.is_empty() { + self.selected_steer = None; + self.queue_focused = false; + } else if self.selected_steer.as_deref() == Some(id) { self.selected_steer = index.and_then(|index| { self.pending_steers .get(index.min(self.pending_steers.len().saturating_sub(1))) @@ -3042,14 +3046,13 @@ impl App { self.queue_focused = false; return Action::None; } - // Keep focus even if delivery drained the queue. A stale Enter/Delete - // must not unexpectedly send or modify the parked composer draft. let Some(index) = self .pending_steers .iter() .position(|pending| Some(&pending.id) == self.selected_steer.as_ref()) else { self.selected_steer = None; + self.queue_focused = false; return Action::None; }; match key.code { @@ -3061,7 +3064,7 @@ impl App { }; self.selected_steer = Some(self.pending_steers[next].id.clone()); } - KeyCode::Delete => { + KeyCode::Backspace | KeyCode::Delete => { if self .steer_mutations .contains_key(&self.pending_steers[index].id) @@ -3171,14 +3174,12 @@ impl App { return self.handle_steer_selection(key); } if key.code == KeyCode::F(2) && !self.editing_steer() { - self.queue_focused = true; - self.selected_steer = self - .pending_steers - .front() - .map(|pending| pending.id.clone()); - if self.selected_steer.is_none() { + let Some(pending) = self.pending_steers.front() else { self.toast("no pending messages"); - } + return Action::None; + }; + self.selected_steer = Some(pending.id.clone()); + self.queue_focused = true; self.file_picker = None; return Action::None; } @@ -5061,8 +5062,9 @@ mod tests { app.latest_agent_source = "answer".into(); app.handle_key(press(KeyCode::F(2))); if drained { - app.pending_steers.clear(); - app.selected_steer = None; + for id in ["a", "b", "c"] { + app.steer_revoked(id); + } } // Without background work Ctrl+K is an editor key, so suppress it. app.handle_key(modified_press(KeyCode::Char('k'), KeyModifiers::CONTROL)); @@ -5095,7 +5097,7 @@ mod tests { app.handle_key(modified_press(KeyCode::Char('k'), KeyModifiers::CONTROL)), Action::CancelBackground(id) if id == "background" )); - assert!(app.queue_focused); + assert_eq!(app.queue_focused, !drained); assert_eq!(app.editor.text(), "draft"); } } @@ -5321,32 +5323,80 @@ mod tests { } #[test] - fn queued_delivery_of_last_selection_never_sends_or_deletes_the_draft() { - let mut app = queued_app(); + fn empty_queue_does_not_capture_composer_focus() { + let mut app = app(); app.paste("draft"); app.handle_key(press(KeyCode::F(2))); - for id in ["a", "b", "c"] { - app.apply(Update::UserMessage { - id: id.into(), - text: id.into(), - images: Vec::new(), - append: false, - }); - } - assert!(app.queue_focused); - assert!(app.selected_steer.is_none()); - assert!(matches!( - app.handle_key(press(KeyCode::Enter)), - Action::None - )); - assert!(matches!( - app.handle_key(press(KeyCode::Delete)), - Action::None - )); - assert_eq!(app.editor.text(), "draft"); - app.handle_key(press(KeyCode::Esc)); assert!(!app.queue_focused); - assert!(app.working()); + assert!(app.selected_steer.is_none()); + assert_eq!(app.toast_text(), Some("no pending messages")); + app.handle_key(press(KeyCode::Char('!'))); + assert_eq!(app.editor.text(), "draft!"); + } + + #[test] + fn draining_the_queue_returns_keyboard_focus_to_the_composer() { + for outcome in ["delivery", "removal", "unavailable", "turn_end"] { + let mut app = queued_app(); + app.paste("draft"); + app.handle_key(press(KeyCode::F(2))); + if outcome == "turn_end" { + app.apply(Update::State(StateUpdate::Idle( + IdleStateUpdate::new().stop_reason(StopReason::EndTurn), + ))); + } else { + for id in ["a", "b", "c"] { + match outcome { + "delivery" => app.apply(Update::UserMessage { + id: id.into(), + text: id.into(), + images: Vec::new(), + append: false, + }), + "removal" => app.steer_revoked(id), + "unavailable" => app.steer_mutation_failed(id, "gone".into(), true), + _ => unreachable!(), + } + } + } + assert!(app.pending_steers.is_empty(), "{outcome}"); + assert!(!app.queue_focused, "{outcome}"); + assert!(app.selected_steer.is_none(), "{outcome}"); + assert_eq!(app.editor.text(), "draft"); + app.handle_key(press(KeyCode::Char('!'))); + assert_eq!(app.editor.text(), "draft!", "{outcome}"); + } + } + + #[test] + fn backspace_and_forward_delete_remove_only_after_acknowledgement() { + for key in [KeyCode::Backspace, KeyCode::Delete] { + let mut app = queued_app(); + app.paste("draft"); + app.handle_key(press(KeyCode::F(2))); + for (index, expected) in ["a", "b", "c"].into_iter().enumerate() { + let Action::RevokeSteer { id } = app.handle_key(press(key)) else { + panic!("expected removal from {key:?}"); + }; + assert_eq!(id, expected); + let token = app.begin_steer_mutation(&id, None).unwrap(); + assert_eq!(app.pending_steers.len(), 3 - index); + assert!(app.queue_focused); + assert!(matches!(app.handle_key(press(key)), Action::None)); + assert_eq!(app.editor.text(), "draft"); + app.apply(Update::SteerMutationFinished { + id, + token, + result: Ok(()), + }); + assert_eq!(app.pending_steers.len(), 2 - index); + assert_eq!(app.queue_focused, index < 2); + } + assert!(app.selected_steer.is_none()); + assert_eq!(app.editor.text(), "draft"); + app.handle_key(press(KeyCode::Char('!'))); + assert_eq!(app.editor.text(), "draft!"); + } } #[test] diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 274d693..f06fdce 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -3546,7 +3546,7 @@ mod tests { &mut app, Event::Key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)), ); - assert!(app.queue_focused); + assert_eq!(app.queue_focused, populated); app.apply(Update::SessionCatalog(Ok(vec![ crate::session::CatalogEntry { id: "saved".into(), @@ -3567,7 +3567,7 @@ mod tests { Some(SessionRename::Editing(input)) if input == path.to_str().unwrap() )); assert!(app.attachments.is_empty()); - assert!(app.queue_focused); + assert_eq!(app.queue_focused, populated); assert_eq!(app.editor.text(), "parked draft"); } } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 031075c..c89bd44 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -2249,17 +2249,15 @@ fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) { } let hints = if app.editing_steer() { "⏎ save edit esc restore draft " - } else if app.queue_focused { - if app.pending_steers.is_empty() { - "queue empty · esc back " - } else if app.can_replace_steer + } else if app.queue_focused && !app.pending_steers.is_empty() { + if app.can_replace_steer && app.pending_steers.iter().any(|pending| { app.selected_steer.as_deref() == Some(pending.id.as_str()) && pending.editable }) { - "↑/↓ select ⏎ edit del remove esc back " + "↑/↓ select ⏎ edit ⌫/del remove esc back " } else { - "↑/↓ select del remove esc back · edit unavailable " + "↑/↓ select ⌫/del remove esc back · edit unavailable " } } else if !app.pending_steers.is_empty() { "F2 queue ⏎ send ⇧⏎ newline " @@ -3131,7 +3129,7 @@ mod tests { } #[test] - fn queued_empty_selector_keeps_its_exit_hint() { + fn empty_queue_keeps_the_composer_hint() { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let mut app = App::new( PathBuf::from("/tmp"), @@ -3141,7 +3139,10 @@ mod tests { ); app.handle_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); let frame = render(&mut app, 100, 18); - assert!(frame.contains("queue empty · esc back"), "{frame}"); + assert!(!app.queue_focused); + assert!(frame.contains("no pending messages"), "{frame}"); + assert!(frame.contains("⏎ send"), "{frame}"); + assert!(!frame.contains("esc back"), "{frame}"); } #[test] From b401fbf99b578bfb236ad81cc499e22375018ecb Mon Sep 17 00:00:00 2001 From: daniel Date: Sat, 5 Sep 2026 14:56:56 +0100 Subject: [PATCH 4/4] fix(tui): guard stale keys after queue auto-close --- docs/user/tui-and-sessions.md | 2 + src/tui/app.rs | 180 ++++++++++++++++++++++++++++++++-- src/tui/ui.rs | 32 ++++++ 3 files changed, 208 insertions(+), 6 deletions(-) diff --git a/docs/user/tui-and-sessions.md b/docs/user/tui-and-sessions.md index a1c1ba2..2eb3ab3 100644 --- a/docs/user/tui-and-sessions.md +++ b/docs/user/tui-and-sessions.md @@ -71,6 +71,8 @@ When the session is idle, `Enter` starts a normal prompt. While the agent is act Press `F2` to focus the queue, then `Up` or `Down` to select a message. The queue opens only when at least one message is pending; otherwise Kit shows `no pending messages` and leaves you in the composer. The selected row stays visible even when the queue is long. Press `Enter` to edit its text in the composer, or `Backspace` / `Delete` to request removal (the normal Mac Delete key works). `Esc` or `F2` returns from the selector without changing your draft. When the last message is delivered or removed, or the turn finishes, the selector closes automatically and keyboard focus returns to the composer. An in-progress text edit remains available to copy or cancel rather than being discarded. +After automatic closure, typing, pasting, or moving the composer cursor works normally. Until that fresh composer interaction (or `Esc` to acknowledge focus), Enter and deletion keys are ignored with a visible hint, so a key intended for the disappearing queue cannot send or delete your parked draft. An empty-queue F2 attempt does not enable this safeguard. + While editing, plain `Enter` saves the replacement with the **same message ID and queue position**. `Esc` cancels the edit. Either a successful save or cancellation restores the previous composer draft, cursor, and attachments. Queue edits do not run slash commands. Editing is available only when the agent advertises ACP `session.inject.pending.replace`; removing a pending injection does not require this optional capability. Save and removal requests run without blocking the UI; updates and `Ctrl+C` cancellation remain available, including when delivery empties the selected queue. Only one change per message can be in flight. You can continue typing or press `Esc` while a save is in progress; `Esc` restores your draft but does not undo a request already sent. A save response never closes a reopened edit or discards text typed after that save began. diff --git a/src/tui/app.rs b/src/tui/app.rs index 26e7adf..dc4b5f9 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -676,6 +676,8 @@ pub struct App { pub can_replace_steer: bool, pub(super) selected_steer: Option, pub(super) queue_focused: bool, + /// Asynchronous selector closure must not redirect stale destructive keys. + pub(super) queue_handoff: bool, steer_edit: Option, retired_steers: HashSet, steer_mutations: HashMap, @@ -978,6 +980,7 @@ impl App { can_replace_steer: false, selected_steer: None, queue_focused: false, + queue_handoff: false, steer_edit: None, retired_steers: HashSet::new(), steer_mutations: HashMap::new(), @@ -1753,8 +1756,7 @@ impl App { fn finish_turn_with_outcome(&mut self, successful: bool, notice: Option) { self.retired_steers .extend(self.pending_steers.drain(..).map(|pending| pending.id)); - self.selected_steer = None; - self.queue_focused = false; + self.close_queue_after_update(); if self.phase == Phase::Idle { self.agent_stream_sealed = true; return; @@ -2196,6 +2198,7 @@ impl App { self.cancel_steer_edit(); self.selected_steer = None; self.queue_focused = false; + self.queue_handoff = false; self.retired_steers.clear(); self.steer_mutations.clear(); self.session_catalog_pending = false; @@ -2458,6 +2461,7 @@ impl App { self.toast("pending-message edits are text-only"); return; } + self.queue_handoff = false; self.next_attachment += 1; let label = match kind { AttachmentKind::Image => "Image", @@ -2542,6 +2546,7 @@ impl App { return; } self.file_picker = None; + self.queue_handoff = false; self.editor.insert_str(text); self.sync_command_completion(); let lines = text.lines().count(); @@ -2915,6 +2920,12 @@ impl App { } } + fn close_queue_after_update(&mut self) { + self.queue_handoff |= self.queue_focused; + self.queue_focused = false; + self.selected_steer = None; + } + fn remove_pending_steer(&mut self, id: &str) { self.retired_steers.insert(id.to_owned()); let index = self @@ -2923,8 +2934,7 @@ impl App { .position(|pending| pending.id == id); self.pending_steers.retain(|pending| pending.id != id); if self.pending_steers.is_empty() { - self.selected_steer = None; - self.queue_focused = false; + self.close_queue_after_update(); } else if self.selected_steer.as_deref() == Some(id) { self.selected_steer = index.and_then(|index| { self.pending_steers @@ -3051,8 +3061,7 @@ impl App { .iter() .position(|pending| Some(&pending.id) == self.selected_steer.as_ref()) else { - self.selected_steer = None; - self.queue_focused = false; + self.close_queue_after_update(); return Action::None; }; match key.code { @@ -3173,6 +3182,41 @@ impl App { if self.queue_focused && !global_key { return self.handle_steer_selection(key); } + if self.queue_handoff && !global_key { + let control = key.modifiers.contains(KeyModifiers::CONTROL); + let command = key.modifiers.contains(KeyModifiers::SUPER); + let destructive = matches!( + key.code, + KeyCode::Enter | KeyCode::Backspace | KeyCode::Delete + ) || (control && matches!(key.code, KeyCode::Char('w' | 'u' | 'k'))) + || (key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Char('d')); + if destructive { + self.toast("queue closed — type, move cursor or Esc before sending/deleting draft"); + return Action::None; + } + if key.code == KeyCode::Esc { + self.queue_handoff = false; + self.toast = None; + return Action::None; + } + // Normal composer interaction stays responsive. View/task shortcuts + // above do not acknowledge the changed focus, nor do repeat deletes. + if matches!( + key.code, + KeyCode::Left + | KeyCode::Right + | KeyCode::Up + | KeyCode::Down + | KeyCode::Home + | KeyCode::End + | KeyCode::Tab + ) || (matches!(key.code, KeyCode::Char(_)) && !control && !command) + || (control && matches!(key.code, KeyCode::Char('a' | 'e' | 'j'))) + { + self.queue_handoff = false; + self.toast = None; + } + } if key.code == KeyCode::F(2) && !self.editing_steer() { let Some(pending) = self.pending_steers.front() else { self.toast("no pending messages"); @@ -3180,6 +3224,7 @@ impl App { }; self.selected_steer = Some(pending.id.clone()); self.queue_focused = true; + self.queue_handoff = false; self.file_picker = None; return Action::None; } @@ -5328,6 +5373,7 @@ mod tests { app.paste("draft"); app.handle_key(press(KeyCode::F(2))); assert!(!app.queue_focused); + assert!(!app.queue_handoff); assert!(app.selected_steer.is_none()); assert_eq!(app.toast_text(), Some("no pending messages")); app.handle_key(press(KeyCode::Char('!'))); @@ -5368,6 +5414,127 @@ mod tests { } } + #[test] + fn automatic_queue_closure_guards_stale_destructive_keys_until_acknowledged() { + for outcome in ["delivery", "removal", "unavailable", "turn_end"] { + for key in [KeyCode::Enter, KeyCode::Backspace, KeyCode::Delete] { + let mut app = queued_app(); + app.paste("draft"); + app.attach( + PathBuf::from("image.png"), + "image/png", + AttachmentKind::Image, + 3, + ); + app.editor.move_left(); + let draft = app.editor.text().to_owned(); + let attachments = app.attachments.clone(); + app.handle_key(press(KeyCode::F(2))); + if outcome == "turn_end" { + app.apply(Update::State(StateUpdate::Idle( + IdleStateUpdate::new().stop_reason(StopReason::EndTurn), + ))); + } else { + for id in ["a", "b", "c"] { + match outcome { + "delivery" => app.apply(Update::UserMessage { + id: id.into(), + text: id.into(), + images: Vec::new(), + append: false, + }), + "removal" => { + let token = app.begin_steer_mutation(id, None).unwrap(); + app.apply(Update::SteerMutationFinished { + id: id.into(), + token, + result: Ok(()), + }); + } + "unavailable" => app.steer_mutation_failed(id, "gone".into(), true), + _ => unreachable!(), + } + } + } + assert!(!app.queue_focused); + assert!(app.queue_handoff); + for _ in 0..3 { + assert!(matches!(app.handle_key(press(key)), Action::None)); + assert_eq!(app.editor.text(), draft, "{outcome} {key:?}"); + assert_eq!(app.attachments, attachments); + assert!(app.queue_handoff); + } + assert!(app.toast_text().unwrap().contains("queue closed")); + // Esc acknowledges composer focus rather than cancelling the turn. + assert!(matches!(app.handle_key(press(KeyCode::Esc)), Action::None)); + assert!(!app.queue_handoff); + assert!(app.phase != Phase::Cancelling); + let action = app.handle_key(press(key)); + if key == KeyCode::Enter { + assert!( + matches!(action, Action::Submit { prompt, .. } if prompt.text == draft && prompt.attachments == attachments) + ); + } else { + assert_ne!(app.editor.text(), draft); + } + } + } + } + + #[test] + fn typing_paste_and_cursor_movement_acknowledge_queue_handoff() { + for interaction in ["typing", "paste", "cursor"] { + let mut app = queued_app(); + app.paste("draft"); + app.handle_key(press(KeyCode::F(2))); + for id in ["a", "b", "c"] { + app.steer_revoked(id); + } + assert!(app.queue_handoff); + match interaction { + "typing" => { + app.handle_key(press(KeyCode::Char('!'))); + } + "paste" => app.paste("!"), + "cursor" => { + app.handle_key(press(KeyCode::Left)); + } + _ => unreachable!(), + } + assert!(!app.queue_handoff, "{interaction}"); + assert!(!app.queue_focused); + app.apply(Update::State(StateUpdate::Idle( + IdleStateUpdate::new().stop_reason(StopReason::EndTurn), + ))); + assert!( + !app.queue_handoff, + "turn completion must not rearm the handoff" + ); + app.last_key = None; + assert!(matches!( + app.handle_key(press(KeyCode::Enter)), + Action::Submit { .. } + )); + } + } + + #[test] + fn explicit_queue_exit_and_session_switch_do_not_require_handoff_acknowledgement() { + let mut app = queued_app(); + app.paste("draft"); + app.handle_key(press(KeyCode::F(2))); + app.handle_key(press(KeyCode::Esc)); + assert!(!app.queue_handoff); + app.handle_key(press(KeyCode::F(2))); + for id in ["a", "b", "c"] { + app.steer_revoked(id); + } + assert!(app.queue_handoff); + app.start_session("other-session".into()); + assert!(!app.queue_handoff); + assert_eq!(app.editor.text(), "draft"); + } + #[test] fn backspace_and_forward_delete_remove_only_after_acknowledgement() { for key in [KeyCode::Backspace, KeyCode::Delete] { @@ -5410,6 +5577,7 @@ mod tests { ))); assert!(app.pending_steers.is_empty()); assert!(app.editing_steer()); + assert!(!app.queue_handoff); assert!(matches!( app.handle_key(press(KeyCode::Enter)), Action::None diff --git a/src/tui/ui.rs b/src/tui/ui.rs index c89bd44..700990a 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -2259,6 +2259,8 @@ fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) { } else { "↑/↓ select ⌫/del remove esc back · edit unavailable " } + } else if app.queue_handoff { + "queue closed · type / ←→ / esc to continue " } else if !app.pending_steers.is_empty() { "F2 queue ⏎ send ⇧⏎ newline " } else { @@ -3128,6 +3130,36 @@ mod tests { assert!(editing.contains("esc restore draft"), "{editing}"); } + #[test] + fn drained_queue_shows_composer_handoff_hint_without_selector_focus() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let mut app = App::new( + PathBuf::from("/tmp"), + "provider".into(), + "model".into(), + "a2a".into(), + ); + app.paste("draft"); + app.apply(Update::SteerAccepted { + id: "pending".into(), + text: "queued".into(), + editable: true, + }); + app.handle_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + app.apply(Update::UserMessage { + id: "pending".into(), + text: "queued".into(), + images: Vec::new(), + append: false, + }); + assert!(!app.queue_focused); + let frame = render(&mut app, 100, 18); + assert!(frame.contains("queue closed · type / ←→ / esc"), "{frame}"); + assert!(!frame.contains("esc back"), "{frame}"); + app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert!(!render(&mut app, 100, 18).contains("queue closed")); + } + #[test] fn empty_queue_keeps_the_composer_hint() { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};