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
4 changes: 2 additions & 2 deletions docs/user/tui-and-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Use the `artifact` tool to read spilled output, including memory-only artifacts;
| `Option+Backspace`, `Ctrl+W` | Delete the previous word |
| `Command+Backspace`, `Ctrl+U` / `Ctrl+K` | Delete to line start / end |
| `Up` / `Down` | Move through prompt lines, then prompt history |
| `Shift+Up` / `Shift+Down`, `PageUp` / `PageDown`, mouse wheel | Scroll the transcript |
| `Shift+Up` / `Shift+Down`, `PageUp` / `PageDown`, mouse wheel, drag the scrollbar | Scroll the transcript |
| `Ctrl+Home` / `Ctrl+End` | Jump to transcript top / bottom |
| `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 |
Expand All @@ -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. 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.
Drag over the transcript to select text within Kit. A press on the scrollbar scrolls instead: drag its thumb, or click the track to jump there. `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.

Expand Down
185 changes: 185 additions & 0 deletions src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,12 @@ pub struct App {
pub transcript_top: usize,
pub transcript_left: usize,
pub transcript_width: usize,
/// Transcript scrollbar cells from the last frame; empty when no bar shows.
pub transcript_bar: Rect,
/// While a left press that began on the scrollbar is held, the grabbed
/// cell's distance from the thumb's top, so the thumb tracks the pointer,
/// and the pointer's last row, so only real movement scrolls.
scrollbar_grab: Option<(usize, u16)>,
pub selection: Option<Selection>,
/// Observed Command+C forwarding; protocol support alone cannot prove it.
pub command_copy_observed: bool,
Expand Down Expand Up @@ -1276,6 +1282,8 @@ impl App {
selection: None,
command_copy_observed: false,
press: None,
transcript_bar: Rect::default(),
scrollbar_grab: None,
toast: None,
last_key: None,
next_file_search_revision: 0,
Expand Down Expand Up @@ -3233,6 +3241,8 @@ impl App {
self.transcript_top = 0;
self.transcript_left = 0;
self.transcript_width = 0;
self.transcript_bar = Rect::default();
self.scrollbar_grab = None;
self.row_calls.clear();
self.row_links.clear();
self.row_code.clear();
Expand Down Expand Up @@ -4970,7 +4980,47 @@ impl App {
self.scroll_by(3);
return Action::Redraw;
}
MouseEventKind::Down(MouseButton::Left)
if self
.transcript_bar
.contains((mouse.column, mouse.row).into()) =>
{
self.press = None;
self.selection = None;
let cell = usize::from(mouse.row - self.transcript_bar.y);
let (start, length) = self.scrollbar_thumb();
self.scrollbar_grab = Some(if (start..start + length).contains(&cell) {
// Grabbing the thumb leaves the view where it is.
(cell - start, mouse.row)
} else {
// A press on the track centers the thumb under the pointer.
let grab = length / 2;
self.drag_scrollbar(mouse.row, grab);
(grab, mouse.row)
});
return Action::Redraw;
}
MouseEventKind::Drag(MouseButton::Left) if self.scrollbar_grab.is_some() => {
let (grab, last_row) = self.scrollbar_grab.unwrap_or_default();
// Sideways motion keeps the offset; many offsets share a row.
if mouse.row == last_row {
return Action::None;
}
self.scrollbar_grab = Some((grab, mouse.row));
return if self.drag_scrollbar(mouse.row, grab) {
Action::Redraw
} else {
Action::None
};
}
MouseEventKind::Up(MouseButton::Left) if self.scrollbar_grab.is_some() => {
self.scrollbar_grab = None;
return Action::None;
}
MouseEventKind::Down(MouseButton::Left) => {
// Clears a drag whose release was lost, such as one let go
// outside the terminal.
self.scrollbar_grab = None;
let dismissed = self.selection.take().is_some();
self.press = Some((mouse.column as usize, mouse.row as usize, dismissed));
if dismissed {
Expand Down Expand Up @@ -5018,6 +5068,53 @@ impl App {
self.press = None;
}

/// The scrollbar thumb's first row and length within the track, matching
/// how ratatui draws `ScrollbarState::new(bottom).position(scroll)`.
fn scrollbar_thumb(&self) -> (usize, usize) {
let track = usize::from(self.transcript_bar.height);
let bottom = self.total_lines.saturating_sub(self.viewport);
let span = (bottom.saturating_sub(1) + track).max(1);
let length = ((track * track + span / 2) / span).clamp(1, track.max(1));
let start = (self.scroll.min(bottom) * track + span / 2) / span;
(start.min(track.saturating_sub(length)), length)
}

/// Scrolls so the thumb's grabbed cell sits on `row`, returning whether
/// the view moved. Rows past either end clamp, so a drag can leave the
/// bar; the last thumb row resumes following the bottom.
fn drag_scrollbar(&mut self, row: u16, grab: usize) -> bool {
let bar = self.transcript_bar;
let track = usize::from(bar.height);
if track == 0 {
return false;
}
let bottom = self.total_lines.saturating_sub(self.viewport);
let (start, length) = self.scrollbar_thumb();
let last = track - length;
let target = usize::from(row.saturating_sub(bar.y))
.saturating_sub(grab)
.min(last);
// Many offsets draw the thumb on one row; keep the current one while
// the thumb stays put mid-track, or fills the track. A thumb pushed
// against either end still snaps to that end.
if last == 0 || (target == start && target != 0 && target != last) {
return false;
}
// The smallest scroll whose thumb rounds to `target`, so a dragged
// thumb lands exactly where it is drawn.
let span = bottom.saturating_sub(1) + track;
let scroll = match target {
0 => 0,
Comment thread
kit-code-agent[bot] marked this conversation as resolved.
target if target == last => bottom,
target => ((2 * target - 1) * span).div_ceil(2 * track).min(bottom),
};
let follow = scroll >= bottom;
let moved = scroll != self.scroll.min(bottom) || follow != self.follow;
self.scroll = scroll;
self.follow = follow;
moved
}

/// Maps a screen cell to (absolute transcript line, column), if it is
/// inside the transcript area.
fn transcript_position(&self, column: usize, row: usize) -> Option<(usize, usize)> {
Expand Down Expand Up @@ -5490,6 +5587,94 @@ mod tests {
assert!(app.press.is_none());
}

#[test]
fn dragging_the_transcript_scrollbar_tracks_the_grabbed_thumb() {
let mut app = app();
// 30 lines in a 10-row view: ratatui draws a 3-row thumb on a
// 10-row track, rows 7..10 while following the bottom.
app.total_lines = 30;
app.viewport = 10;
app.transcript_width = 10;
app.scroll = 20;
app.transcript_bar = ratatui::layout::Rect::new(20, 0, 1, 10);
let mouse = |kind, column, row| MouseEvent {
kind,
column,
row,
modifiers: KeyModifiers::NONE,
};
let down = |row| mouse(MouseEventKind::Down(MouseButton::Left), 20, row);
let drag = |row| mouse(MouseEventKind::Drag(MouseButton::Left), 20, row);
let up = |row| mouse(MouseEventKind::Up(MouseButton::Left), 20, row);
assert_eq!(app.scrollbar_thumb(), (7, 3));

// Grabbing the thumb does not move it or stop following.
app.handle_mouse(down(7));
assert_eq!((app.scroll, app.follow), (20, true));
assert!(app.press.is_none());

// The thumb follows the pointer, and a layout change mid-drag keeps it.
app.handle_mouse(drag(3));
assert_eq!((app.scroll, app.follow), (8, false));
assert_eq!(app.scrollbar_thumb().0, 3);
app.clear_transcript_interaction();
app.handle_mouse(drag(0));
assert_eq!(app.scroll, 0);
assert!(matches!(app.handle_mouse(drag(0)), Action::None));

// Leaving the bar clamps, and the last thumb row resumes following.
app.handle_mouse(drag(40));
assert_eq!((app.scroll, app.follow), (20, true));
assert!(app.selection.is_none());
assert!(matches!(app.handle_mouse(up(40)), Action::None));
app.handle_mouse(drag(0));
assert_eq!(app.scroll, 20);

// A press on the track centers the thumb there.
app.scroll = 0;
app.follow = false;
app.handle_mouse(down(5));
assert_eq!(app.scrollbar_thumb().0, 4);
assert!(!app.follow);

// Grabbing a thumb drawn at a non-canonical offset keeps that offset.
app.total_lines = 1010;
app.scroll = 500;
app.handle_mouse(down(5));
assert!(matches!(app.handle_mouse(drag(5)), Action::None));
assert_eq!((app.scroll, app.follow), (500, false));
app.handle_mouse(up(5));

// Dragging past the end snaps there even when the thumb already
// draws on its last row.
app.transcript_bar = ratatui::layout::Rect::new(20, 0, 1, 18);
app.total_lines = 1000;
app.viewport = 18;
app.scroll = 979;
app.handle_mouse(down(17));
app.handle_mouse(drag(17));
assert_eq!((app.scroll, app.follow), (979, false));
app.handle_mouse(drag(40));
assert_eq!((app.scroll, app.follow), (982, true));
app.handle_mouse(up(40));
app.transcript_bar = ratatui::layout::Rect::new(20, 0, 1, 10);
app.viewport = 10;

// A thumb that fills the track has nowhere to go and keeps following.
app.total_lines = 11;
app.scroll = 1;
app.follow = true;
app.handle_mouse(down(5));
app.handle_mouse(drag(40));
app.handle_mouse(drag(0));
assert_eq!((app.scroll, app.follow), (1, true));

// With the release lost, the next press off the bar selects text.
app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 2));
app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 4, 2));
assert!(app.selection.is_some());
}

fn app() -> App {
App::new(
PathBuf::from("/tmp"),
Expand Down
6 changes: 5 additions & 1 deletion src/tui/app/focus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,11 @@ impl App {
}
}
self.child_focus.as_ref()?;
if self.agents_area.contains((mouse.column, mouse.row).into()) {
// Drags and releases still reach the child, so a scrollbar drag or
// selection can cross into the roster beside its transcript.
if self.agents_area.contains((mouse.column, mouse.row).into())
&& !matches!(mouse.kind, MouseEventKind::Drag(_) | MouseEventKind::Up(_))
{
match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_agents_by(-3),
MouseEventKind::ScrollDown => self.scroll_agents_by(3),
Expand Down
59 changes: 59 additions & 0 deletions src/tui/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRuntime) {
} else if let Some(dialog) = &app.effort_dialog {
draw_effort_dialog(frame, app, dialog);
}
if native_links_obscured(app) {
// Presses on a dialog over the bar belong to the dialog.
app.transcript_bar = Rect::default();
}
// Durability stays visible on the start screen and over session pickers.
// Pending data belongs to the process, not the currently selected session.
let area = frame.area();
Expand Down Expand Up @@ -1205,6 +1209,7 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti
width: text_area.width.saturating_sub(2),
..text_area
};
app.transcript_bar = Rect::default();
if app.blocks.is_empty() {
app.row_calls.clear();
app.row_links.clear();
Expand Down Expand Up @@ -1354,6 +1359,7 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti
}
}
if total > height {
app.transcript_bar = bar_area;
let mut state = ScrollbarState::new(bottom).position(offset);
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
Expand Down Expand Up @@ -4441,6 +4447,59 @@ mod tests {
}
}

#[test]
fn dragged_scrollbar_thumb_stays_under_the_pointer() {
use crossterm::event::MouseButton;

let mut app = App::new(
PathBuf::from("/tmp/kit"),
"test".into(),
"test".into(),
"0:0".into(),
);
let numbers: Vec<String> = (1..=100).map(|n| n.to_string()).collect();
app.blocks.push(Block::Agent(numbers.join("\n\n")));
let mut terminal = Terminal::new(TestBackend::new(40, 20)).expect("terminal");
let mut images = ImageRuntime::disabled();
let mut thumb = |app: &mut App| {
terminal
.draw(|frame| draw(frame, app, &mut images))
.expect("draw succeeds");
let bar = app.transcript_bar;
let buffer = terminal.backend().buffer();
(bar.y..bar.y + bar.height)
.filter(|&row| buffer[(bar.x, row)].symbol() == "█")
.collect::<Vec<u16>>()
};
let mouse = |kind, column, row| MouseEvent {
kind,
column,
row,
modifiers: KeyModifiers::NONE,
};

let following = thumb(&mut app);
let bar = app.transcript_bar;
assert!(bar.height > 0);
assert_eq!(following.last(), Some(&(bar.y + bar.height - 1)));

// Grabbing the thumb's top row leaves it in place and still following.
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
bar.x,
following[0],
));
assert_eq!(thumb(&mut app), following);
assert!(app.follow);

for row in [bar.y + 2, bar.y + 5, bar.y] {
app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), bar.x, row));
assert_eq!(thumb(&mut app).first(), Some(&row));
}
assert_eq!(app.scroll, 0);
assert!(app.selection.is_none());
}

#[test]
fn focused_child_frame_emits_visible_link_destinations() {
let mut app = panel_app(1);
Expand Down
Loading