From 2c4a0b4c2a1b8539353ddba74a59036bdd9b9e74 Mon Sep 17 00:00:00 2001 From: daniel Date: Thu, 1 Oct 2026 17:34:24 +0100 Subject: [PATCH 1/3] feat(tui): copy transcript selections with Command-C --- docs/user/tui-and-sessions.md | 5 ++++- src/tui/app.rs | 9 ++++++++- src/tui/ui.rs | 7 ++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/user/tui-and-sessions.md b/docs/user/tui-and-sessions.md index 9e14185..ac18b90 100644 --- a/docs/user/tui-and-sessions.md +++ b/docs/user/tui-and-sessions.md @@ -67,11 +67,14 @@ Use the `artifact` tool to read spilled output, including memory-only artifacts; | `Ctrl+R` / `Ctrl+L` / `Ctrl+T` | Toggle the agent roster / agent log / reasoning | | `Ctrl+K` | Kill the selected running background tool call; otherwise delete to the end of the editor line | | `Ctrl+O`, or click a tool card | Fold or unfold raw tool output | -| `Ctrl+Y` | Copy the latest agent response as original Markdown | +| `Ctrl+Y` | Copy the drag selection, or the latest agent response as original Markdown | +| `Command+C` | Copy the drag selection when the terminal forwards the shortcut | | Click a fenced code block | Copy its contents without the backticks or language label | `Command` and `Shift+Enter` require a terminal with the Kitty keyboard protocol, such as Ghostty, Kitty, WezTerm, or recent iTerm2. The control-key alternatives work without that protocol. +Drag over the transcript to select text within Kit. `Command+C` copies that selection when the terminal forwards the shortcut through the Kitty keyboard protocol; if the terminal intercepts it for native Copy, use `Ctrl+Y` instead. Selection copying rejoins wrapped lines and removes code-block gutters. Shift-drag uses the terminal's own selection instead. + `Ctrl+Y` copies with the terminal's OSC 52 clipboard protocol. It preserves the agent's original Markdown, whitespace, and newlines instead of copying rendered TUI borders, list glyphs, or wrapped lines. Clipboard access must be enabled in the terminal; multiplexers such as tmux may also require OSC 52 passthrough. 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. diff --git a/src/tui/app.rs b/src/tui/app.rs index e4b3601..3779c8d 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -4174,7 +4174,7 @@ impl App { // 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('b' | 'c') => key.modifiers == KeyModifiers::SUPER, KeyCode::Char('k') => { key.modifiers.contains(KeyModifiers::CONTROL) && self @@ -4332,6 +4332,13 @@ impl App { .unwrap_or_default(), ); } + KeyCode::Char('c') if key.modifiers == KeyModifiers::SUPER => { + if let Some(text) = self.selection_text() { + self.toast("copied selection"); + return Action::Copy(text); + } + return Action::None; + } KeyCode::Char('y') if control => { if let Some(text) = self.selection_text() { self.toast("copied selection"); diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 87c6dd9..27241fe 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -6163,7 +6163,7 @@ mod tests { } #[test] - fn dragging_selects_and_ctrl_y_copies_instead_of_clicking() { + fn dragging_selects_and_copy_shortcuts_copy_instead_of_clicking() { use crossterm::event::{ KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; @@ -6203,9 +6203,14 @@ mod tests { }; assert_eq!(text, "one"); + let action = app.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER)); + assert!(matches!(action, Action::Copy(text) if text == "one")); + // The next press clears the selection. app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), left)); assert!(app.selection.is_none()); + let action = app.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER)); + assert!(matches!(action, Action::None)); } #[test] From f89912f6b75147531624ba93fd2ade42a9d922cf Mon Sep 17 00:00:00 2001 From: daniel Date: Thu, 1 Oct 2026 17:36:50 +0100 Subject: [PATCH 2/3] feat(tui): show observed copy shortcut for transcript selections --- docs/user/tui-and-sessions.md | 2 +- src/tui/app.rs | 6 ++++++ src/tui/ui.rs | 21 +++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/user/tui-and-sessions.md b/docs/user/tui-and-sessions.md index ac18b90..502ebbd 100644 --- a/docs/user/tui-and-sessions.md +++ b/docs/user/tui-and-sessions.md @@ -73,7 +73,7 @@ Use the `artifact` tool to read spilled output, including memory-only artifacts; `Command` and `Shift+Enter` require a terminal with the Kitty keyboard protocol, such as Ghostty, Kitty, WezTerm, or recent iTerm2. The control-key alternatives work without that protocol. -Drag over the transcript to select text within Kit. `Command+C` copies that selection when the terminal forwards the shortcut through the Kitty keyboard protocol; if the terminal intercepts it for native Copy, use `Ctrl+Y` instead. Selection copying rejoins wrapped lines and removes code-block gutters. Shift-drag uses the terminal's own selection instead. +Drag over the transcript to select text within Kit. `Command+C` copies that selection when the terminal forwards the shortcut through the Kitty keyboard protocol; if the terminal intercepts it for native Copy, use `Ctrl+Y` instead. Selection copying rejoins wrapped lines and removes code-block gutters. Shift-drag uses the terminal's own selection instead. While text is selected, the footer shows `Ctrl+Y` to copy; after Kit receives a `Command+C` keypress, it shows `⌘C` instead. Keyboard-protocol support alone cannot establish whether the terminal forwards that specific shortcut. `Ctrl+Y` copies with the terminal's OSC 52 clipboard protocol. It preserves the agent's original Markdown, whitespace, and newlines instead of copying rendered TUI borders, list glyphs, or wrapped lines. Clipboard access must be enabled in the terminal; multiplexers such as tmux may also require OSC 52 passthrough. diff --git a/src/tui/app.rs b/src/tui/app.rs index 3779c8d..6e12ab2 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -958,6 +958,8 @@ pub struct App { pub transcript_left: usize, pub transcript_width: usize, pub selection: Option, + /// Observed Command+C forwarding; protocol support alone cannot prove it. + pub command_copy_observed: bool, /// Pending left press; the flag suppresses a release-click when this press /// dismissed an older selection, while still allowing it to start a drag. press: Option<(usize, usize, bool)>, @@ -1225,6 +1227,7 @@ impl App { transcript_left: 0, transcript_width: 0, selection: None, + command_copy_observed: false, press: None, toast: None, last_key: None, @@ -4087,6 +4090,9 @@ impl App { if key.kind != KeyEventKind::Press { return Action::None; } + if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::SUPER { + self.command_copy_observed = true; + } if let Some(action) = self.handle_focus_key(key) { return action; } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 27241fe..473a107 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -3263,6 +3263,18 @@ fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) { } else { "⏎ send ⇧⏎ newline ^r agents ^t reasoning ^l log ^c quit " }; + let selection_hints; + let hints = if app.selection.is_some() { + let copy = if app.command_copy_observed { + "⌘c copy selection" + } else { + "^y copy selection" + }; + selection_hints = format!("{copy} {hints}"); + selection_hints.as_str() + } else { + hints + }; let used: usize = left.iter().map(|span| span.content.chars().count()).sum(); let hint_width = hints.chars().count(); @@ -6203,14 +6215,23 @@ mod tests { }; assert_eq!(text, "one"); + let frame = render(&mut app, 100, 24); + assert!(frame.lines().last().unwrap().contains("^y copy selection")); + assert!(!frame.contains("⌘c copy selection")); + let action = app.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER)); assert!(matches!(action, Action::Copy(text) if text == "one")); + let frame = render(&mut app, 100, 24); + assert!(frame.lines().last().unwrap().contains("⌘c copy selection")); + assert!(!frame.contains("^y copy selection")); // The next press clears the selection. app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), left)); assert!(app.selection.is_none()); let action = app.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER)); assert!(matches!(action, Action::None)); + let frame = render(&mut app, 100, 24); + assert!(!frame.contains("copy selection")); } #[test] From 6d2096777464f0d60617eb1fc78249a00e68ccc7 Mon Sep 17 00:00:00 2001 From: daniel Date: Thu, 1 Oct 2026 17:58:29 +0100 Subject: [PATCH 3/3] fix(tui): copy selections from focused child transcripts --- src/tui/app.rs | 12 +++++ src/tui/ui.rs | 127 ++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 132 insertions(+), 7 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 6e12ab2..79f5df7 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -4093,6 +4093,18 @@ impl App { if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::SUPER { self.command_copy_observed = true; } + // A focused child has a restricted key handler. Copy only its visible + // selection here rather than forwarding arbitrary root actions to it. + if ((key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::SUPER) + || (key.code == KeyCode::Char('y') && key.modifiers.contains(KeyModifiers::CONTROL))) + && let Some(id) = self.child_focus.as_ref() + { + return self + .child_views + .get(id) + .and_then(|view| view.app.selection_text()) + .map_or(Action::None, Action::Copy); + } if let Some(action) = self.handle_focus_key(key) { return action; } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 473a107..069fc55 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -185,6 +185,14 @@ fn child_body_layout(area: Rect) -> (Rect, Rect) { } } +fn selection_copy_hint(command_copy_observed: bool) -> &'static str { + if command_copy_observed { + "⌘c copy selection" + } else { + "^y copy selection" + } +} + fn draw_child( frame: &mut Frame<'_>, root: &mut App, @@ -205,12 +213,13 @@ fn draw_child( .display_rows(app.prompt_width) .clamp(1, MAX_PROMPT_ROWS) as u16 + 2; - let [back, title, notice, body, prompt] = Layout::vertical([ + let [back, title, notice, body, prompt, footer] = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(2), Constraint::Min(0), Constraint::Length(prompt_rows), + Constraint::Length(u16::from(app.selection.is_some())), ]) .areas(frame.area()); root.child_back_area = Rect { @@ -234,6 +243,12 @@ fn draw_child( let (transcript, agents) = child_body_layout(body); draw_transcript(frame, app, images, transcript); draw_agents(frame, root, agents); + if app.selection.is_some() { + frame.render_widget( + Paragraph::new(selection_copy_hint(root.command_copy_observed)).style(theme::dim()), + footer, + ); + } // This is deliberately not the root prompt: no commands, attachments, // queue actions, session controls, or idle-message submission affordance. @@ -3265,12 +3280,10 @@ fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) { }; let selection_hints; let hints = if app.selection.is_some() { - let copy = if app.command_copy_observed { - "⌘c copy selection" - } else { - "^y copy selection" - }; - selection_hints = format!("{copy} {hints}"); + selection_hints = format!( + "{} {hints}", + selection_copy_hint(app.command_copy_observed) + ); selection_hints.as_str() } else { hints @@ -4448,6 +4461,106 @@ mod tests { }) } + #[test] + fn focused_child_drag_copy_routes_through_root_without_editing_drafts() { + use crossterm::event::{ + KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, + }; + + for mode in ["steerable", "read-only", "pending", "roster"] { + let mut app = panel_app(1); + app.editor.insert_str("root draft"); + app.blocks + .push(Block::Agent("root response must not be copied".into())); + app.apply(Update::Runtime(RuntimeEvent::SubagentStateChanged { + id: "agent-0".into(), + name: "Scout 0".into(), + status: SubagentStatus::Working, + outcome: None, + generation: 2, + task: "task".into(), + parent_id: None, + parent_name: None, + harness: "acp.kit".into(), + vendor: crate::events::HarnessVendor::Kit, + model: None, + created_at_unix_ms: 1, + generation_started_at_unix_ms: 1, + generation_finished_at_unix_ms: None, + })); + app.focus_child("agent-0".into()); + let child = app.child_views.get_mut("agent-0").unwrap(); + child.can_steer = mode != "read-only"; + child.app.phase = Phase::Working; + child.app.editor.insert_str("child draft"); + child + .app + .blocks + .push(Block::Agent("visible child text".into())); + if mode == "pending" { + assert!( + matches!(app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), Action::SteerChild { text, .. } if text == "child draft") + ); + } + app.agents_keyboard_focus = mode == "roster"; + let frame = render(&mut app, 100, 24); + let row = frame + .lines() + .position(|line| line.contains("visible child text")) + .expect("child transcript is visible") as u16; + let left = app.child_views["agent-0"].app.transcript_left as u16; + let mouse = |kind, column| MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }; + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), left)); + app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), left + 6)); + assert!(matches!( + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), left + 6)), + Action::None + )); + assert_eq!( + app.child_views["agent-0"].app.selection_text().as_deref(), + Some("visible") + ); + let frame = render(&mut app, 100, 24); + assert!(frame.lines().last().unwrap().contains("^y copy selection")); + + let mut release = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER); + release.kind = KeyEventKind::Release; + assert!(matches!(app.handle_key(release), Action::None)); + assert!(!app.command_copy_observed); + for (code, modifiers) in [('y', KeyModifiers::CONTROL), ('c', KeyModifiers::SUPER)] { + assert!( + matches!(app.handle_key(KeyEvent::new(KeyCode::Char(code), modifiers)), Action::Copy(text) if text == "visible") + ); + } + let frame = render(&mut app, 100, 24); + assert!(frame.lines().last().unwrap().contains("⌘c copy selection")); + assert!(!frame.contains("^y copy selection")); + + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), left)); + for (code, modifiers) in [('c', KeyModifiers::SUPER), ('y', KeyModifiers::CONTROL)] { + assert!(matches!( + app.handle_key(KeyEvent::new(KeyCode::Char(code), modifiers)), + Action::None + )); + } + assert!(!render(&mut app, 100, 24).contains("copy selection")); + assert_eq!(app.editor.text(), "root draft"); + let child = &app.child_views["agent-0"]; + assert_eq!(child.app.editor.text(), "child draft"); + if mode == "pending" { + assert!(matches!( + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Action::Redraw + )); + } + } + } + #[test] fn focused_child_draw_uses_child_composer_and_root_roster() { for width in [60, 120] {