diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 06fc14e7196..ed4676228d7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1820,6 +1820,7 @@ dependencies = [ "pulldown-cmark", "rand 0.9.2", "ratatui", + "ratatui-core", "ratatui-macros", "regex-lite", "reqwest", @@ -1841,6 +1842,7 @@ dependencies = [ "tracing-subscriber", "tree-sitter-bash", "tree-sitter-highlight", + "tui-scrollbar", "unicode-segmentation", "unicode-width 0.2.1", "url", @@ -2005,6 +2007,20 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -2582,6 +2598,15 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -3907,6 +3932,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +dependencies = [ + "hashbrown 0.16.0", + "thiserror 2.0.17", +] + [[package]] name = "keyring" version = "3.6.3" @@ -4052,6 +4087,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "local-waker" version = "0.1.4" @@ -5391,7 +5432,7 @@ source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch# dependencies = [ "bitflags 2.10.0", "cassowary", - "compact_str", + "compact_str 0.8.1", "crossterm", "indoc", "instability", @@ -5400,7 +5441,27 @@ dependencies = [ "paste", "strum 0.26.3", "unicode-segmentation", - "unicode-truncate", + "unicode-truncate 1.1.0", + "unicode-width 0.2.1", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.10.0", + "compact_str 0.9.0", + "hashbrown 0.16.0", + "indoc", + "itertools 0.14.0", + "kasuari", + "lru 0.16.2", + "strum 0.27.2", + "thiserror 2.0.17", + "unicode-segmentation", + "unicode-truncate 2.0.0", "unicode-width 0.2.1", ] @@ -6567,6 +6628,9 @@ name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] [[package]] name = "strum_macros" @@ -7413,6 +7477,16 @@ dependencies = [ "termcolor", ] +[[package]] +name = "tui-scrollbar" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42613099915b2e30e9f144670666e858e2538366f77742e1cf1c2f230efcacd" +dependencies = [ + "document-features", + "ratatui-core", +] + [[package]] name = "typenum" version = "1.18.0" @@ -7480,6 +7554,17 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "unicode-truncate" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fbf03860ff438702f3910ca5f28f8dac63c1c11e7efb5012b8b175493606330" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.2.1", +] + [[package]] name = "unicode-width" version = "0.1.14" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 7aca509cc85..df161340a21 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -176,6 +176,7 @@ pretty_assertions = "1.4.1" pulldown-cmark = "0.10" rand = "0.9" ratatui = "0.29.0" +ratatui-core = "0.1.0" ratatui-macros = "0.6.0" regex = "1.12.2" regex-lite = "0.1.8" @@ -219,6 +220,7 @@ tree-sitter = "0.25.10" tree-sitter-bash = "0.25" tree-sitter-highlight = "0.25.10" ts-rs = "11" +tui-scrollbar = "0.2.1" uds_windows = "1.1.0" unicode-segmentation = "1.12.0" unicode-width = "0.2" diff --git a/codex-rs/tui2/Cargo.toml b/codex-rs/tui2/Cargo.toml index eb4e9cebde5..3108e5561e5 100644 --- a/codex-rs/tui2/Cargo.toml +++ b/codex-rs/tui2/Cargo.toml @@ -62,6 +62,7 @@ ratatui = { workspace = true, features = [ "unstable-rendered-line-info", "unstable-widget-ref", ] } +ratatui-core = { workspace = true } ratatui-macros = { workspace = true } regex-lite = { workspace = true } reqwest = { version = "0.12", features = ["json"] } @@ -73,6 +74,7 @@ strum_macros = { workspace = true } supports-color = { workspace = true } tempfile = { workspace = true } textwrap = { workspace = true } +tui-scrollbar = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 84d16415d74..677d73d71ea 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -20,6 +20,11 @@ use crate::transcript_copy_action::TranscriptCopyAction; use crate::transcript_copy_action::TranscriptCopyFeedback; use crate::transcript_copy_ui::TranscriptCopyUi; use crate::transcript_multi_click::TranscriptMultiClick; +use crate::transcript_scrollbar::render_transcript_scrollbar_if_active; +use crate::transcript_scrollbar::split_transcript_area; +use crate::transcript_scrollbar_ui::TranscriptScrollbarMouseEvent; +use crate::transcript_scrollbar_ui::TranscriptScrollbarMouseHandling; +use crate::transcript_scrollbar_ui::TranscriptScrollbarUi; use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS; use crate::transcript_selection::TranscriptSelection; use crate::transcript_selection::TranscriptSelectionPoint; @@ -337,6 +342,7 @@ pub(crate) struct App { transcript_total_lines: usize, transcript_copy_ui: TranscriptCopyUi, transcript_copy_action: TranscriptCopyAction, + transcript_scrollbar_ui: TranscriptScrollbarUi, // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, @@ -503,6 +509,7 @@ impl App { transcript_total_lines: 0, transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(copy_selection_shortcut), transcript_copy_action: TranscriptCopyAction::default(), + transcript_scrollbar_ui: TranscriptScrollbarUi::default(), overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -708,18 +715,19 @@ impl App { return area.y; } - let transcript_area = Rect { + let transcript_full_area = Rect { x: area.x, y: area.y, width: area.width, height: max_transcript_height, }; + let (transcript_area, _) = split_transcript_area(transcript_full_area); self.transcript_view_cache .ensure_wrapped(cells, transcript_area.width); let total_lines = self.transcript_view_cache.lines().len(); if total_lines == 0 { - Clear.render_ref(transcript_area, frame.buffer); + Clear.render_ref(transcript_full_area, frame.buffer); self.transcript_scroll = TranscriptScroll::default(); self.transcript_view_top = 0; self.transcript_total_lines = 0; @@ -760,12 +768,14 @@ impl App { ); } - let transcript_area = Rect { + let transcript_full_area = Rect { x: area.x, y: area.y, width: area.width, height: transcript_visible_height, }; + let (transcript_area, transcript_scrollbar_area) = + split_transcript_area(transcript_full_area); // Cache a few viewports worth of rasterized rows so redraws during streaming can cheaply // copy already-rendered `Cell`s instead of re-running grapheme segmentation. @@ -806,6 +816,13 @@ impl App { } else { self.transcript_copy_ui.clear_affordance(); } + render_transcript_scrollbar_if_active( + frame.buffer, + transcript_scrollbar_area, + total_lines, + max_visible, + top_offset, + ); chat_top } @@ -854,21 +871,45 @@ impl App { return; } - let transcript_area = Rect { + let transcript_full_area = Rect { x: 0, y: 0, width, height: transcript_height, }; + let (transcript_area, transcript_scrollbar_area) = + split_transcript_area(transcript_full_area); let base_x = transcript_area.x.saturating_add(TRANSCRIPT_GUTTER_COLS); let max_x = transcript_area.right().saturating_sub(1); + if matches!( + self.transcript_scrollbar_ui + .handle_mouse_event(TranscriptScrollbarMouseEvent { + tui, + mouse_event, + transcript_area, + scrollbar_area: transcript_scrollbar_area, + transcript_cells: &self.transcript_cells, + transcript_view_cache: &mut self.transcript_view_cache, + transcript_scroll: &mut self.transcript_scroll, + transcript_view_top: &mut self.transcript_view_top, + transcript_total_lines: &mut self.transcript_total_lines, + mouse_scroll_state: &mut self.scroll_state, + }), + TranscriptScrollbarMouseHandling::Handled + ) { + return; + } + // Treat the transcript as the only interactive region for transcript selection. // // This prevents clicks in the composer/footer from starting or extending a transcript // selection, while still allowing a left-click outside the transcript to clear an // existing highlight. - if mouse_event.row < transcript_area.y || mouse_event.row >= transcript_area.bottom() { + if !self.transcript_scrollbar_ui.pointer_capture_active() + && (mouse_event.row < transcript_full_area.y + || mouse_event.row >= transcript_full_area.bottom()) + { if matches!( mouse_event.kind, MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) @@ -1082,7 +1123,15 @@ impl App { return None; } - Some((transcript_height as usize, width)) + let transcript_full_area = Rect { + x: 0, + y: 0, + width, + height: transcript_height, + }; + let (transcript_area, _) = split_transcript_area(transcript_full_area); + + Some((transcript_height as usize, transcript_area.width)) } /// Scroll the transcript by a number of visual lines. @@ -2084,6 +2133,7 @@ mod tests { CopySelectionShortcut::CtrlShiftC, ), transcript_copy_action: TranscriptCopyAction::default(), + transcript_scrollbar_ui: TranscriptScrollbarUi::default(), overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -2136,6 +2186,7 @@ mod tests { CopySelectionShortcut::CtrlShiftC, ), transcript_copy_action: TranscriptCopyAction::default(), + transcript_scrollbar_ui: TranscriptScrollbarUi::default(), overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 3c5ac92f6c3..8cd3fde1331 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -81,6 +81,8 @@ mod transcript_copy_action; mod transcript_copy_ui; mod transcript_multi_click; mod transcript_render; +mod transcript_scrollbar; +mod transcript_scrollbar_ui; mod transcript_selection; mod transcript_view_cache; mod tui; diff --git a/codex-rs/tui2/src/transcript_copy_action.rs b/codex-rs/tui2/src/transcript_copy_action.rs index 04ea239b024..49a894d3353 100644 --- a/codex-rs/tui2/src/transcript_copy_action.rs +++ b/codex-rs/tui2/src/transcript_copy_action.rs @@ -17,8 +17,10 @@ use std::time::Duration; use std::time::Instant; use crate::history_cell::HistoryCell; +use crate::transcript_scrollbar::split_transcript_area; use crate::transcript_selection::TranscriptSelection; use crate::tui; +use ratatui::layout::Rect; /// User-visible feedback shown briefly after a copy attempt. /// @@ -162,10 +164,18 @@ pub(crate) fn copy_transcript_selection( return CopySelectionOutcome::NoSelection; } + let transcript_full_area = Rect { + x: 0, + y: 0, + width, + height: transcript_height, + }; + let (transcript_area, _) = split_transcript_area(transcript_full_area); + let Some(text) = crate::transcript_copy::selection_to_copy_text_for_cells( transcript_cells, transcript_selection, - width, + transcript_area.width, ) else { return CopySelectionOutcome::NoSelection; }; diff --git a/codex-rs/tui2/src/transcript_scrollbar.rs b/codex-rs/tui2/src/transcript_scrollbar.rs new file mode 100644 index 00000000000..d58d758d17c --- /dev/null +++ b/codex-rs/tui2/src/transcript_scrollbar.rs @@ -0,0 +1,535 @@ +//! Transcript scrollbar rendering. +//! +//! The transcript in `codex-tui2` is rendered as a flattened list of wrapped visual lines. The +//! viewport is tracked as a top-row offset (`transcript_view_top`) into that flattened list (see +//! `tui/scrolling.rs` and `tui_viewport_and_history.md`). +//! +//! This module adds a scrollbar to that viewport using the `tui-scrollbar` widget, but does so in +//! a way that keeps the transcript hot path simple and avoids visual layout jank. +//! +//! # Layout and invariants +//! +//! The transcript area is split into: +//! +//! - `content_area`: where transcript text is rendered +//! - `scrollbar_area`: a 1-column region used to render the scrollbar +//! +//! Additionally, we reserve a 1-column *gap* between content and scrollbar. This produces a +//! slightly more stable/intentional look (the scrollbar reads as an affordance, not part of the +//! transcript content) and avoids accidental overlap with selection/copy UI. +//! +//! Important invariant: **any code that computes transcript wrapping, scrolling, selection, or +//! copy must use the same width as on-screen transcript rendering**. In practice that means: +//! +//! - Use [`split_transcript_area`] and pass `content_area.width` into anything that depends on +//! transcript width (wrapping, scroll deltas, selection reconstruction for copy, etc.). +//! - Do not mix `terminal.width` and `content_area.width` for transcript operations; doing so +//! causes off-by-one/off-by-two behaviors where the selection highlights and copied text do not +//! match what the user sees. +//! +//! `App` follows this rule by deriving `content_area.width` anywhere it needs transcript width. +//! +//! # When the scrollbar is shown +//! +//! The scrollbar is only drawn when the transcript is *not* pinned to the bottom: +//! +//! - `offset < max_offset` → draw scrollbar +//! - `offset == max_offset` → keep the column reserved but blank +//! +//! This keeps the UI clean during normal operation (where the viewport follows streaming output), +//! while still providing a clear affordance when the user is actively reading scrollback. +//! +//! # Styling and theme heuristics +//! +//! `tui-scrollbar` 0.2.1 changed defaults (no arrows, space track + dark background). We keep +//! default glyphs/arrows so the widget controls its own shape, but override track/thumb colors so +//! it matches `codex-tui2`’s existing "user prompt block" styling: +//! +//! - The track background is a small blend toward the terminal foreground so it looks like a +//! subtle indent against the terminal background. +//! - The thumb foreground is a stronger blend so it reads as the active element. +//! - In light themes (terminal background is light), the thumb is intentionally a *darker* shade +//! than the track so it reads as an inset element. +//! +//! We derive these colors from the terminal’s default foreground/background (when available via +//! `terminal_palette`). When defaults are unknown (tests / unsupported terminals), we fall back to +//! ANSI colors so the scrollbar remains visible. +//! +//! # Pointer interaction (mouse click/drag) +//! +//! `tui-scrollbar` includes an interaction helper that translates pointer events into +//! `ScrollCommand::SetOffset(...)` updates, including "grab offset" handling so dragging keeps the +//! pointer anchored within the thumb. +//! +//! `codex-tui2` uses that helper rather than reimplementing scrollbar hit testing and drag math. +//! The app owns the actual transcript scroll state (anchors in `tui/scrolling.rs`), so we only use +//! `tui-scrollbar` to decide *which* offset the user requested. `App` then converts that raw +//! `offset` back into a stable [`TranscriptScroll`] anchor. +//! +//! Note: we use `tui-scrollbar`’s backend-agnostic [`ScrollEvent`] types instead of its optional +//! `crossterm` adapter, because the workspace uses a patched `crossterm` and we want to avoid +//! pulling in multiple `crossterm` versions (which would make `MouseEvent` types incompatible). +//! +//! Because the scrollbar is visually hidden while pinned-to-bottom, `App` also keeps a tiny +//! "pointer capture" bool so a drag that reaches the bottom doesn't accidentally turn into a text +//! selection once the scrollbar disappears. +//! +//! # `ratatui` vs `ratatui-core` +//! +//! `codex-tui2` uses the `ratatui` crate, while `tui-scrollbar` is built on `ratatui-core`. +//! Because the buffer and style types are distinct, we render the scrollbar into a small +//! `ratatui-core` scratch buffer and then copy the resulting glyphs into the main `ratatui` +//! buffer with `ratatui` styles. +//! +//! ## Upgrade note: Ratatui 0.30+ +//! +//! Ratatui 0.30 split many core types (including `Buffer`, `Rect`, and `Widget`) into the new +//! `ratatui-core` crate. `codex-tui2` is currently pinned to an older Ratatui, so it still works +//! with `ratatui::buffer::Buffer` / `ratatui::layout::Rect`, while `tui-scrollbar` is already on +//! `ratatui-core`. +//! +//! That mismatch forces two bits of "glue" that should go away once `codex-tui2` upgrades to +//! Ratatui 0.30: +//! +//! - Rendering: `render_transcript_scrollbar_if_active` currently renders into a `ratatui-core` +//! scratch buffer and copies glyphs/styles into the `ratatui` buffer. With Ratatui 0.30, the +//! app’s buffer/rect types should unify with `tui-scrollbar`’s `ratatui-core` types, so we can +//! render directly without copying. +//! - Input: we currently translate `crossterm::MouseEvent` into `tui-scrollbar`’s backend-agnostic +//! `ScrollEvent` types (and intentionally avoid `tui-scrollbar`’s optional `crossterm` adapter) +//! to prevent multiple `crossterm` versions in the dependency graph. Once the Ratatui upgrade +//! is complete, this should be revisited; if the workspace’s `crossterm` resolves to a single +//! version, we can use `tui-scrollbar`’s adapter and reduce more local glue. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui_core::buffer::Buffer as CoreBuffer; +use ratatui_core::layout::Rect as CoreRect; +use ratatui_core::widgets::Widget as _; +use tui_scrollbar::PointerButton; +use tui_scrollbar::PointerEvent; +use tui_scrollbar::PointerEventKind; +use tui_scrollbar::ScrollBar; +use tui_scrollbar::ScrollBarInteraction; +use tui_scrollbar::ScrollCommand; +use tui_scrollbar::ScrollEvent; +use tui_scrollbar::ScrollLengths; +use tui_scrollbar::TrackClickBehavior; + +/// Number of columns reserved between transcript content and the scrollbar track. +/// +/// This exists purely for visual separation and to avoid selection/copy UI feeling "attached" to +/// the scrollbar. +const TRANSCRIPT_SCROLLBAR_GAP_COLS: u16 = 1; +/// Width of the scrollbar track itself (in terminal cells). +/// +/// `tui-scrollbar` renders a vertical scrollbar into a 1-column area. +const TRANSCRIPT_SCROLLBAR_TRACK_COLS: u16 = 1; +/// Total columns reserved for transcript scrollbar UI (gap + track). +pub(crate) const TRANSCRIPT_SCROLLBAR_COLS: u16 = + TRANSCRIPT_SCROLLBAR_GAP_COLS + TRANSCRIPT_SCROLLBAR_TRACK_COLS; + +/// Split a transcript viewport into content + scrollbar regions. +/// +/// `codex-tui2` reserves space for the transcript scrollbar even when it is not visible so the +/// transcript does not "reflow" when the user scrolls away from the bottom. +/// +/// Layout: +/// - `content_area`: original area minus [`TRANSCRIPT_SCROLLBAR_COLS`] on the right. +/// - `scrollbar_area`: the last column of the original area (1 cell wide). +/// - The remaining column (immediately left of `scrollbar_area`) is the "gap" and is intentionally +/// left unused so the scrollbar reads as a separate affordance. +/// +/// Returns `(area, None)` when the terminal is too narrow to reserve the required columns. +pub(crate) fn split_transcript_area(area: Rect) -> (Rect, Option) { + if area.width <= TRANSCRIPT_SCROLLBAR_COLS { + return (area, None); + } + + let content_width = area.width.saturating_sub(TRANSCRIPT_SCROLLBAR_COLS); + let content_area = Rect { + x: area.x, + y: area.y, + width: content_width, + height: area.height, + }; + let scrollbar_area = Rect { + x: area.right().saturating_sub(1), + y: area.y, + width: TRANSCRIPT_SCROLLBAR_TRACK_COLS, + height: area.height, + }; + + (content_area, Some(scrollbar_area)) +} + +/// Whether the transcript scrollbar should be visible. +/// +/// The scrollbar is treated as "active" when the transcript is scrollable and the viewport is not +/// pinned to the bottom. This is used both for rendering (draw vs. keep blank) and for interaction +/// (whether the scrollbar should be hit-testable). +/// +/// Note that `codex-tui2` still reserves space for the scrollbar even when it is inactive; see +/// [`split_transcript_area`]. +pub(crate) fn is_transcript_scrollbar_active( + total_lines: usize, + viewport_lines: usize, + top_offset: usize, +) -> bool { + if total_lines <= viewport_lines { + return false; + } + + let max_offset = total_lines.saturating_sub(viewport_lines); + top_offset < max_offset +} + +/// Render the transcript scrollbar into `buf` when the viewport is scrolled away from bottom. +/// +/// The scrollbar is hidden (but its column(s) remain reserved) while the viewport follows the +/// latest output. +/// +/// Implementation notes: +/// - We keep `tui-scrollbar`’s default glyph selection and shape logic, but override colors to +/// better match `codex-tui2`’s theme heuristics (see module docs). +/// - Because `tui-scrollbar` renders into a `ratatui-core` buffer while `codex-tui2` uses `ratatui` +/// (pre-0.30), we render into a scratch buffer and then copy the resulting symbols into the main +/// buffer. +pub(crate) fn render_transcript_scrollbar_if_active( + buf: &mut Buffer, + scrollbar_area: Option, + total_lines: usize, + viewport_lines: usize, + top_offset: usize, +) { + let Some(scrollbar_area) = scrollbar_area else { + return; + }; + + if scrollbar_area.width == 0 || scrollbar_area.height == 0 { + return; + } + + if !is_transcript_scrollbar_active(total_lines, viewport_lines, top_offset) { + return; + } + + let lengths = ScrollLengths { + content_len: total_lines, + viewport_len: viewport_lines, + }; + + let scrollbar = ScrollBar::vertical(lengths).offset(top_offset); + + let core_bar_area = CoreRect { + x: scrollbar_area.x, + y: scrollbar_area.y, + width: scrollbar_area.width, + height: scrollbar_area.height, + }; + let mut scratch = CoreBuffer::empty(core_bar_area); + (&scrollbar).render(core_bar_area, &mut scratch); + + let (track_style, thumb_style) = scrollbar_styles(); + for row in 0..scrollbar_area.height { + let x = scrollbar_area.x; + let y = scrollbar_area.y + row; + let src = &scratch[(x, y)]; + let dst = &mut buf[(x, y)]; + let symbol = src.symbol(); + dst.set_symbol(symbol); + if symbol == " " { + dst.set_style(track_style); + } else { + dst.set_style(thumb_style); + } + } +} + +/// Convert a `crossterm` mouse event into a requested transcript offset for the scrollbar. +/// +/// This is a thin wrapper over `tui-scrollbar`’s pointer interaction logic: +/// - It builds a `ScrollBar` configured with the current `top_offset`. +/// - It translates the mouse event into a backend-agnostic [`ScrollEvent`]. +/// - It passes the event through `tui-scrollbar`’s hit testing and drag state (`interaction`). +/// +/// `clamp_to_track` exists for `App`’s "pointer capture" behavior: once the user starts a drag on +/// the scrollbar, we keep treating the gesture as a scrollbar drag even if the pointer moves +/// outside the 1-column track. Without this clamp, the drag could stop producing offsets, and the +/// same mouse gesture could then be interpreted as transcript selection. +/// +/// Returns `None` when: +/// - the scrollbar area is empty, +/// - the transcript does not scroll (`total_lines <= viewport_lines`), +/// - or the event is not a left-button down/drag/up. +pub(crate) fn transcript_scrollbar_offset_for_mouse_event( + scrollbar_area: Rect, + total_lines: usize, + viewport_lines: usize, + top_offset: usize, + mut event: crossterm::event::MouseEvent, + interaction: &mut ScrollBarInteraction, + clamp_to_track: bool, +) -> Option { + if scrollbar_area.width == 0 || scrollbar_area.height == 0 { + return None; + } + + if total_lines <= viewport_lines { + return None; + } + + if clamp_to_track { + let max_x = scrollbar_area.right().saturating_sub(1); + let max_y = scrollbar_area.bottom().saturating_sub(1); + event.column = event.column.clamp(scrollbar_area.x, max_x); + event.row = event.row.clamp(scrollbar_area.y, max_y); + } + + let lengths = ScrollLengths { + content_len: total_lines, + viewport_len: viewport_lines, + }; + let scrollbar = ScrollBar::vertical(lengths) + .offset(top_offset) + .track_click_behavior(TrackClickBehavior::JumpToClick); + + let core_bar_area = CoreRect { + x: scrollbar_area.x, + y: scrollbar_area.y, + width: scrollbar_area.width, + height: scrollbar_area.height, + }; + let scroll_event = match event.kind { + crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) => { + Some(ScrollEvent::Pointer(PointerEvent { + column: event.column, + row: event.row, + kind: PointerEventKind::Down, + button: PointerButton::Primary, + })) + } + crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left) => { + Some(ScrollEvent::Pointer(PointerEvent { + column: event.column, + row: event.row, + kind: PointerEventKind::Up, + button: PointerButton::Primary, + })) + } + crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => { + Some(ScrollEvent::Pointer(PointerEvent { + column: event.column, + row: event.row, + kind: PointerEventKind::Drag, + button: PointerButton::Primary, + })) + } + _ => None, + }; + scroll_event + .and_then(|scroll_event| scrollbar.handle_event(core_bar_area, scroll_event, interaction)) + .map(|command| match command { + ScrollCommand::SetOffset(offset) => offset, + }) +} + +/// Derive track/thumb styles for the scrollbar from terminal defaults. +/// +/// We prefer using the terminal’s default background/foreground so the scrollbar feels like a +/// native part of the theme (and stays readable across 16-color / 256-color / truecolor +/// backends). +/// +/// When terminal defaults are unavailable (tests / unsupported terminals), we fall back to fixed +/// ANSI colors that are likely to be visible. +fn scrollbar_styles() -> (Style, Style) { + let Some(terminal_bg) = crate::terminal_palette::default_bg() else { + let track_style = Style::new().bg(Color::DarkGray); + let thumb_style = Style::new().fg(Color::Gray).bg(Color::DarkGray); + return (track_style, thumb_style); + }; + + let terminal_fg = crate::terminal_palette::default_fg(); + + let (track_rgb, thumb_rgb) = scrollbar_colors(terminal_bg, terminal_fg); + let track_bg = crate::terminal_palette::best_color(track_rgb); + let thumb_fg = crate::terminal_palette::best_color(thumb_rgb); + + let track_style = Style::new().bg(track_bg); + let thumb_style = Style::new().fg(thumb_fg).bg(track_bg); + (track_style, thumb_style) +} + +/// Compute `(track_bg_rgb, thumb_fg_rgb)` for the transcript scrollbar. +/// +/// The scrollbar is styled to feel consistent with the user prompt background (see +/// `style::user_message_bg`), but is tuned separately so the thumb reads as an inset control: +/// +/// - Dark themes: track is a subtle brightening of the background; thumb is brighter than the track +/// (but not pure white). +/// - Light themes: track is a subtle darkening of the background; thumb is darker than the track. +fn scrollbar_colors( + terminal_bg: (u8, u8, u8), + terminal_fg: Option<(u8, u8, u8)>, +) -> ((u8, u8, u8), (u8, u8, u8)) { + let is_light = crate::color::is_light(terminal_bg); + let fallback_fg = if is_light { (0, 0, 0) } else { (255, 255, 255) }; + let terminal_fg = terminal_fg.unwrap_or(fallback_fg); + + // We want the scrollbar to feel visually related to the user message block background + // (`style::user_message_bg` uses 0.1), but slightly more subtle: + // + // - Light mode: keep both colors closer to the background (alpha < 0.1), with the thumb darker + // than the track. + // - Dark mode: keep the track slightly darker than the prompt block, but make the thumb + // brighter so it's easy to pick out without becoming "white". + let (track_alpha, thumb_alpha) = if is_light { (0.04, 0.08) } else { (0.08, 0.18) }; + + let track_rgb = crate::color::blend(terminal_fg, terminal_bg, track_alpha); + let thumb_rgb = crate::color::blend(terminal_fg, terminal_bg, thumb_alpha); + (track_rgb, thumb_rgb) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn scrollbar_bg(buf: &Buffer, scrollbar_area: Rect) -> Vec { + use ratatui::style::Color; + + let x = scrollbar_area.x; + (0..scrollbar_area.height) + .map(|row| { + buf[(x, scrollbar_area.y + row)] + .style() + .bg + .unwrap_or(Color::Reset) + }) + .collect() + } + + #[test] + fn does_not_render_when_pinned_to_bottom() { + let full_area = Rect::new(0, 0, 10, 6); + let (_, scrollbar_area) = split_transcript_area(full_area); + let mut buf = Buffer::empty(full_area); + + render_transcript_scrollbar_if_active(&mut buf, scrollbar_area, 100, 6, 94); + + assert_eq!( + scrollbar_bg(&buf, scrollbar_area.expect("scrollbar area")), + vec![ + ratatui::style::Color::Reset, + ratatui::style::Color::Reset, + ratatui::style::Color::Reset, + ratatui::style::Color::Reset, + ratatui::style::Color::Reset, + ratatui::style::Color::Reset + ] + ); + } + + #[test] + fn renders_when_scrolled_away_from_bottom() { + let full_area = Rect::new(0, 0, 10, 6); + let (_, scrollbar_area) = split_transcript_area(full_area); + let mut buf = Buffer::empty(full_area); + + render_transcript_scrollbar_if_active(&mut buf, scrollbar_area, 100, 6, 80); + + assert_eq!( + scrollbar_bg(&buf, scrollbar_area.expect("scrollbar area")), + vec![ + ratatui::style::Color::DarkGray, + ratatui::style::Color::DarkGray, + ratatui::style::Color::DarkGray, + ratatui::style::Color::DarkGray, + ratatui::style::Color::DarkGray, + ratatui::style::Color::DarkGray + ] + ); + } + + #[test] + fn split_leaves_gap_before_scrollbar() { + let full_area = Rect::new(0, 0, 10, 6); + let (content, scrollbar) = split_transcript_area(full_area); + + assert_eq!(content.width, 8); + assert_eq!(scrollbar.expect("scrollbar").x, 9); + } + + #[test] + fn scrollbar_mouse_drag_moves_offset_downward() { + use crossterm::event::KeyModifiers; + use crossterm::event::MouseButton; + use crossterm::event::MouseEvent; + use crossterm::event::MouseEventKind; + + let scrollbar_area = Rect::new(9, 0, 1, 10); + let mut interaction = ScrollBarInteraction::new(); + + let down = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 9, + row: 0, + modifiers: KeyModifiers::empty(), + }; + let mut offset = 0; + if let Some(next) = transcript_scrollbar_offset_for_mouse_event( + scrollbar_area, + 100, + 10, + offset, + down, + &mut interaction, + true, + ) { + offset = next; + } + + let drag = MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 9, + row: 9, + modifiers: KeyModifiers::empty(), + }; + let dragged = transcript_scrollbar_offset_for_mouse_event( + scrollbar_area, + 100, + 10, + offset, + drag, + &mut interaction, + true, + ) + .expect("drag should set an offset"); + + assert!(dragged > offset); + } + + #[test] + fn light_mode_thumb_is_darker_than_track() { + let bg = (255, 255, 255); + let fg = Some((0, 0, 0)); + let (track, thumb) = scrollbar_colors(bg, fg); + + assert!(thumb.0 < track.0); + assert!(thumb.1 < track.1); + assert!(thumb.2 < track.2); + } + + #[test] + fn dark_mode_thumb_is_brighter_than_track() { + let bg = (0, 0, 0); + let fg = Some((255, 255, 255)); + let (track, thumb) = scrollbar_colors(bg, fg); + + assert!(thumb.0 > track.0); + assert!(thumb.1 > track.1); + assert!(thumb.2 > track.2); + } +} diff --git a/codex-rs/tui2/src/transcript_scrollbar_ui.rs b/codex-rs/tui2/src/transcript_scrollbar_ui.rs new file mode 100644 index 00000000000..92f19d1bc54 --- /dev/null +++ b/codex-rs/tui2/src/transcript_scrollbar_ui.rs @@ -0,0 +1,225 @@ +//! Transcript scrollbar mouse interaction. +//! +//! This module handles pointer interaction (click/drag) for the transcript scrollbar rendered by +//! [`crate::transcript_scrollbar`]. It exists to keep `app.rs` from growing further: the transcript +//! is a particularly stateful part of the UI (selection, wrapping, scroll anchoring, copy, etc.), +//! and scrollbar interaction needs to coordinate with several of those subsystems. +//! +//! # Responsibilities +//! +//! - Translate `crossterm` mouse events into `tui-scrollbar` interaction events (backend-agnostic +//! [`tui_scrollbar::ScrollEvent`]). +//! - Maintain `tui-scrollbar`’s drag interaction state (`ScrollBarInteraction`) across frames so +//! the thumb "grab offset" behaves naturally. +//! - Maintain a tiny "pointer capture" flag so a drag that reaches the bottom doesn't fall through +//! into transcript selection once the scrollbar becomes visually hidden (because the view is now +//! pinned to bottom). +//! +//! This module does *not* render anything. Rendering lives in `transcript_scrollbar.rs`. +//! +//! # Interaction model and transcript anchors +//! +//! `tui-scrollbar` reports requested scroll positions as a raw `offset` (a top-row index). The +//! transcript scroll state in `codex-tui2` is represented as a stable anchor +//! ([`crate::tui::scrolling::TranscriptScroll`]) so it survives transcript growth and re-wrapping. +//! +//! The conversion happens here: +//! - Ask `tui-scrollbar` for a `next_offset`. +//! - Convert that concrete offset back into a stable anchor using +//! [`crate::tui::scrolling::TranscriptScroll::anchor_for`]. +//! - If the requested offset is the bottom-most valid position, use `ToBottom` rather than a fixed +//! anchor, restoring auto-follow behavior. +//! +//! This keeps scrollbar interaction consistent with other scroll mechanisms (wheel, PgUp/PgDn), +//! which also operate in terms of the `TranscriptScroll` state machine. +//! +//! # Upgrade note: Ratatui 0.30+ +//! +//! This module intentionally uses `tui-scrollbar`’s backend-agnostic event types instead of its +//! optional `crossterm` adapter. The workspace uses a patched `crossterm`, and enabling the adapter +//! would pull in a second `crossterm` version, making `MouseEvent` types incompatible. +//! +//! Once `codex-tui2` upgrades to Ratatui 0.30 (and the workspace converges on a single `crossterm` +//! version), we should revisit whether we can remove this translation layer. + +use crate::history_cell::HistoryCell; +use crate::transcript_scrollbar::is_transcript_scrollbar_active; +use crate::transcript_scrollbar::transcript_scrollbar_offset_for_mouse_event; +use crate::transcript_view_cache::TranscriptViewCache; +use crate::tui; +use crate::tui::scrolling::MouseScrollState; +use crate::tui::scrolling::TranscriptScroll; +use crossterm::event::MouseButton; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; +use ratatui::layout::Rect; +use std::sync::Arc; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TranscriptScrollbarMouseHandling { + /// The event is unrelated to the scrollbar; callers may handle it normally (e.g. selection). + NotHandled, + /// The event was handled by the scrollbar logic and should not be interpreted as selection. + Handled, +} + +/// Persistent UI state for transcript scrollbar pointer interaction. +/// +/// This stores `tui-scrollbar`’s drag state (`ScrollBarInteraction`) plus a small "pointer capture" +/// flag used by `codex-tui2`: +/// +/// - When the user clicks the scrollbar thumb/track, we enter pointer capture. +/// - While capture is active, subsequent drag events are treated as scrollbar drags even if the +/// pointer leaves the 1-column track. +/// - Capture is released on `MouseUp`. +/// +/// The capture flag is important because the transcript scrollbar is hidden while pinned to +/// bottom; without capture, a drag that reaches the bottom could stop producing offsets and fall +/// through into transcript selection mid-gesture. +#[derive(Debug, Default)] +pub(crate) struct TranscriptScrollbarUi { + interaction: tui_scrollbar::ScrollBarInteraction, + pointer_capture: bool, +} + +/// Bundles the arguments needed to handle a transcript scrollbar mouse event. +/// +/// This is intentionally a struct (rather than a long argument list) because scrollbar interaction +/// touches several pieces of transcript state at once: wrapping cache, scroll anchor state, the +/// concrete top-row offset, and the wheel-scroll stream state machine. Grouping them makes call +/// sites easier to scan and helps keep `app.rs` glue minimal. +pub(crate) struct TranscriptScrollbarMouseEvent<'a> { + pub(crate) tui: &'a mut tui::Tui, + pub(crate) mouse_event: MouseEvent, + pub(crate) transcript_area: Rect, + pub(crate) scrollbar_area: Option, + pub(crate) transcript_cells: &'a [Arc], + pub(crate) transcript_view_cache: &'a mut TranscriptViewCache, + pub(crate) transcript_scroll: &'a mut TranscriptScroll, + pub(crate) transcript_view_top: &'a mut usize, + pub(crate) transcript_total_lines: &'a mut usize, + pub(crate) mouse_scroll_state: &'a mut MouseScrollState, +} + +impl TranscriptScrollbarUi { + pub(crate) fn pointer_capture_active(&self) -> bool { + self.pointer_capture + } + + /// Handle click/drag events for the transcript scrollbar. + /// + /// The caller is expected to provide the transcript layout for the current terminal size: + /// `transcript_area` for content and `scrollbar_area` for the 1-column scrollbar track. See + /// [`crate::transcript_scrollbar::split_transcript_area`]. + /// + /// Returns [`TranscriptScrollbarMouseHandling::Handled`] when the event should not be + /// interpreted as transcript selection (either because it updated the scroll position or + /// because an in-progress scrollbar drag is being captured). + pub(crate) fn handle_mouse_event( + &mut self, + event: TranscriptScrollbarMouseEvent<'_>, + ) -> TranscriptScrollbarMouseHandling { + let TranscriptScrollbarMouseEvent { + tui, + mouse_event, + transcript_area, + scrollbar_area, + transcript_cells, + transcript_view_cache, + transcript_scroll, + transcript_view_top, + transcript_total_lines, + mouse_scroll_state, + } = event; + let is_scrollbar_event = matches!( + mouse_event.kind, + MouseEventKind::Down(MouseButton::Left) + | MouseEventKind::Drag(MouseButton::Left) + | MouseEventKind::Up(MouseButton::Left) + ); + if !is_scrollbar_event { + return TranscriptScrollbarMouseHandling::NotHandled; + } + + let Some(scrollbar_area) = scrollbar_area else { + if matches!(mouse_event.kind, MouseEventKind::Up(MouseButton::Left)) { + self.pointer_capture = false; + } + return if self.pointer_capture { + TranscriptScrollbarMouseHandling::Handled + } else { + TranscriptScrollbarMouseHandling::NotHandled + }; + }; + + let is_over_scrollbar = mouse_event.column >= scrollbar_area.x + && mouse_event.column < scrollbar_area.right() + && mouse_event.row >= scrollbar_area.y + && mouse_event.row < scrollbar_area.bottom(); + + if !self.pointer_capture && !is_over_scrollbar { + return TranscriptScrollbarMouseHandling::NotHandled; + } + + let viewport_lines = transcript_area.height as usize; + let scrollbar_is_visible = if viewport_lines > 0 && !transcript_cells.is_empty() { + transcript_view_cache.ensure_wrapped(transcript_cells, transcript_area.width); + let total_lines = transcript_view_cache.lines().len(); + let max_visible = std::cmp::min(total_lines, viewport_lines); + is_transcript_scrollbar_active(total_lines, max_visible, *transcript_view_top) + } else { + false + }; + + // When the transcript is pinned to bottom, we intentionally hide the scrollbar (but still + // reserve its column). In that state, we avoid hit-testing the scrollbar track so the + // reserved column doesn't become an invisible interactive region. Pointer capture remains + // active for an in-progress drag so a gesture that reaches the bottom doesn't fall through + // into transcript selection mid-drag. + if !self.pointer_capture && !scrollbar_is_visible { + return TranscriptScrollbarMouseHandling::NotHandled; + } + + if matches!(mouse_event.kind, MouseEventKind::Down(MouseButton::Left)) && is_over_scrollbar + { + self.pointer_capture = true; + } + + if viewport_lines > 0 && !transcript_cells.is_empty() { + // `ensure_wrapped` was already called above when checking visibility. + let total_lines = transcript_view_cache.lines().len(); + let max_visible = std::cmp::min(total_lines, viewport_lines); + let max_offset = total_lines.saturating_sub(max_visible); + + if let Some(next_offset) = transcript_scrollbar_offset_for_mouse_event( + scrollbar_area, + total_lines, + max_visible, + *transcript_view_top, + mouse_event, + &mut self.interaction, + self.pointer_capture, + ) { + let next_offset = next_offset.min(max_offset); + let line_meta = transcript_view_cache.line_meta(); + + *transcript_scroll = if next_offset >= max_offset { + TranscriptScroll::ToBottom + } else { + TranscriptScroll::anchor_for(line_meta, next_offset) + .unwrap_or(TranscriptScroll::ToBottom) + }; + *transcript_view_top = next_offset.min(max_offset); + *transcript_total_lines = total_lines; + *mouse_scroll_state = MouseScrollState::default(); + tui.frame_requester().schedule_frame(); + } + } + + if matches!(mouse_event.kind, MouseEventKind::Up(MouseButton::Left)) { + self.pointer_capture = false; + } + + TranscriptScrollbarMouseHandling::Handled + } +}