From a6a5464824310d8deb176eaa2667751df70d4cd6 Mon Sep 17 00:00:00 2001 From: David Danialy Date: Thu, 22 Oct 2026 14:19:54 -0700 Subject: [PATCH 1/3] feat(tui): drag the transcript scrollbar Pressing the scrollbar now scrolls instead of starting a text selection. Dragging the thumb keeps the grabbed cell under the pointer, a press on the track centers the thumb there, and dragging to the last thumb row resumes following the bottom. Dialogs over the bar take the press, and a drag survives streaming layout changes and crossing into a focused child's agent roster. Co-Authored-By: Claude Opus 5.5 --- docs/user/tui-and-sessions.md | 4 +- src/tui/app.rs | 140 ++++++++++++++++++++++++++++++++++ src/tui/app/focus.rs | 6 +- src/tui/ui.rs | 59 ++++++++++++++ 4 files changed, 206 insertions(+), 3 deletions(-) diff --git a/docs/user/tui-and-sessions.md b/docs/user/tui-and-sessions.md index 45cada73..f999daa5 100644 --- a/docs/user/tui-and-sessions.md +++ b/docs/user/tui-and-sessions.md @@ -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 | @@ -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. diff --git a/src/tui/app.rs b/src/tui/app.rs index 5714458a..107c46e4 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -986,6 +986,11 @@ 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. + scrollbar_grab: Option, pub selection: Option, /// Observed Command+C forwarding; protocol support alone cannot prove it. pub command_copy_observed: bool, @@ -1276,6 +1281,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, @@ -3233,6 +3240,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(); @@ -4970,7 +4979,41 @@ 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(); + // A press on the track centers the thumb under the pointer. + let grab = if (start..start + length).contains(&cell) { + cell - start + } else { + length / 2 + }; + self.scrollbar_grab = Some(grab); + self.drag_scrollbar(mouse.row, grab); + return Action::Redraw; + } + MouseEventKind::Drag(MouseButton::Left) if self.scrollbar_grab.is_some() => { + let grab = self.scrollbar_grab.unwrap_or_default(); + 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 { @@ -5018,6 +5061,47 @@ 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 (_, length) = self.scrollbar_thumb(); + let last = track - length; + let target = usize::from(row.saturating_sub(bar.y)) + .saturating_sub(grab) + .min(last); + // 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, + 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)> { @@ -5490,6 +5574,62 @@ 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); + + // 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"), diff --git a/src/tui/app/focus.rs b/src/tui/app/focus.rs index e41c2bf4..b601fef7 100644 --- a/src/tui/app/focus.rs +++ b/src/tui/app/focus.rs @@ -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), diff --git a/src/tui/ui.rs b/src/tui/ui.rs index c81650ed..201496cc 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -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(); @@ -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(); @@ -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) @@ -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 = (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::>() + }; + 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); From 078c90f7be21edf7c1856da487826f95b42b117f Mon Sep 17 00:00:00 2001 From: David Danialy Date: Thu, 22 Oct 2026 14:38:40 -0700 Subject: [PATCH 2/3] fix(tui): keep the scroll offset while the thumb stays put Co-Authored-By: Claude Opus 5.5 --- src/tui/app.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 107c46e4..b6a28b55 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -5082,11 +5082,16 @@ impl App { return false; } let bottom = self.total_lines.saturating_sub(self.viewport); - let (_, length) = self.scrollbar_thumb(); + 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, including when it fills the track. + if last == 0 || target == start { + 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; @@ -5624,6 +5629,23 @@ mod tests { 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)); + + // 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)); From 6718bc58f355058a7d6bb7e1aade381bb43413dd Mon Sep 17 00:00:00 2001 From: David Danialy Date: Thu, 22 Oct 2026 15:06:27 -0700 Subject: [PATCH 3/3] fix(tui): snap a scrollbar drag to the end it is pushed past Co-Authored-By: Claude Opus 5.5 --- src/tui/app.rs | 47 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index b6a28b55..2c4bb0d2 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -989,8 +989,9 @@ pub struct App { /// 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. - scrollbar_grab: Option, + /// 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, /// Observed Command+C forwarding; protocol support alone cannot prove it. pub command_copy_observed: bool, @@ -4988,18 +4989,24 @@ impl App { self.selection = None; let cell = usize::from(mouse.row - self.transcript_bar.y); let (start, length) = self.scrollbar_thumb(); - // A press on the track centers the thumb under the pointer. - let grab = if (start..start + length).contains(&cell) { - cell - start + self.scrollbar_grab = Some(if (start..start + length).contains(&cell) { + // Grabbing the thumb leaves the view where it is. + (cell - start, mouse.row) } else { - length / 2 - }; - self.scrollbar_grab = Some(grab); - self.drag_scrollbar(mouse.row, grab); + // 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 = self.scrollbar_grab.unwrap_or_default(); + 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 { @@ -5088,8 +5095,9 @@ impl App { .saturating_sub(grab) .min(last); // Many offsets draw the thumb on one row; keep the current one while - // the thumb stays put, including when it fills the track. - if last == 0 || target == start { + // 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 @@ -5637,6 +5645,21 @@ mod tests { 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;