From 5530a7fc92be544861267b63a0c391f3dd030ac3 Mon Sep 17 00:00:00 2001 From: David Danialy Date: Fri, 18 Sep 2026 19:43:57 -0700 Subject: [PATCH 1/4] fix(tui): support image steering and immediate paste feedback --- Cargo.lock | 2 +- Cargo.toml | 8 +- src/tui/app.rs | 50 ++++++- src/tui/editor.rs | 7 + src/tui/mod.rs | 356 +++++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 395 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ebefbb3..e24ab43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,7 +144,7 @@ dependencies = [ [[package]] name = "agentkit-acp" version = "0.10.11" -source = "git+https://github.com/danielkov/agentkit.git?rev=bec9dcc45ee0f436d538286bc220b16dc19fd5f3#bec9dcc45ee0f436d538286bc220b16dc19fd5f3" +source = "git+https://github.com/daviddanialy/agentkit.git?rev=6d519ed1e93e28e54ba1cc18889534e2e8337181#6d519ed1e93e28e54ba1cc18889534e2e8337181" dependencies = [ "agent-client-protocol", "agentkit-core", diff --git a/Cargo.toml b/Cargo.toml index 98041f7..603d91b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -125,7 +125,7 @@ tokio = { version = "=1.53.1", features = ["test-util"] } [patch.crates-io] agentkit-loop = { git = "https://github.com/danielkov/agentkit.git", rev = "bec9dcc45ee0f436d538286bc220b16dc19fd5f3" } -agentkit-acp = { git = "https://github.com/danielkov/agentkit.git", rev = "bec9dcc45ee0f436d538286bc220b16dc19fd5f3" } +agentkit-acp = { git = "https://github.com/daviddanialy/agentkit.git", rev = "6d519ed1e93e28e54ba1cc18889534e2e8337181" } 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" } @@ -146,3 +146,9 @@ todo = "deny" unimplemented = "deny" disallowed_methods = "deny" disallowed_macros = "deny" + +# Keep the media-budget fork limited to ACP; share the existing loop and registry types. +[patch."https://github.com/daviddanialy/agentkit.git"] +agentkit-loop = { git = "https://github.com/danielkov/agentkit.git", rev = "bec9dcc45ee0f436d538286bc220b16dc19fd5f3" } +agentkit-core = "=0.10.5" +agentkit-tools-core = "=0.10.5" diff --git a/src/tui/app.rs b/src/tui/app.rs index e352850..c093f43 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -838,6 +838,7 @@ pub struct App { next_attachment: usize, submitted_attachment: usize, clipboard_route_epoch: u64, + pub(super) pending_clipboard: Vec, pub phase: Phase, pub turn_started: Option, /// When the user last started something new, as opposed to steering. @@ -1106,6 +1107,7 @@ impl App { next_attachment: 0, submitted_attachment: 0, clipboard_route_epoch: 0, + pending_clipboard: Vec::new(), phase: Phase::Idle, turn_started: None, prompt_started: None, @@ -3141,7 +3143,7 @@ impl App { } fn delete_with_attachments(&mut self, backwards: bool, delete: fn(&mut Editor)) { - if self.attachments.is_empty() { + if self.attachments.is_empty() && self.pending_clipboard.is_empty() { delete(&mut self.editor); return; } @@ -3158,8 +3160,13 @@ impl App { old_cursor..old_cursor + removed }; let mut expanded = deleted.clone(); - for attachment in &self.attachments { - for (start, placeholder) in old_text.match_indices(&attachment.placeholder) { + for placeholder in self + .attachments + .iter() + .map(|a| &a.placeholder) + .chain(&self.pending_clipboard) + { + for (start, placeholder) in old_text.match_indices(placeholder) { let end = start + placeholder.len(); let deleted_separator = backwards && end == deleted.start @@ -3200,6 +3207,39 @@ impl App { .is_some_and(|dialog| dialog.rename.is_some()) } + pub(super) fn move_out_of_pending_clipboard(&mut self) { + let cursor = self.editor.cursor(); + for placeholder in &self.pending_clipboard { + if let Some(start) = self.editor.text().find(placeholder) + && start < cursor + && cursor < start + placeholder.len() + { + self.editor.set_cursor(start + placeholder.len()); + break; + } + } + } + + pub(super) fn cancel_clipboard_placeholders(&mut self) { + for placeholder in self.pending_clipboard.drain(..) { + // A route change may have saved the composer while editing a steer. + for editor in std::iter::once(&mut self.editor) + .chain(self.steer_edit.as_mut().map(|edit| &mut edit.draft)) + { + while let Some(start) = editor.text().find(&placeholder) { + let end = start + placeholder.len(); + let cursor = editor.cursor(); + editor.replace_range(start..end, ""); + editor.set_cursor(if cursor >= end { + cursor - placeholder.len() + } else { + cursor.min(start) + }); + } + } + } + } + pub(super) fn clipboard_route(&self) -> ClipboardRoute { if self.model_switch.is_some() || self.model_dialog.is_some() @@ -4095,6 +4135,10 @@ impl App { self.toast = None; } KeyCode::Enter if key.modifiers.is_empty() && !pasted => { + if !self.pending_clipboard.is_empty() { + self.toast("waiting for clipboard paste before submitting"); + return Action::None; + } if self.editor.is_empty() { return Action::None; } diff --git a/src/tui/editor.rs b/src/tui/editor.rs index bc36f6b..b8a5201 100644 --- a/src/tui/editor.rs +++ b/src/tui/editor.rs @@ -27,6 +27,13 @@ impl Editor { self.cursor } + /// Moves the cursor to a valid byte boundary without changing the draft. + pub fn set_cursor(&mut self, cursor: usize) { + if cursor <= self.text.len() && self.text.is_char_boundary(cursor) { + self.cursor = cursor; + } + } + /// Replaces the initial slash-command token and leaves following text intact. pub fn replace_command_token(&mut self, replacement: &str) -> bool { if !self.text.starts_with('/') { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f2b7df3..7bf8d29 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -342,41 +342,52 @@ impl Drop for NativeVoice { #[derive(Default)] struct ClipboardPastes { pending: Option, + next_placeholder: u64, } struct PendingClipboardPastes { generation: u64, route: ClipboardRoute, remaining: usize, + placeholders: std::collections::VecDeque, submit: bool, } impl ClipboardPastes { - fn retain_current(&mut self, generation: Option, route: &ClipboardRoute) { + fn retain_current(&mut self, app: &mut App, generation: Option, route: &ClipboardRoute) { if self.pending.as_ref().is_some_and(|pending| { Some(pending.generation) != generation || &pending.route != route }) { self.pending = None; + app.cancel_clipboard_placeholders(); } } - fn queued(&mut self, generation: u64, route: ClipboardRoute) { + fn queued(&mut self, app: &mut App, generation: u64, route: ClipboardRoute) { if !matches!(route, ClipboardRoute::Composer(_)) { return; } + self.next_placeholder += 1; + let placeholder = format!("[Pasting… #{}]", self.next_placeholder); if let Some(pending) = &mut self.pending && pending.generation == generation && pending.route == route { pending.remaining += 1; + pending.placeholders.push_back(placeholder.clone()); } else { + app.cancel_clipboard_placeholders(); self.pending = Some(PendingClipboardPastes { generation, route, remaining: 1, + placeholders: [placeholder.clone()].into(), submit: false, }); } + app.move_out_of_pending_clipboard(); + app.editor.insert_str(&placeholder); + app.pending_clipboard.push(placeholder); } fn finish(&mut self, generation: u64, route: &ClipboardRoute, accepted: bool) -> bool { @@ -1861,9 +1872,11 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( // Reconcile after every prior event, including failures in // result/observe, before accepting any next user input. voice.notify_state(&connection, &session_id); + let clipboard_route = app.clipboard_route(); clipboard_pastes.retain_current( + &mut app, transition_session.lock().map(|active| active.generation).ok(), - &app.clipboard_route(), + &clipboard_route, ); if let Some(queued) = pending_update.take() { match background_workers.try_update(queued) { @@ -2531,7 +2544,7 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( .map(|route| route.generation) .unwrap_or_default(); match background_workers.try_clipboard(generation, route.clone(), mode) { - Ok(()) => clipboard_pastes.queued(generation, route), + Ok(()) => clipboard_pastes.queued(&mut app, generation, route), Err(std::sync::mpsc::TrySendError::Full(_)) => app.note("clipboard is busy; try again"), Err(std::sync::mpsc::TrySendError::Disconnected(_)) => app.note("clipboard worker is unavailable"), } @@ -2843,15 +2856,18 @@ fn handle_with_clipboard(app: &mut App, pastes: &mut ClipboardPastes, event: Eve if let Some(pending) = &mut pastes.pending { if pending.route != app.clipboard_route() { pastes.pending = None; - } else if matches!( - event, - Event::Key(KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) - ) { + app.cancel_clipboard_placeholders(); + } else if !app.pending_clipboard.is_empty() + && matches!( + event, + Event::Key(KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) + ) + { pending.submit = true; app.toast("waiting for clipboard paste before submitting"); return Action::None; @@ -2863,7 +2879,31 @@ fn handle_with_clipboard(app: &mut App, pastes: &mut ClipboardPastes, event: Eve pending.submit = false; } } - handle(app, event) + // Treat a pending marker as one composer object: insertions made after + // navigating inside it go after it, rather than corrupting its identity. + if matches!(&event, Event::Paste(_)) + || matches!(&event, Event::Key(key) if key.kind != KeyEventKind::Release + && matches!(key.code, KeyCode::Char(_) | KeyCode::Tab | KeyCode::Enter)) + { + app.move_out_of_pending_clipboard(); + } + if matches!(&event, Event::Key(key) if key.code == KeyCode::Esc + && key.kind != KeyEventKind::Release) + { + app.cancel_clipboard_placeholders(); + } + let action = handle(app, event); + if pastes + .pending + .as_ref() + .is_some_and(|pending| pending.route != app.clipboard_route()) + { + pastes.pending = None; + app.cancel_clipboard_placeholders(); + } + app.pending_clipboard + .retain(|placeholder| app.editor.text().contains(placeholder)); + action } fn finish_clipboard_paste( @@ -2874,7 +2914,43 @@ fn finish_clipboard_paste( route: ClipboardRoute, result: ClipboardResult, ) -> bool { - let accepted = apply_clipboard_completion(app, active, generation, route.clone(), result); + // The worker is FIFO. Each result replaces its own marker, not the live + // cursor, so edits and subsequent pastes retain their original ordering. + let placeholder = pastes + .pending + .as_mut() + .filter(|pending| pending.generation == generation && pending.route == route) + .and_then(|pending| pending.placeholders.pop_front()); + let accepted = if let Some(placeholder) = placeholder { + app.pending_clipboard + .retain(|pending| pending != &placeholder); + if let Some(start) = app.editor.text().find(&placeholder) { + let cursor = app.editor.cursor(); + let old_len = app.editor.text().len(); + app.editor + .replace_range(start..start + placeholder.len(), ""); + let accepted = + apply_clipboard_completion(app, active, generation, route.clone(), result); + let inserted = app.editor.text().len() + placeholder.len() - old_len; + let cursor = if cursor <= start { + cursor + } else if cursor < start + placeholder.len() { + start + inserted + } else { + cursor - placeholder.len() + inserted + }; + app.editor.set_cursor(cursor); + accepted + } else { + // Already cancelled by editing; this late result is a no-op. + // It must not cancel a newer Enter waiting for another live paste. + true + } + } else if matches!(route, ClipboardRoute::Composer(_)) { + false + } else { + apply_clipboard_completion(app, active, generation, route.clone(), result) + }; let submit = pastes.finish(generation, &route, accepted); if submit { // The deferred Enter is an explicit submission, not a pasted newline. @@ -6145,13 +6221,13 @@ mod tests { else { panic!("empty bracketed paste must request an image-only read"); }; - pastes.queued(1, route.clone()); + pastes.queued(&mut app, 1, route.clone()); let enter = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!( super::handle_with_clipboard(&mut app, &mut pastes, enter.clone()), Action::None )); - assert_eq!(app.editor.text(), "describe screenshot"); + assert_eq!(app.editor.text(), "describe screenshot[Pasting… #1]"); let result = if has_image { ClipboardResult::Attachment( clipboard_image_attachment(arboard::ImageData { @@ -6270,7 +6346,7 @@ mod tests { else { panic!("expected clipboard request"); }; - pastes.queued(1, route.clone()); + pastes.queued(app, 1, route.clone()); route } @@ -6295,7 +6371,7 @@ mod tests { super::handle_with_clipboard(&mut app, &mut pastes, enter.clone()), Action::None )); - assert_eq!(app.editor.text(), "describe"); + assert_eq!(app.editor.text(), "describe[Pasting… #1][Pasting… #2]"); let mut release = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); release.kind = crossterm::event::KeyEventKind::Release; super::handle_with_clipboard(&mut app, &mut pastes, Event::Key(release)); @@ -6307,7 +6383,7 @@ mod tests { first, ClipboardResult::Text(" this".into()) )); - assert_eq!(app.editor.text(), "describe this"); + assert_eq!(app.editor.text(), "describe this[Pasting… #2]"); let attachment = clipboard_image_attachment(arboard::ImageData { width: 1, height: 1, @@ -6370,7 +6446,7 @@ mod tests { &mut pastes, Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), ); - let before = app.editor.text().to_owned(); + let before = app.editor.text().replace("[Pasting… #1]", ""); let result = if full { ClipboardResult::Attachment(attachment) } else { @@ -6412,7 +6488,8 @@ mod tests { ); if switch { active.lock().unwrap().generation = 2; - pastes.retain_current(Some(2), &app.clipboard_route()); + let route = app.clipboard_route(); + pastes.retain_current(&mut app, Some(2), &route); } else { super::handle_with_clipboard(&mut app, &mut pastes, Event::Paste(" edited".into())); } @@ -6429,12 +6506,245 @@ mod tests { if switch { "draft" } else { - "draft edited pasted" + "draft pasted edited" } ); } } + #[test] + fn pending_clipboard_resolves_at_original_position_without_moving_live_cursor() { + for move_before in [false, true] { + let mut app = App::new( + PathBuf::from("."), + "provider".into(), + "model".into(), + "a2a".into(), + ); + let mut pastes = super::ClipboardPastes::default(); + let active = Arc::new(Mutex::new(ActiveSessionRoute { + id: "session".into(), + generation: 1, + })); + app.paste("before after"); + app.editor.set_cursor("before ".len()); + let route = queue_composer_paste(&mut app, &mut pastes); + assert_eq!(app.editor.text(), "before [Pasting… #1]after"); + assert!(app.attachments.is_empty()); + if move_before { + app.editor.move_line_start(); + } else { + app.editor.move_line_end(); + } + super::handle_with_clipboard(&mut app, &mut pastes, Event::Paste("é".into())); + let attachment = clipboard_image_attachment(arboard::ImageData { + width: 1, + height: 1, + bytes: Cow::Borrowed(&[20, 40, 60, 255]), + }) + .unwrap(); + assert!(!super::finish_clipboard_paste( + &mut app, + &active, + &mut pastes, + 1, + route, + ClipboardResult::Attachment(attachment) + )); + assert_eq!(app.attachments.len(), 1); + assert_eq!( + app.editor.text(), + if move_before { + "ébefore [Image #1] after" + } else { + "before [Image #1] afteré" + } + ); + assert_eq!( + app.editor.cursor(), + if move_before { + "é".len() + } else { + app.editor.text().len() + } + ); + } + } + + #[test] + fn deleting_or_cancelling_pending_clipboard_does_not_revive_it() { + for key in [KeyCode::Backspace, KeyCode::Delete, KeyCode::Esc] { + let mut app = App::new( + PathBuf::from("."), + "provider".into(), + "model".into(), + "a2a".into(), + ); + let mut pastes = super::ClipboardPastes::default(); + let active = Arc::new(Mutex::new(ActiveSessionRoute { + id: "session".into(), + generation: 1, + })); + app.paste("draft"); + let first = queue_composer_paste(&mut app, &mut pastes); + if key == KeyCode::Delete { + app.editor.set_cursor("draft".len()); + } + super::handle_with_clipboard( + &mut app, + &mut pastes, + Event::Key(KeyEvent::new(key, KeyModifiers::NONE)), + ); + assert_eq!(app.editor.text(), "draft"); + let second = queue_composer_paste(&mut app, &mut pastes); + super::handle_with_clipboard( + &mut app, + &mut pastes, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ); + assert!(!super::finish_clipboard_paste( + &mut app, + &active, + &mut pastes, + 1, + first, + ClipboardResult::Text("discarded".into()) + )); + assert_eq!(app.editor.text(), "draft[Pasting… #2]"); + assert!(super::finish_clipboard_paste( + &mut app, + &active, + &mut pastes, + 1, + second, + ClipboardResult::Text(" kept".into()) + )); + assert_eq!(app.editor.text(), "draft kept"); + assert!(app.pending_clipboard.is_empty()); + } + } + + #[test] + fn cancelled_clipboard_allows_submission_before_worker_returns() { + for key in [KeyCode::Backspace, KeyCode::Esc] { + let mut app = App::new( + PathBuf::from("."), + "provider".into(), + "model".into(), + "a2a".into(), + ); + let mut pastes = super::ClipboardPastes::default(); + let active = Arc::new(Mutex::new(ActiveSessionRoute { + id: "session".into(), + generation: 1, + })); + app.paste("draft"); + let route = queue_composer_paste(&mut app, &mut pastes); + super::handle_with_clipboard( + &mut app, + &mut pastes, + Event::Key(KeyEvent::new(key, KeyModifiers::NONE)), + ); + app.last_key = None; + let Action::Submit { prompt, .. } = super::handle_with_clipboard( + &mut app, + &mut pastes, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ) else { + panic!("cancelled paste must not block submission"); + }; + assert_eq!(prompt.text, "draft"); + assert!(!super::finish_clipboard_paste( + &mut app, + &active, + &mut pastes, + 1, + route, + ClipboardResult::Text("discarded".into()) + )); + assert!(app.editor.is_empty()); + } + } + + #[test] + fn shift_insert_inside_pending_clipboard_preserves_both_pastes() { + let mut app = App::new( + PathBuf::from("."), + "provider".into(), + "model".into(), + "a2a".into(), + ); + let mut pastes = super::ClipboardPastes::default(); + let active = Arc::new(Mutex::new(ActiveSessionRoute { + id: "session".into(), + generation: 1, + })); + let first = queue_composer_paste(&mut app, &mut pastes); + app.editor.move_left(); + let Action::ReadClipboard(second, _) = super::handle_with_clipboard( + &mut app, + &mut pastes, + Event::Key(KeyEvent::new(KeyCode::Insert, KeyModifiers::SHIFT)), + ) else { + panic!("expected clipboard paste"); + }; + pastes.queued(&mut app, 1, second.clone()); + assert_eq!(app.editor.text(), "[Pasting… #1][Pasting… #2]"); + assert!(!super::finish_clipboard_paste( + &mut app, + &active, + &mut pastes, + 1, + first, + ClipboardResult::Text("one".into()) + )); + assert!(!super::finish_clipboard_paste( + &mut app, + &active, + &mut pastes, + 1, + second, + ClipboardResult::Text("two".into()) + )); + assert_eq!(app.editor.text(), "onetwo"); + } + + #[test] + fn typing_inside_pending_clipboard_keeps_marker_resolvable() { + let mut app = App::new( + PathBuf::from("."), + "provider".into(), + "model".into(), + "a2a".into(), + ); + let mut pastes = super::ClipboardPastes::default(); + let active = Arc::new(Mutex::new(ActiveSessionRoute { + id: "session".into(), + generation: 1, + })); + let route = queue_composer_paste(&mut app, &mut pastes); + super::handle_with_clipboard( + &mut app, + &mut pastes, + Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)), + ); + super::handle_with_clipboard( + &mut app, + &mut pastes, + Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + ); + assert_eq!(app.editor.text(), "[Pasting… #1]x"); + assert!(!super::finish_clipboard_paste( + &mut app, + &active, + &mut pastes, + 1, + route, + ClipboardResult::Text("paste".into()) + )); + assert_eq!(app.editor.text(), "pastex"); + } + #[test] fn clipboard_completion_is_rejected_after_composer_reset() { for reset in [ From a407bc64ba43e9c5634dcf0813843477076fdc14 Mon Sep 17 00:00:00 2001 From: David Danialy Date: Fri, 18 Sep 2026 19:50:29 -0700 Subject: [PATCH 2/4] fix(tui): omit trailing space after clipboard images --- src/tui/app.rs | 34 ++++++++++++++++++++++++++++++---- src/tui/mod.rs | 4 ++-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index c093f43..d2028cf 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -3088,10 +3088,12 @@ impl App { self.editor.insert_char(' '); } self.editor.insert_str(&placeholder); - if after.is_none_or(|character| !character.is_whitespace()) { - self.editor.insert_char(' '); - } else { - self.editor.move_right(); + if attachment.temporary.is_none() { + if after.is_none_or(|character| !character.is_whitespace()) { + self.editor.insert_char(' '); + } else { + self.editor.move_right(); + } } attachment.placeholder = placeholder; self.attachments.push(attachment); @@ -6525,6 +6527,30 @@ mod tests { assert_eq!(app.attachments[0].placeholder, "[Image #1]"); } + #[test] + fn clipboard_image_preserves_spacing_and_leaves_cursor_at_placeholder_end() { + for (text, cursor, expected, expected_cursor) in [ + ("", 0, "[Image #1]", 10), + ("leftright", 4, "left [Image #1]right", 15), + ("left", 4, "left [Image #1]", 15), + ("left right", 4, "left [Image #1] right", 15), + ("left right", 5, "left [Image #1] right", 15), + ("left\n\nright", 5, "left\n[Image #1]\nright", 15), + ] { + let mut app = app(); + app.editor.insert_str(text); + app.editor.set_cursor(cursor); + let path = tempfile::NamedTempFile::new().unwrap().into_temp_path(); + app.attach_attachment(Attachment::clipboard_image( + crate::tui::attachment::own_temp_path(path), + 0, + )); + + assert_eq!(app.editor.text(), expected); + assert_eq!(app.editor.cursor(), expected_cursor); + } + } + #[test] fn accepted_clipboard_image_file_lives_until_the_session_is_cleared() { let path = tempfile::NamedTempFile::new().unwrap().into_temp_path(); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 7bf8d29..d091068 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -6555,9 +6555,9 @@ mod tests { assert_eq!( app.editor.text(), if move_before { - "ébefore [Image #1] after" + "ébefore [Image #1]after" } else { - "before [Image #1] afteré" + "before [Image #1]afteré" } ); assert_eq!( From a34e3a5eb9b74b9d5c47e2944d8442c703c4f681 Mon Sep 17 00:00:00 2001 From: David Danialy Date: Mon, 21 Sep 2026 15:00:30 -0600 Subject: [PATCH 3/4] fix(tui): cancel pending pastes before history navigation --- src/tui/app.rs | 5 +++- src/tui/editor.rs | 4 +++ src/tui/mod.rs | 65 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index d2028cf..d655201 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -4364,7 +4364,10 @@ impl App { KeyCode::Up if shift => self.scroll_by(-1), KeyCode::Down if shift => self.scroll_by(1), KeyCode::Up => { - if !self.editor.move_row_up(self.prompt_width) { + if !self.editor.move_row_up(self.prompt_width) && self.editor.has_history() { + // Cancel before history parks the draft: pending markers + // must never be restored without their paste tracking. + self.cancel_clipboard_placeholders(); self.editor.history_prev(); } } diff --git a/src/tui/editor.rs b/src/tui/editor.rs index b8a5201..012e90f 100644 --- a/src/tui/editor.rs +++ b/src/tui/editor.rs @@ -289,6 +289,10 @@ impl Editor { self.cursor = self.line_bounds(self.cursor).1; } + pub fn has_history(&self) -> bool { + !self.history.is_empty() + } + /// Recalls the previous prompt, parking any unsent draft. pub fn history_prev(&mut self) { if self.history.is_empty() { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index d091068..561b39a 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -6666,6 +6666,71 @@ mod tests { } } + #[test] + fn history_navigation_cancels_pending_clipboard_before_stashing() { + for complete_before_down in [true, false] { + let mut app = App::new( + PathBuf::from("."), + "provider".into(), + "model".into(), + "a2a".into(), + ); + let mut pastes = super::ClipboardPastes::default(); + let active = Arc::new(Mutex::new(ActiveSessionRoute { + id: "session".into(), + generation: 1, + })); + app.editor.insert_str("previous prompt"); + app.editor.submit(); + app.paste("draft"); + let route = queue_composer_paste(&mut app, &mut pastes); + super::handle_with_clipboard( + &mut app, + &mut pastes, + Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)), + ); + assert_eq!(app.editor.text(), "previous prompt"); + assert!(app.pending_clipboard.is_empty()); + if complete_before_down { + assert!(!super::finish_clipboard_paste( + &mut app, + &active, + &mut pastes, + 1, + route.clone(), + ClipboardResult::Text("discarded".into()), + )); + assert_eq!(app.editor.text(), "previous prompt"); + } + super::handle_with_clipboard( + &mut app, + &mut pastes, + Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)), + ); + assert_eq!(app.editor.text(), "draft"); + app.last_key = None; + let Action::Submit { prompt, .. } = super::handle_with_clipboard( + &mut app, + &mut pastes, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ) else { + panic!("history-cancelled paste must not block submission"); + }; + assert_eq!(prompt.text, "draft"); + if !complete_before_down { + assert!(!super::finish_clipboard_paste( + &mut app, + &active, + &mut pastes, + 1, + route, + ClipboardResult::Text("discarded".into()), + )); + assert!(app.editor.is_empty()); + } + } + } + #[test] fn shift_insert_inside_pending_clipboard_preserves_both_pastes() { let mut app = App::new( From 52070765ac3cf418a6ff36e1ee61ea696d35988b Mon Sep 17 00:00:00 2001 From: David Danialy Date: Mon, 21 Sep 2026 15:23:30 -0600 Subject: [PATCH 4/4] test(acp): allow session setup within wire receive deadline --- src/protocols/acp/v2.rs | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/protocols/acp/v2.rs b/src/protocols/acp/v2.rs index 8adaa75..18e24d8 100644 --- a/src/protocols/acp/v2.rs +++ b/src/protocols/acp/v2.rs @@ -3223,12 +3223,19 @@ mod tests { .unwrap(); } - async fn receive_wire(channel: &mut agent_client_protocol::Channel) -> serde_json::Value { + async fn receive_wire( + channel: &mut agent_client_protocol::Channel, + expected: &str, + ) -> serde_json::Value { use futures_util::StreamExt; - let frame = timeout(Duration::from_secs(2), channel.rx.next()) + // Real session creation includes best-effort model discovery with a 10-second + // HTTP timeout. Allow that fallback plus CI scheduling headroom; this is a + // deadlock guard, not a latency assertion. Always inspect the very next frame. + let wait = Duration::from_secs(30); + let frame = timeout(wait, channel.rx.next()) .await - .expect("ACP frame timed out") - .expect("ACP transport closed"); + .unwrap_or_else(|_| panic!("ACP frame timed out after {wait:?} waiting for {expected}")) + .unwrap_or_else(|| panic!("ACP transport closed waiting for {expected}")); let agent_client_protocol::TransportFrame::Single(message) = frame else { panic!("expected a single ACP message, got {frame:?}"); }; @@ -3261,7 +3268,10 @@ mod tests { )) .unwrap(), ); - assert_eq!(receive_wire(&mut client).await["id"], 1); + assert_eq!( + receive_wire(&mut client, "initialize response (id 1)").await["id"], + 1 + ); send_wire( &client, "session/new", @@ -3269,14 +3279,14 @@ mod tests { serde_json::to_value(wire::NewSessionRequest::new(root.path().to_path_buf())).unwrap(), ); - let response = receive_wire(&mut client).await; + let response = receive_wire(&mut client, "session/new response (id 2)").await; assert_eq!( response["id"], 2, "session response must be the first frame: {response}" ); let response: wire::NewSessionResponse = serde_json::from_value(response["result"].clone()).unwrap(); - let notification = receive_wire(&mut client).await; + let notification = receive_wire(&mut client, "available commands notification").await; assert_eq!(notification["method"], "session/update"); let notification: wire::UpdateSessionNotification = serde_json::from_value(notification["params"].clone()).unwrap(); @@ -3293,7 +3303,7 @@ mod tests { 3, serde_json::to_value(wire::CloseSessionRequest::new(response.session_id)).unwrap(), ); - let closed = receive_wire(&mut client).await; + let closed = receive_wire(&mut client, "session/close response (id 3)").await; assert_eq!(closed["id"], 3); assert!(closed.get("result").is_some(), "close failed: {closed}"); server.abort(); @@ -3739,7 +3749,12 @@ mod tests { )) .unwrap(), ); - assert!(receive_wire(&mut client).await.get("result").is_some()); + assert!( + receive_wire(&mut client, "initialize response (id 1)") + .await + .get("result") + .is_some() + ); send_wire( &client, "session/inject", @@ -3753,7 +3768,7 @@ mod tests { )) .unwrap(), ); - let response = receive_wire(&mut client).await; + let response = receive_wire(&mut client, "session/inject response (id 2)").await; assert!( response.get("result").is_some(), "injection failed: {response}"