Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/user/tui-and-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 26 additions & 1 deletion src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,8 @@ pub struct App {
pub transcript_left: usize,
pub transcript_width: usize,
pub selection: Option<Selection>,
/// 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)>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -4332,6 +4350,13 @@ impl App {
.unwrap_or_default(),
);
}
KeyCode::Char('c') if key.modifiers == KeyModifiers::SUPER => {
if let Some(text) = self.selection_text() {
Comment thread
kit-code-agent[bot] marked this conversation as resolved.
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");
Expand Down
143 changes: 141 additions & 2 deletions src/tui/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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]
Expand Down