diff --git a/docs/user/tui-and-sessions.md b/docs/user/tui-and-sessions.md index 9e14185..502ebbd 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. 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. 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..79f5df7 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,21 @@ 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; + } + // 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; } @@ -4174,7 +4192,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 +4350,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..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. @@ -3263,6 +3278,16 @@ 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() { + selection_hints = format!( + "{} {hints}", + selection_copy_hint(app.command_copy_observed) + ); + selection_hints.as_str() + } else { + hints + }; let used: usize = left.iter().map(|span| span.content.chars().count()).sum(); let hint_width = hints.chars().count(); @@ -4436,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] { @@ -6163,7 +6288,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 +6328,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]