diff --git a/codex-rs/tui2/Cargo.toml b/codex-rs/tui2/Cargo.toml index fd542feda..eb4e9cebd 100644 --- a/codex-rs/tui2/Cargo.toml +++ b/codex-rs/tui2/Cargo.toml @@ -106,8 +106,8 @@ arboard = { workspace = true } [dev-dependencies] -codex-core = { workspace = true, features = ["test-support"] } assert_matches = { workspace = true } +codex-core = { workspace = true, features = ["test-support"] } chrono = { workspace = true, features = ["serde"] } insta = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/tui2/docs/streaming_wrapping_design.md b/codex-rs/tui2/docs/streaming_wrapping_design.md new file mode 100644 index 000000000..28937c377 --- /dev/null +++ b/codex-rs/tui2/docs/streaming_wrapping_design.md @@ -0,0 +1,85 @@ +# Streaming Markdown Wrapping & Animation – TUI2 Notes + +This document mirrors the original `tui/streaming_wrapping_design.md` and +captures how the same concerns apply to the new `tui2` crate. It exists so that +future viewport and streaming work in TUI2 can rely on the same context without +having to cross‑reference the legacy TUI implementation. + +At a high level, the design constraints are the same: + +- Streaming agent responses are rendered incrementally, with an animation loop + that reveals content over time. +- Non‑streaming history cells are rendered width‑agnostically and wrapped only + at display time, so they reflow correctly when the terminal is resized. +- Streaming content should eventually follow the same “wrap on display” model so + the transcript reflows consistently across width changes, without regressing + animation or markdown semantics. + +## 1. Where streaming is implemented in TUI2 + +TUI2 keeps the streaming pipeline conceptually aligned with the legacy TUI but +in a separate crate: + +- `tui2/src/markdown_stream.rs` implements the markdown streaming collector and + animation controller for agent deltas. +- `tui2/src/chatwidget.rs` integrates streamed content into the transcript via + `HistoryCell` implementations. +- `tui2/src/history_cell.rs` provides the concrete history cell types used by + the inline transcript and overlays. +- `tui2/src/wrapping.rs` contains the shared text wrapping utilities used by + both streaming and non‑streaming render paths: + - `RtOptions` describes viewport‑aware wrapping (width, indents, algorithm). + - `word_wrap_line`, `word_wrap_lines`, and `word_wrap_lines_borrowed` provide + span‑aware wrapping that preserves markdown styling and emoji width. + +As in the original TUI, the key tension is between: + +- **Pre‑wrapping streamed content at commit time** (simpler animation, but + baked‑in splits that don’t reflow), and +- **Deferring wrapping to render time** (better reflow, but requires a more + sophisticated streaming cell model or recomputation on each frame). + +## 2. Current behavior and limitations + +TUI2 is intentionally conservative for now: + +- Streaming responses use the same markdown streaming and wrapping utilities as + the legacy TUI, with width decisions made near the streaming collector. +- The transcript viewport (`App::render_transcript_cells` in + `tui2/src/app.rs`) always uses `word_wrap_lines_borrowed` against the + current `Rect` width, so: + - Non‑streaming cells reflow naturally on resize. + - Streamed cells respect whatever wrapping was applied when their lines were + constructed, and may not fully “un‑wrap” if that work happened at a fixed + width earlier in the pipeline. + +This means TUI2 shares the same fundamental limitation documented in the +original design note: streamed paragraphs can retain historical wrap decisions +made at the time they were streamed, even if the viewport later grows wider. + +## 3. Design directions (forward‑looking) + +The options outlined in the legacy document apply here as well: + +1. **Keep the current behavior but clarify tests and documentation.** + - Ensure tests in `tui2/src/markdown_stream.rs`, `tui2/src/markdown_render.rs`, + `tui2/src/history_cell.rs`, and `tui2/src/wrapping.rs` encode the current + expectations around streaming, wrapping, and emoji / markdown styling. +2. **Move towards width‑agnostic streaming cells.** + - Introduce a dedicated streaming history cell that stores the raw markdown + buffer and lets `HistoryCell::display_lines(width)` perform both markdown + rendering and wrapping based on the current viewport width. + - Keep the commit animation logic expressed in terms of “logical” positions + (e.g., number of tokens or lines committed) rather than pre‑wrapped visual + lines at a fixed width. +3. **Hybrid “visual line count” model.** + - Track committed visual lines as a scalar and re‑render the streamed prefix + at the current width, revealing only the first `N` visual lines on each + animation tick. + +TUI2 does not yet implement these refactors; it intentionally stays close to +the legacy behavior while the viewport work (scrolling, selection, exit +transcripts) is being ported. This document exists to make that trade‑off +explicit for TUI2 and to provide a natural home for any TUI2‑specific streaming +wrapping notes as the design evolves. + diff --git a/codex-rs/tui2/docs/tui_viewport_and_history.md b/codex-rs/tui2/docs/tui_viewport_and_history.md new file mode 100644 index 000000000..8485e9364 --- /dev/null +++ b/codex-rs/tui2/docs/tui_viewport_and_history.md @@ -0,0 +1,454 @@ +# TUI2 Viewport, Transcript, and History – Design Notes + +This document describes the viewport and history model we are implementing in the new +`codex-rs/tui2` crate. It builds on lessons from the legacy TUI and explains why we moved away +from directly writing history into terminal scrollback. + +The target audience is Codex developers and curious contributors who want to understand or +critique how TUI2 owns its viewport, scrollback, and suspend behavior. + +Unless stated otherwise, references to “the TUI” in this document mean the TUI2 implementation; +when we mean the legacy TUI specifically, we call it out explicitly. + +--- + +## 1. Problem Overview + +Historically, the legacy TUI tried to “cooperate” with the terminal’s own scrollback: + +- The inline viewport sat somewhere above the bottom of the screen. +- When new history arrived, we tried to insert it directly into the terminal scrollback above the + viewport. +- On certain transitions (e.g. switching sessions, overlays), we cleared and re‑wrote portions of + the screen from scratch. + +This had several failure modes: + +- **Terminal‑dependent behavior.** + - Different terminals handle scroll regions, clears, and resize semantics differently. + - What looked correct in one terminal could drop or duplicate content in another. + +- **Resizes and layout churn.** + - The TUI reacts to resizes, focus changes, and overlay transitions. + - When the viewport moved or its size changed, our attempts to keep scrollback “aligned” with the + in‑memory history could go out of sync. + - In practice this meant: + - Some lines were lost or overwritten. + - Others were duplicated or appeared in unexpected places. + +- **“Clear and rewrite everything” didn’t save us.** + - We briefly tried a strategy of clearing large regions (or the full screen) and re‑rendering + history when the layout changed. + - This ran into two issues: + - Terminals treat full clears differently. For example, Terminal.app often leaves the cleared + screen as a “page” at the top of scrollback, some terminals interpret only a subset of the + ANSI clear/scrollback codes, and others (like iTerm2) gate “clear full scrollback” behind + explicit user consent. + - Replaying a long session is expensive and still subject to timing/race conditions with user + output (e.g. shell prompts) when we weren’t in alt screen. + +The net result: the legacy TUI could not reliably guarantee “the history you see on screen is complete, in +order, and appears exactly once” across terminals, resizes, suspend/resume, and overlay transitions. + +--- + +## 2. Goals + +The redesign is guided by a few explicit goals: + +1. **Codex, not the terminal, owns the viewport.** + - The in‑memory transcript (a list of history entries) is the single source of truth for what’s + on screen. + - The TUI decides how to map that transcript into the current viewport; scrollback becomes an + output target, not an extra data structure we try to maintain. + +2. **History must be correct, ordered, and never silently dropped.** + - Every logical history cell should either: + - Be visible in the TUI, or + - Have been printed into scrollback as part of a suspend/exit flow. + - We would rather (rarely) duplicate content than risk losing it. + +3. **Avoid unnecessary duplication.** + - When emitting history to scrollback (on suspend or exit), print each logical cell’s content at + most once. + - Streaming cells are allowed to be “re‑seen” as they grow, but finished cells should not keep + reappearing. + +4. **Behave sensibly under resizes.** + - TUI rendering should reflow to the current width on every frame. + - History printed to scrollback may have been wrapped at different widths over time; that is + acceptable, but it must not cause missing content or unbounded duplication. + +5. **Suspend/alt‑screen interaction is predictable.** + - `Ctrl+Z` should: + - Cleanly exit alt screen, if active. + - Print a consistent transcript prefix into normal scrollback. + - Resume with the TUI fully redrawn, without stale artifacts. + +--- + +## 3. New Viewport & Transcript Model + +### 3.1 Transcript as a logical sequence of cells + +At a high level, the TUI transcript is a list of “cells”, each representing one logical thing in +the conversation: + +- A user prompt (with padding and a distinct background). +- An agent response (which may arrive in multiple streaming chunks). +- System or info rows (session headers, migration banners, reasoning summaries, etc.). + +Each cell knows how to draw itself for a given width: how many lines it needs, what prefixes to +use, how to style its content. The transcript itself is purely logical: + +- It has no scrollback coordinates or terminal state baked into it. +- It can be re‑rendered for any viewport width. + +The TUI’s job is to take this logical sequence and decide how much of it fits into the current +viewport, and how it should be wrapped and styled on screen. + +### 3.2 Building viewport lines from the transcript + +To render the main transcript area above the composer, the TUI: + +1. Defines a “transcript region” as the full frame minus the height of the bottom input area. +2. Flattens all cells into a list of visual lines, remembering for each visual line which cell it + came from and which line within that cell it corresponds to. +3. Uses this flattened list plus a scroll position to decide which visual line should appear at the + top of the region. +4. Clears the transcript region and draws the visible slice of lines into it. +5. For user messages, paints the entire row background (including padding lines) so the user block + stands out even when it does not fill the whole width. +6. Applies selection styling and other overlays on top of the rendered lines. + +Scrolling (mouse wheel, PgUp/PgDn, Home/End) operates entirely in terms of these flattened lines +and the current scroll anchor. The terminal’s own scrollback is not part of this calculation; it +only ever sees fully rendered frames. + +### 3.3 Alternate screen, overlays, and redraw guarantees + +The TUI uses the terminal’s alternate screen for: + +- The main interactive chat session (so the viewport can cover the full terminal). +- Full‑screen overlays such as the transcript pager, diff view, model migration screen, and + onboarding. + +Conceptually: + +- Entering alt screen: + - Switches the terminal into alt screen and expands the viewport to cover the full terminal. + - Clears that alt‑screen buffer. + +- Leaving alt screen: + - Disables “alternate scroll” so mouse wheel events behave predictably. + - Returns to the normal screen. + +- On leaving overlays and on resuming from suspend, the TUI viewport is explicitly cleared and fully + redrawn: + - This prevents stale overlay content or shell output from lingering in the TUI area. + - The next frame reconstructs the UI entirely from the in‑memory transcript and other state, not + from whatever the terminal happened to remember. + +Alt screen is therefore treated as a temporary render target. The only authoritative copy of the UI +is the in‑memory state. + +--- + +## 4. Mouse, Selection, and Scrolling + +Mouse interaction is a first‑class part of the new design: + +- **Scrolling.** + - Mouse wheel scrolls the transcript in fixed line increments. + - Keyboard shortcuts (PgUp/PgDn/Home/End) use the same scroll model, so the footer can show + consistent hints regardless of input device. + +- **Selection.** + - A click‑and‑drag gesture defines a linear text selection in terms of the flattened transcript + lines (not raw buffer coordinates). + - Selection tracks the _content_ rather than a fixed screen row. When the transcript scrolls, the + selection moves along with the underlying lines instead of staying glued to a particular Y + position. + - The selection only covers the “transcript text” area; it intentionally skips the left gutter + that we use for bullets/prefixes. + +- **Copy.** + - When the user triggers copy, the TUI re‑renders just the transcript region off‑screen using the + same wrapping as the visible view. + - It then walks the selected lines and columns in that off‑screen buffer to reconstruct the exact + text region the user highlighted (including internal spaces and empty lines). + - That text is sent to the system clipboard and a status footer indicates success or failure. + +Because scrolling, selection, and copy all operate on the same flattened transcript representation, +they remain consistent even as the viewport resizes or the chat composer grows/shrinks. Owning our +own scrolling also means we must own mouse interactions end‑to‑end: if we left scrolling entirely +to the terminal, we could not reliably line up selections with transcript content or avoid +accidentally copying gutter/margin characters instead of just the conversation text. + +--- + +## 5. Printing History to Scrollback + +We still want the final session (and suspend points) to appear in the user’s normal scrollback, but +we no longer try to maintain scrollback in lock‑step with the TUI frame. Instead, we treat +scrollback as an **append‑only log** of logical transcript cells. + +In practice this means: + +- The TUI may print history both when you suspend (`Ctrl+Z`) and when you exit. +- Some users may prefer to only print on exit (for example to keep scrollback quieter during long + sessions). The current design anticipates gating suspend‑time printing behind a config toggle so + that this behavior can be made opt‑in or opt‑out without touching the core viewport logic, but + that switch has not been implemented yet. + +### 5.1 Cell‑based high‑water mark + +Internally, the TUI keeps a simple “high‑water mark” for history printing: + +- Think of this as “how many cells at the front of the transcript have already been sent to + scrollback.” +- It is just a counter over the logical transcript, not over wrapped lines. +- It moves forward only when we have actually printed more history. + +This means we never try to guess “how many terminal lines have already been printed”; we only +remember that “the first N logical entries are done.” + +### 5.2 Rendering new cells for scrollback + +When we need to print history (on suspend or exit), we: + +1. Take the suffix of the transcript that lies beyond the high‑water mark. +2. Render just that suffix into styled lines at the **current** terminal width. +3. Write those lines to stdout. +4. Advance the high‑water mark to include all cells we just printed. + +Older cells are never re‑rendered for scrollback; they remain in whatever wrapping they had when +they were first printed. This avoids the line‑count–based bugs we had before while still allowing +the on‑screen TUI to reflow freely. + +### 5.3 Suspend (`Ctrl+Z`) flow + +On suspend (typically `Ctrl+Z` on Unix): + +- Before yielding control back to the shell, the TUI: + - Leaves alt screen if it is active and restores normal terminal modes. + - Determines which transcript cells have not yet been printed and renders them for the current + width. + - Prints those new lines once into normal scrollback. + - Marks those cells as printed in the high‑water mark. + - Finally, sends the process to the background. + +On `fg`, the process resumes, re‑enters TUI modes, and redraws the viewport from the in‑memory +transcript. The history printed during suspend stays in scrollback and is not touched again. + +### 5.4 Exit flow + +When the TUI exits, we follow the same principle: + +- We compute the suffix of the transcript that has not yet been printed (taking into account any + prior suspends). +- We render just that suffix to styled lines at the current width. +- The outer `main` function leaves alt screen, restores the terminal, and prints those lines, plus a + blank line and token usage summary. + +If you never suspended, exit prints the entire transcript once. If you did suspend one or more +times, exit prints only the cells appended after the last suspend. In both cases, each logical +conversation entry reaches scrollback exactly once. + +--- + +## 6. Streaming, Width Changes, and Tradeoffs + +### 6.1 Streaming cells + +Streaming agent responses are represented as a sequence of history entries: + +- The first chunk produces a “first line” entry for the message. +- Subsequent chunks produce continuation entries that extend that message. + +From the history/scrollback perspective: + +- Each streaming chunk is just another entry in the logical transcript. +- The high‑water mark is a simple count of how many entries at the _front_ of the transcript have + already been printed. +- As new streaming chunks arrive, they are appended as new entries and will be included the next + time we print history on suspend or exit. + +We do **not** attempt to reprint or retroactively merge older chunks. In scrollback you will see the +streaming response as a series of discrete blocks, matching the internal history structure. + +Today, streaming rendering still “bakes in” some width at the time chunks are committed: line breaks +for the streaming path are computed using the width that was active at the time, and stored in the +intermediate representation. This is a known limitation and is called out in more detail in +`codex-rs/tui2/docs/streaming_wrapping_design.md`; a follow‑up change will make streaming behavior +match the rest of the transcript more closely (wrap only at display time, not at commit time). + +### 6.2 Width changes over time + +Because we now use a **cell‑level** high‑water mark instead of a visual line‑count, width changes +are handled gracefully: + +- On every suspend/exit, we render the not‑yet‑printed suffix of the transcript at the **current** + width and append those lines. +- Previously printed entries remain in scrollback with whatever wrapping they had at the time they + were printed. +- We no longer rely on “N lines printed before, therefore skip N lines of the newly wrapped + transcript,” which was the source of dropped and duplicated content when widths changed. + +This does mean scrollback can contain older cells wrapped for narrower or wider widths than the +final terminal size, but: + +- Each logical cell’s content appears exactly once. +- New cells are append‑only and never overwrite or implicitly “shrink” earlier content. +- The on‑screen TUI always reflows to the current width independently of scrollback. + +If we later choose to also re‑emit the “currently streaming” cell when printing on suspend (to make +sure the latest chunk of a long answer is always visible in scrollback), that would intentionally +duplicate a small number of lines at the boundary of that cell. The design assumes any such behavior +would be controlled by configuration (for example, by disabling suspend‑time printing entirely for +users who prefer only exit‑time output). + +### 6.3 Why not reflow scrollback? + +In theory we could try to reflow already‑printed content when widths change by: + +- Recomputing the entire transcript at the new width, and +- Printing diffs that “rewrite” old regions in scrollback. + +In practice, this runs into the same issues that motivated the redesign: + +- Terminals treat full clears and scroll regions differently. +- There is no portable way to “rewrite” arbitrary portions of scrollback above the visible buffer. +- Interleaving user output (e.g. shell prompts after suspend) makes it impossible to reliably + reconstruct the original scrollback structure. + +We therefore deliberately accept that scrollback is **append‑only** and not subject to reflow; +correctness is measured in terms of logical transcript content, not pixel‑perfect layout. + +--- + +## 7. Backtrack and Overlays (Context) + +While this document is focused on viewport and history, it’s worth mentioning a few related +behaviors that rely on the same model. + +### 7.1 Transcript overlay and backtrack + +The transcript overlay (pager) is a full‑screen view of the same logical transcript: + +- When opened, it takes a snapshot of the current transcript and renders it in an alt‑screen + overlay. +- Backtrack mode (`Esc` sequences) walks backwards through user messages in that snapshot and + highlights the candidate “edit from here” point. +- Confirming a backtrack request forks the conversation on the server and trims the in‑memory + transcript so that only history up to the chosen user message remains, then re‑renders that prefix + in the main view. + +The overlay is purely a different _view_ of the same transcript; it never infers anything from +scrollback. + +--- + +## 8. Summary of Tradeoffs + +**What we gain:** + +- The TUI has a clear, single source of truth for history (the in‑memory transcript). +- Viewport rendering is deterministic and independent of scrollback. +- Suspend and exit flows: + - Print each logical history cell exactly once. + - Are robust to terminal width changes. + - Interact cleanly with alt screen and raw‑mode toggling. +- Streaming, overlays, selection, and backtrack all share the same logical history model. +- Because cells are always re‑rendered live from the transcript, per‑cell interactions can become + richer over time. Instead of treating the transcript as “dead text”, we can make individual + entries interactive after they are rendered: expanding or contracting tool calls, diffs, or + reasoning summaries in place, jum…truncated… \*\*\* + +--- + +## 9. TUI2 Implementation Notes + +This section maps the design above onto the `codex-rs/tui2` crate so future viewport work has +concrete code pointers. + +### 9.1 Transcript state and layout + +The main app struct (`codex-rs/tui2/src/app.rs`) tracks the transcript and viewport state with: + +- `transcript_cells: Vec>` – the logical history. +- `transcript_scroll: TranscriptScroll` – whether the viewport is pinned to the bottom or + anchored at a specific cell/line pair. +- `transcript_selection: TranscriptSelection` – a selection expressed in screen coordinates over + the flattened transcript region. +- `transcript_view_top` / `transcript_total_lines` – the current viewport’s top line index and + total number of wrapped lines for the inline transcript area. + +### 9.2 Rendering, wrapping, and selection + +`App::render_transcript_cells` defines the transcript region, builds flattened lines via +`App::build_transcript_lines`, wraps them with `word_wrap_lines_borrowed` from +`codex-rs/tui2/src/wrapping.rs`, and applies selection via `apply_transcript_selection` before +writing to the frame buffer. + +Streaming wrapping details live in `codex-rs/tui2/docs/streaming_wrapping_design.md`. + +### 9.3 Input, selection, and footer state + +Mouse handling lives in `App::handle_mouse_event`, keyboard scrolling in +`App::handle_key_event`, selection rendering in `App::apply_transcript_selection`, and copy in +`App::copy_transcript_selection` plus `codex-rs/tui2/src/clipboard_copy.rs`. Scroll/selection UI +state is forwarded through `ChatWidget::set_transcript_ui_state`, +`BottomPane::set_transcript_ui_state`, and `ChatComposer::footer_props`, with footer text +assembled in `codex-rs/tui2/src/bottom_pane/footer.rs`. + +### 9.4 Exit transcript output + +`App::run` returns `session_lines` on `AppExitInfo` after flattening with +`App::build_transcript_lines` and converting to ANSI via `App::render_lines_to_ansi`. The CLI +prints those lines before the token usage and resume hints. + +## 10. Future Work and Open Questions + +This section collects design questions that follow naturally from the current model and are worth +explicit discussion before we commit to further UI changes. + +- **“Scroll mode” vs “live follow” UI.** + - We already distinguish “scrolled away from bottom” vs “following the latest output” in the + footer and scroll state. Do we need a more explicit “scroll mode vs live mode” affordance (e.g., + a dedicated indicator or toggle), or is the current behavior sufficient and adding more chrome + would be noise? + +- **Ephemeral scroll indicator.** + - For long sessions, a more visible sense of “where am I?” could help. One option is a minimalist + scrollbar that appears while the user is actively scrolling and fades out when idle. A full + “mini‑map” is probably too heavy for a TUI given the limited vertical space, but we could + imagine adding simple markers along the scrollbar to show where prior prompts occurred, or + where text search matches are, without trying to render a full preview of the buffer. + +- **Selection affordances.** + - Today, the primary hint that selection is active is the reversed text and the “Ctrl+Y copy + selection” footer text. Do we want an explicit “Selecting… (Esc to cancel)” status while a drag + is in progress, or would that be redundant/clutter for most users? + +- **Suspend banners in scrollback.** + - When printing history on suspend, should we also emit a small banner such as + `--- codex suspended; history up to here ---` to make those boundaries obvious in scrollback? + This would slightly increase noise but could make multi‑suspend sessions easier to read. + +- **Configuring suspend printing behavior.** + - The design already assumes that suspend‑time printing can be gated by config. Questions to + resolve: + - Should printing on suspend be on or off by default? + - Should we support multiple modes (e.g., “off”, “print all new cells”, “print streaming cell + tail only”) or keep it binary? + +- **Streaming duplication at the edges.** + - If we later choose to always re‑emit the “currently streaming” message when printing on suspend, + we would intentionally allow a small amount of duplication at the boundary of that message (for + example, its last line appearing twice across suspends). Is that acceptable if it improves the + readability of long streaming answers in scrollback, and should the ability to disable + suspend‑time printing be our escape hatch for users who care about exact de‑duplication?\*\*\* + +--- + diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index bb4c63244..032a228ec 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -3,10 +3,13 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::chatwidget::ChatWidget; +use crate::clipboard_copy; +use crate::custom_terminal::Frame; use crate::diff_render::DiffSummary; use crate::exec_command::strip_bash_lc_and_escape; use crate::file_search::FileSearchManager; use crate::history_cell::HistoryCell; +use crate::history_cell::UserHistoryCell; use crate::model_migration::ModelMigrationOutcome; use crate::model_migration::migration_copy_for_models; use crate::model_migration::run_model_migration_prompt; @@ -19,6 +22,9 @@ use crate::skill_error_prompt::run_skill_error_prompt; use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_line; +use crate::wrapping::word_wrap_lines_borrowed; use codex_ansi_escape::ansi_escape_line; use codex_core::AuthManager; use codex_core::ConversationManager; @@ -46,9 +52,14 @@ use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +use crossterm::event::MouseButton; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use std::collections::BTreeMap; use std::path::Path; @@ -60,6 +71,7 @@ use std::thread; use std::time::Duration; use tokio::select; use tokio::sync::mpsc::unbounded_channel; +use unicode_width::UnicodeWidthStr; #[cfg(not(debug_assertions))] use crate::history_cell::UpdateAvailableHistoryCell; @@ -69,6 +81,12 @@ pub struct AppExitInfo { pub token_usage: TokenUsage, pub conversation_id: Option, pub update_action: Option, + /// ANSI-styled transcript lines to print after the TUI exits. + /// + /// These lines are rendered against the same width as the final TUI + /// viewport and include styling (colors, bold, etc.) so that scrollback + /// preserves the visual structure of the on-screen transcript. + pub session_lines: Vec, } impl From for codex_tui::AppExitInfo { @@ -268,6 +286,7 @@ async fn handle_model_migration_prompt_if_needed( token_usage: TokenUsage::default(), conversation_id: None, update_action: None, + session_lines: Vec::new(), }); } } @@ -290,6 +309,12 @@ pub(crate) struct App { pub(crate) transcript_cells: Vec>, + #[allow(dead_code)] + transcript_scroll: TranscriptScroll, + transcript_selection: TranscriptSelection, + transcript_view_top: usize, + transcript_total_lines: usize, + // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, pub(crate) deferred_history_lines: Vec>, @@ -314,6 +339,42 @@ pub(crate) struct App { skip_world_writable_scan_once: bool, } +/// Scroll state for the inline transcript viewport. +/// +/// This tracks whether the transcript is pinned to the latest line or anchored +/// at a specific cell/line pair so later viewport changes can implement +/// scrollback without losing the notion of "bottom". +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, Default)] +enum TranscriptScroll { + #[default] + ToBottom, + Scrolled { + cell_index: usize, + line_in_cell: usize, + }, +} +/// Content-relative selection within the inline transcript viewport. +/// +/// Selection endpoints are expressed in terms of flattened, wrapped transcript +/// line indices and columns, so the highlight tracks logical conversation +/// content even when the viewport scrolls or the terminal is resized. +#[derive(Debug, Clone, Copy, Default)] +struct TranscriptSelection { + anchor: Option, + head: Option, +} + +/// A single endpoint of a transcript selection. +/// +/// `line_index` is an index into the flattened wrapped transcript lines, and +/// `column` is a zero-based column offset within that visual line, counted from +/// the first content column to the right of the transcript gutter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TranscriptSelectionPoint { + line_index: usize, + column: u16, +} impl App { async fn shutdown_current_conversation(&mut self) { if let Some(conversation_id) = self.chat_widget.conversation_id() { @@ -433,6 +494,10 @@ impl App { file_search, enhanced_keys_supported, transcript_cells: Vec::new(), + transcript_scroll: TranscriptScroll::ToBottom, + transcript_selection: TranscriptSelection::default(), + transcript_view_top: 0, + transcript_total_lines: 0, overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -493,11 +558,25 @@ impl App { app.handle_tui_event(tui, event).await? } } {} + let width = tui.terminal.last_known_screen_size.width; + let session_lines = if width == 0 { + Vec::new() + } else { + let (lines, meta) = Self::build_transcript_lines(&app.transcript_cells, width); + let is_user_cell: Vec = app + .transcript_cells + .iter() + .map(|cell| cell.as_any().is::()) + .collect(); + Self::render_lines_to_ansi(&lines, &meta, &is_user_cell, width) + }; + tui.terminal.clear()?; Ok(AppExitInfo { token_usage: app.token_usage(), conversation_id: app.chat_widget.conversation_id(), update_action: app.pending_update_action, + session_lines, }) } @@ -513,6 +592,9 @@ impl App { TuiEvent::Key(key_event) => { self.handle_key_event(tui, key_event).await; } + TuiEvent::Mouse(mouse_event) => { + self.handle_mouse_event(tui, mouse_event); + } TuiEvent::Paste(pasted) => { // Many terminals convert newlines to \r when pasting (e.g., iTerm2), // but tui-textarea expects \n. Normalize CR to LF. @@ -529,21 +611,880 @@ impl App { { return Ok(true); } - tui.draw( - self.chat_widget.desired_height(tui.terminal.size()?.width), - |frame| { - self.chat_widget.render(frame.area(), frame.buffer); - if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { - frame.set_cursor_position((x, y)); - } - }, - )?; + let cells = self.transcript_cells.clone(); + tui.draw(tui.terminal.size()?.height, |frame| { + let chat_height = self.chat_widget.desired_height(frame.area().width); + let chat_top = self.render_transcript_cells(frame, &cells, chat_height); + let chat_area = Rect { + x: frame.area().x, + y: chat_top, + width: frame.area().width, + height: chat_height.min( + frame + .area() + .height + .saturating_sub(chat_top.saturating_sub(frame.area().y)), + ), + }; + self.chat_widget.render(chat_area, frame.buffer); + let chat_bottom = chat_area.y.saturating_add(chat_area.height); + if chat_bottom < frame.area().bottom() { + Clear.render_ref( + Rect { + x: frame.area().x, + y: chat_bottom, + width: frame.area().width, + height: frame.area().bottom().saturating_sub(chat_bottom), + }, + frame.buffer, + ); + } + if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) { + frame.set_cursor_position((x, y)); + } + })?; + let transcript_scrolled = + !matches!(self.transcript_scroll, TranscriptScroll::ToBottom); + let selection_active = matches!( + (self.transcript_selection.anchor, self.transcript_selection.head), + (Some(a), Some(b)) if a != b + ); + let scroll_position = if self.transcript_total_lines == 0 { + None + } else { + Some(( + self.transcript_view_top.saturating_add(1), + self.transcript_total_lines, + )) + }; + self.chat_widget.set_transcript_ui_state( + transcript_scrolled, + selection_active, + scroll_position, + ); } } } Ok(true) } + pub(crate) fn render_transcript_cells( + &mut self, + frame: &mut Frame, + cells: &[Arc], + chat_height: u16, + ) -> u16 { + let area = frame.area(); + if area.width == 0 || area.height == 0 { + self.transcript_scroll = TranscriptScroll::ToBottom; + self.transcript_view_top = 0; + self.transcript_total_lines = 0; + return area.bottom().saturating_sub(chat_height); + } + + let chat_height = chat_height.min(area.height); + let max_transcript_height = area.height.saturating_sub(chat_height); + if max_transcript_height == 0 { + self.transcript_scroll = TranscriptScroll::ToBottom; + self.transcript_view_top = 0; + self.transcript_total_lines = 0; + return area.y; + } + + let transcript_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: max_transcript_height, + }; + + let (lines, meta) = Self::build_transcript_lines(cells, transcript_area.width); + if lines.is_empty() { + Clear.render_ref(transcript_area, frame.buffer); + self.transcript_scroll = TranscriptScroll::ToBottom; + self.transcript_view_top = 0; + self.transcript_total_lines = 0; + return area.y; + } + + let wrapped = word_wrap_lines_borrowed(&lines, transcript_area.width.max(1) as usize); + if wrapped.is_empty() { + self.transcript_scroll = TranscriptScroll::ToBottom; + self.transcript_view_top = 0; + self.transcript_total_lines = 0; + return area.y; + } + + let is_user_cell: Vec = cells + .iter() + .map(|c| c.as_any().is::()) + .collect(); + let base_opts: RtOptions<'_> = RtOptions::new(transcript_area.width.max(1) as usize); + let mut wrapped_is_user_row: Vec = Vec::with_capacity(wrapped.len()); + let mut first = true; + for (idx, line) in lines.iter().enumerate() { + let opts = if first { + base_opts.clone() + } else { + base_opts + .clone() + .initial_indent(base_opts.subsequent_indent.clone()) + }; + let seg_count = word_wrap_line(line, opts).len(); + let is_user_row = meta + .get(idx) + .and_then(Option::as_ref) + .map(|(cell_index, _)| is_user_cell.get(*cell_index).copied().unwrap_or(false)) + .unwrap_or(false); + wrapped_is_user_row.extend(std::iter::repeat_n(is_user_row, seg_count)); + first = false; + } + + let total_lines = wrapped.len(); + self.transcript_total_lines = total_lines; + let max_visible = std::cmp::min(max_transcript_height as usize, total_lines); + let max_start = total_lines.saturating_sub(max_visible); + + let top_offset = match self.transcript_scroll { + TranscriptScroll::ToBottom => max_start, + TranscriptScroll::Scrolled { + cell_index, + line_in_cell, + } => { + let mut anchor = None; + for (idx, entry) in meta.iter().enumerate() { + if let Some((ci, li)) = entry + && *ci == cell_index + && *li == line_in_cell + { + anchor = Some(idx); + break; + } + } + if let Some(idx) = anchor { + idx.min(max_start) + } else { + self.transcript_scroll = TranscriptScroll::ToBottom; + max_start + } + } + }; + self.transcript_view_top = top_offset; + + let transcript_visible_height = max_visible as u16; + let chat_top = if total_lines <= max_transcript_height as usize { + let gap = if transcript_visible_height == 0 { 0 } else { 1 }; + area.y + .saturating_add(transcript_visible_height) + .saturating_add(gap) + } else { + area.bottom().saturating_sub(chat_height) + }; + + let clear_height = chat_top.saturating_sub(area.y); + if clear_height > 0 { + Clear.render_ref( + Rect { + x: area.x, + y: area.y, + width: area.width, + height: clear_height, + }, + frame.buffer, + ); + } + + let transcript_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: transcript_visible_height, + }; + + for (row_index, line_index) in (top_offset..total_lines).enumerate() { + if row_index >= max_visible { + break; + } + + let y = transcript_area.y + row_index as u16; + let row_area = Rect { + x: transcript_area.x, + y, + width: transcript_area.width, + height: 1, + }; + + if wrapped_is_user_row + .get(line_index) + .copied() + .unwrap_or(false) + { + let base_style = crate::style::user_message_style(); + for x in row_area.x..row_area.right() { + let cell = &mut frame.buffer[(x, y)]; + let style = cell.style().patch(base_style); + cell.set_style(style); + } + } + + wrapped[line_index].render_ref(row_area, frame.buffer); + } + + self.apply_transcript_selection(transcript_area, frame.buffer); + chat_top + } + + /// Handle mouse interaction in the main transcript view. + /// + /// - Mouse wheel movement scrolls the conversation history by small, fixed increments, + /// independent of the terminal's own scrollback. + /// - Mouse clicks and drags adjust a text selection defined in terms of + /// flattened transcript lines and columns, so the selection is anchored + /// to the underlying content rather than absolute screen rows. + /// - When the user drags to extend a selection while the view is following the bottom + /// and a task is actively running (e.g., streaming a response), the scroll mode is + /// first converted into an anchored position so that ongoing updates no longer move + /// the viewport under the selection. A simple click without a drag does not change + /// scroll behavior. + fn handle_mouse_event( + &mut self, + tui: &mut tui::Tui, + mouse_event: crossterm::event::MouseEvent, + ) { + use crossterm::event::MouseEventKind; + + if self.overlay.is_some() { + return; + } + + let size = tui.terminal.last_known_screen_size; + let width = size.width; + let height = size.height; + if width == 0 || height == 0 { + return; + } + + let chat_height = self.chat_widget.desired_height(width); + if chat_height >= height { + return; + } + + // Only handle events over the transcript area above the composer. + let transcript_height = height.saturating_sub(chat_height); + if transcript_height == 0 { + return; + } + + let transcript_area = Rect { + x: 0, + y: 0, + width, + height: transcript_height, + }; + let base_x = transcript_area.x.saturating_add(2); + let max_x = transcript_area.right().saturating_sub(1); + + let mut clamped_x = mouse_event.column; + let mut clamped_y = mouse_event.row; + + if clamped_y < transcript_area.y || clamped_y >= transcript_area.bottom() { + clamped_y = transcript_area.y; + } + if clamped_x < base_x { + clamped_x = base_x; + } + if clamped_x > max_x { + clamped_x = max_x; + } + + let streaming = self.chat_widget.is_task_running(); + + match mouse_event.kind { + MouseEventKind::ScrollUp => { + self.scroll_transcript( + tui, + -3, + transcript_area.height as usize, + transcript_area.width, + ); + } + MouseEventKind::ScrollDown => { + self.scroll_transcript( + tui, + 3, + transcript_area.height as usize, + transcript_area.width, + ); + } + MouseEventKind::Down(MouseButton::Left) => { + if let Some(point) = self.transcript_point_from_coordinates( + transcript_area, + base_x, + clamped_x, + clamped_y, + ) { + self.transcript_selection.anchor = Some(point); + self.transcript_selection.head = Some(point); + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(anchor) = self.transcript_selection.anchor + && let Some(point) = self.transcript_point_from_coordinates( + transcript_area, + base_x, + clamped_x, + clamped_y, + ) + { + if streaming + && matches!(self.transcript_scroll, TranscriptScroll::ToBottom) + && point != anchor + { + self.lock_transcript_scroll_to_current_view( + transcript_area.height as usize, + transcript_area.width, + ); + } + self.transcript_selection.head = Some(point); + } + } + MouseEventKind::Up(MouseButton::Left) => { + if self.transcript_selection.anchor == self.transcript_selection.head { + self.transcript_selection = TranscriptSelection::default(); + } + } + _ => {} + } + } + + /// Scroll the transcript by a fixed number of visual lines. + /// + /// This is the shared implementation behind mouse wheel movement and PgUp/PgDn keys in + /// the main view. Scroll state is expressed in terms of transcript cells and their + /// internal line indices, so scrolling refers to logical conversation content and + /// remains stable even as wrapping or streaming causes visual reflows. + fn scroll_transcript( + &mut self, + tui: &mut tui::Tui, + delta_lines: i32, + visible_lines: usize, + width: u16, + ) { + if visible_lines == 0 { + return; + } + + let (lines, meta) = Self::build_transcript_lines(&self.transcript_cells, width); + let total_lines = lines.len(); + if total_lines <= visible_lines { + self.transcript_scroll = TranscriptScroll::ToBottom; + return; + } + + let max_start = total_lines.saturating_sub(visible_lines); + + let current_top = match self.transcript_scroll { + TranscriptScroll::ToBottom => max_start, + TranscriptScroll::Scrolled { + cell_index, + line_in_cell, + } => { + let mut anchor = None; + for (idx, entry) in meta.iter().enumerate() { + if let Some((ci, li)) = entry + && *ci == cell_index + && *li == line_in_cell + { + anchor = Some(idx); + break; + } + } + anchor.unwrap_or(max_start).min(max_start) + } + }; + + if delta_lines == 0 { + return; + } + + let new_top = if delta_lines < 0 { + current_top.saturating_sub(delta_lines.unsigned_abs() as usize) + } else { + current_top + .saturating_add(delta_lines as usize) + .min(max_start) + }; + + if new_top == max_start { + self.transcript_scroll = TranscriptScroll::ToBottom; + } else { + let anchor = meta.iter().skip(new_top).find_map(|entry| *entry); + if let Some((cell_index, line_in_cell)) = anchor { + self.transcript_scroll = TranscriptScroll::Scrolled { + cell_index, + line_in_cell, + }; + } else if let Some(prev_idx) = (0..=new_top).rfind(|&idx| meta[idx].is_some()) { + if let Some((cell_index, line_in_cell)) = meta[prev_idx] { + self.transcript_scroll = TranscriptScroll::Scrolled { + cell_index, + line_in_cell, + }; + } else { + self.transcript_scroll = TranscriptScroll::ToBottom; + } + } else { + self.transcript_scroll = TranscriptScroll::ToBottom; + } + } + + tui.frame_requester().schedule_frame(); + } + + /// Convert a `ToBottom` (auto-follow) scroll state into a fixed anchor at the current view. + /// + /// When the user begins a mouse selection while new output is streaming in, the view + /// should stop auto-following the latest line so the selection stays on the intended + /// content. This helper inspects the flattened transcript at the given width, derives + /// a concrete position corresponding to the current top row, and switches into a scroll + /// mode that keeps that position stable until the user scrolls again. + fn lock_transcript_scroll_to_current_view(&mut self, visible_lines: usize, width: u16) { + if self.transcript_cells.is_empty() || visible_lines == 0 || width == 0 { + return; + } + + let (lines, meta) = Self::build_transcript_lines(&self.transcript_cells, width); + if lines.is_empty() || meta.is_empty() { + return; + } + + let total_lines = lines.len(); + let max_visible = std::cmp::min(visible_lines, total_lines); + if max_visible == 0 { + return; + } + + let max_start = total_lines.saturating_sub(max_visible); + let top_offset = match self.transcript_scroll { + TranscriptScroll::ToBottom => max_start, + TranscriptScroll::Scrolled { .. } => { + // Already anchored; nothing to lock. + return; + } + }; + + let mut anchor = None; + if let Some((cell_index, line_in_cell)) = meta.iter().skip(top_offset).flatten().next() { + anchor = Some((*cell_index, *line_in_cell)); + } + if anchor.is_none() + && let Some((cell_index, line_in_cell)) = + meta[..top_offset].iter().rev().flatten().next() + { + anchor = Some((*cell_index, *line_in_cell)); + } + + if let Some((cell_index, line_in_cell)) = anchor { + self.transcript_scroll = TranscriptScroll::Scrolled { + cell_index, + line_in_cell, + }; + } + } + + /// Build the flattened transcript lines for rendering, scrolling, and exit transcripts. + /// + /// Returns both the visible `Line` buffer and a parallel metadata vector + /// that maps each line back to its originating `(cell_index, line_in_cell)` + /// pair, or `None` for spacer lines. This allows the scroll state to anchor + /// to a specific history cell even as new content arrives or the viewport + /// size changes, and gives exit transcript renderers enough structure to + /// style user rows differently from agent rows. + fn build_transcript_lines( + cells: &[Arc], + width: u16, + ) -> (Vec>, Vec>) { + let mut lines: Vec> = Vec::new(); + let mut meta: Vec> = Vec::new(); + let mut has_emitted_lines = false; + + for (cell_index, cell) in cells.iter().enumerate() { + let cell_lines = cell.display_lines(width); + if cell_lines.is_empty() { + continue; + } + + if !cell.is_stream_continuation() { + if has_emitted_lines { + lines.push(Line::from("")); + meta.push(None); + } else { + has_emitted_lines = true; + } + } + + for (line_in_cell, line) in cell_lines.into_iter().enumerate() { + meta.push(Some((cell_index, line_in_cell))); + lines.push(line); + } + } + + (lines, meta) + } + + /// Render flattened transcript lines into ANSI strings suitable for + /// printing after the TUI exits. + /// + /// This helper mirrors the original TUI viewport behavior: + /// - Merges line-level style into each span so the ANSI output matches + /// the on-screen styling (e.g., blockquotes, lists). + /// - For user-authored rows, pads the background style out to the full + /// terminal width so prompts appear as solid blocks in scrollback. + /// - Streams spans through the shared vt100 writer so downstream tests + /// and tools see consistent escape sequences. + fn render_lines_to_ansi( + lines: &[Line<'static>], + meta: &[Option<(usize, usize)>], + is_user_cell: &[bool], + width: u16, + ) -> Vec { + lines + .iter() + .enumerate() + .map(|(idx, line)| { + let is_user_row = meta + .get(idx) + .and_then(|entry| entry.as_ref()) + .map(|(cell_index, _)| is_user_cell.get(*cell_index).copied().unwrap_or(false)) + .unwrap_or(false); + + let mut merged_spans: Vec> = line + .spans + .iter() + .map(|span| ratatui::text::Span { + style: span.style.patch(line.style), + content: span.content.clone(), + }) + .collect(); + + if is_user_row && width > 0 { + let text: String = merged_spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + let text_width = UnicodeWidthStr::width(text.as_str()); + let total_width = usize::from(width); + if text_width < total_width { + let pad_len = total_width.saturating_sub(text_width); + if pad_len > 0 { + let pad_style = crate::style::user_message_style(); + merged_spans.push(ratatui::text::Span { + style: pad_style, + content: " ".repeat(pad_len).into(), + }); + } + } + } + + let mut buf: Vec = Vec::new(); + let _ = crate::insert_history::write_spans(&mut buf, merged_spans.iter()); + String::from_utf8(buf).unwrap_or_default() + }) + .collect() + } + + /// Apply the current transcript selection to the given buffer. + /// + /// The selection is defined in terms of flattened wrapped transcript line + /// indices and columns. This method maps those content-relative endpoints + /// into the currently visible viewport based on `transcript_view_top` and + /// `transcript_total_lines`, so the highlight moves with the content as the + /// user scrolls. + fn apply_transcript_selection(&self, area: Rect, buf: &mut Buffer) { + let (anchor, head) = match ( + self.transcript_selection.anchor, + self.transcript_selection.head, + ) { + (Some(a), Some(h)) => (a, h), + _ => return, + }; + + if self.transcript_total_lines == 0 { + return; + } + + let base_x = area.x.saturating_add(2); + let max_x = area.right().saturating_sub(1); + + let mut start = anchor; + let mut end = head; + if (end.line_index < start.line_index) + || (end.line_index == start.line_index && end.column < start.column) + { + std::mem::swap(&mut start, &mut end); + } + + let visible_start = self.transcript_view_top; + let visible_end = self + .transcript_view_top + .saturating_add(area.height as usize) + .min(self.transcript_total_lines); + + for (row_index, line_index) in (visible_start..visible_end).enumerate() { + if line_index < start.line_index || line_index > end.line_index { + continue; + } + + let y = area.y + row_index as u16; + + let mut first_text_x = None; + let mut last_text_x = None; + for x in base_x..=max_x { + let cell = &buf[(x, y)]; + if cell.symbol() != " " { + if first_text_x.is_none() { + first_text_x = Some(x); + } + last_text_x = Some(x); + } + } + + let (text_start, text_end) = match (first_text_x, last_text_x) { + // Treat indentation spaces as part of the selectable region by + // starting from the first content column to the right of the + // transcript gutter, but still clamp to the last non-space + // glyph so trailing padding is not included. + (Some(_), Some(e)) => (base_x, e), + _ => continue, + }; + + let line_start_col = if line_index == start.line_index { + start.column + } else { + 0 + }; + let line_end_col = if line_index == end.line_index { + end.column + } else { + max_x.saturating_sub(base_x) + }; + + let row_sel_start = base_x.saturating_add(line_start_col); + let row_sel_end = base_x.saturating_add(line_end_col).min(max_x); + + if row_sel_start > row_sel_end { + continue; + } + + let from_x = row_sel_start.max(text_start); + let to_x = row_sel_end.min(text_end); + + if from_x > to_x { + continue; + } + + for x in from_x..=to_x { + let cell = &mut buf[(x, y)]; + let style = cell.style(); + cell.set_style(style.add_modifier(ratatui::style::Modifier::REVERSED)); + } + } + } + + /// Copy the currently selected transcript region to the system clipboard. + /// + /// The selection is defined in terms of flattened wrapped transcript line + /// indices and columns, and this method reconstructs the same wrapped + /// transcript used for on-screen rendering so the copied text closely + /// matches the highlighted region. + fn copy_transcript_selection(&mut self, tui: &tui::Tui) { + let (anchor, head) = match ( + self.transcript_selection.anchor, + self.transcript_selection.head, + ) { + (Some(a), Some(h)) if a != h => (a, h), + _ => return, + }; + + let size = tui.terminal.last_known_screen_size; + let width = size.width; + let height = size.height; + if width == 0 || height == 0 { + return; + } + + let chat_height = self.chat_widget.desired_height(width); + if chat_height >= height { + return; + } + + let transcript_height = height.saturating_sub(chat_height); + if transcript_height == 0 { + return; + } + + let transcript_area = Rect { + x: 0, + y: 0, + width, + height: transcript_height, + }; + + let cells = self.transcript_cells.clone(); + let (lines, _) = Self::build_transcript_lines(&cells, transcript_area.width); + if lines.is_empty() { + return; + } + + let wrapped = crate::wrapping::word_wrap_lines_borrowed( + &lines, + transcript_area.width.max(1) as usize, + ); + let total_lines = wrapped.len(); + if total_lines == 0 { + return; + } + + let max_visible = transcript_area.height as usize; + let visible_start = self + .transcript_view_top + .min(total_lines.saturating_sub(max_visible)); + let visible_end = std::cmp::min(visible_start + max_visible, total_lines); + + let mut buf = Buffer::empty(transcript_area); + Clear.render_ref(transcript_area, &mut buf); + + for (row_index, line_index) in (visible_start..visible_end).enumerate() { + let row_area = Rect { + x: transcript_area.x, + y: transcript_area.y + row_index as u16, + width: transcript_area.width, + height: 1, + }; + wrapped[line_index].render_ref(row_area, &mut buf); + } + + let base_x = transcript_area.x.saturating_add(2); + let max_x = transcript_area.right().saturating_sub(1); + + let mut start = anchor; + let mut end = head; + if (end.line_index < start.line_index) + || (end.line_index == start.line_index && end.column < start.column) + { + std::mem::swap(&mut start, &mut end); + } + + let mut lines_out: Vec = Vec::new(); + + for (row_index, line_index) in (visible_start..visible_end).enumerate() { + if line_index < start.line_index || line_index > end.line_index { + continue; + } + + let y = transcript_area.y + row_index as u16; + + let line_start_col = if line_index == start.line_index { + start.column + } else { + 0 + }; + let line_end_col = if line_index == end.line_index { + end.column + } else { + max_x.saturating_sub(base_x) + }; + + let row_sel_start = base_x.saturating_add(line_start_col); + let row_sel_end = base_x.saturating_add(line_end_col).min(max_x); + + if row_sel_start > row_sel_end { + continue; + } + + let mut first_text_x = None; + let mut last_text_x = None; + for x in base_x..=max_x { + let cell = &buf[(x, y)]; + if cell.symbol() != " " { + if first_text_x.is_none() { + first_text_x = Some(x); + } + last_text_x = Some(x); + } + } + + let (text_start, text_end) = match (first_text_x, last_text_x) { + // Treat indentation spaces as part of the copyable region by + // starting from the first content column to the right of the + // transcript gutter, but still clamp to the last non-space + // glyph so trailing padding is not included. + (Some(_), Some(e)) => (base_x, e), + _ => { + lines_out.push(String::new()); + continue; + } + }; + + let from_x = row_sel_start.max(text_start); + let to_x = row_sel_end.min(text_end); + if from_x > to_x { + continue; + } + + let mut line_text = String::new(); + for x in from_x..=to_x { + let cell = &buf[(x, y)]; + let symbol = cell.symbol(); + if !symbol.is_empty() { + line_text.push_str(symbol); + } + } + + lines_out.push(line_text); + } + + if lines_out.is_empty() { + return; + } + + let text = lines_out.join("\n"); + if let Err(err) = clipboard_copy::copy_text(text) { + tracing::error!(error = %err, "failed to copy selection to clipboard"); + } + } + + /// Map a mouse position in the transcript area to a content-relative + /// selection point, if there is transcript content to select. + fn transcript_point_from_coordinates( + &self, + transcript_area: Rect, + base_x: u16, + x: u16, + y: u16, + ) -> Option { + if self.transcript_total_lines == 0 { + return None; + } + + let mut row_index = y.saturating_sub(transcript_area.y); + if row_index >= transcript_area.height { + if transcript_area.height == 0 { + return None; + } + row_index = transcript_area.height.saturating_sub(1); + } + + let max_line = self.transcript_total_lines.saturating_sub(1); + let line_index = self + .transcript_view_top + .saturating_add(usize::from(row_index)) + .min(max_line); + let column = x.saturating_sub(base_x); + + Some(TranscriptSelectionPoint { line_index, column }) + } + async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { let model_family = self .server @@ -655,8 +1596,8 @@ impl App { } AppEvent::InsertHistoryCell(cell) => { let cell: Arc = cell.into(); - if let Some(Overlay::Transcript(t)) = &mut self.overlay { - t.insert_cell(cell.clone()); + if let Some(Overlay::Transcript(transcript)) = &mut self.overlay { + transcript.insert_cell(cell.clone()); tui.frame_requester().schedule_frame(); } self.transcript_cells.push(cell.clone()); @@ -674,8 +1615,6 @@ impl App { } if self.overlay.is_some() { self.deferred_history_lines.extend(display); - } else { - tui.insert_history_lines(display); } } } @@ -1139,6 +2078,83 @@ impl App { self.chat_widget.handle_key_event(key_event); } } + KeyEvent { + code: KeyCode::Char('y'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.copy_transcript_selection(tui); + } + KeyEvent { + code: KeyCode::PageUp, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + let size = tui.terminal.last_known_screen_size; + let width = size.width; + let height = size.height; + if width > 0 && height > 0 { + let chat_height = self.chat_widget.desired_height(width); + if chat_height < height { + let transcript_height = height.saturating_sub(chat_height); + if transcript_height > 0 { + let delta = -i32::from(transcript_height); + self.scroll_transcript( + tui, + delta, + usize::from(transcript_height), + width, + ); + } + } + } + } + KeyEvent { + code: KeyCode::PageDown, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + let size = tui.terminal.last_known_screen_size; + let width = size.width; + let height = size.height; + if width > 0 && height > 0 { + let chat_height = self.chat_widget.desired_height(width); + if chat_height < height { + let transcript_height = height.saturating_sub(chat_height); + if transcript_height > 0 { + let delta = i32::from(transcript_height); + self.scroll_transcript( + tui, + delta, + usize::from(transcript_height), + width, + ); + } + } + } + } + KeyEvent { + code: KeyCode::Home, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + if !self.transcript_cells.is_empty() { + self.transcript_scroll = TranscriptScroll::Scrolled { + cell_index: 0, + line_in_cell: 0, + }; + tui.frame_requester().schedule_frame(); + } + } + KeyEvent { + code: KeyCode::End, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.transcript_scroll = TranscriptScroll::ToBottom; + tui.frame_requester().schedule_frame(); + } // Enter confirms backtrack when primed + count > 0. Otherwise pass to widget. KeyEvent { code: KeyCode::Enter, @@ -1218,6 +2234,7 @@ mod tests { use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; use codex_protocol::ConversationId; + use pretty_assertions::assert_eq; use ratatui::prelude::Line; use std::path::PathBuf; use std::sync::Arc; @@ -1245,6 +2262,10 @@ mod tests { active_profile: None, file_search, transcript_cells: Vec::new(), + transcript_scroll: TranscriptScroll::ToBottom, + transcript_selection: TranscriptSelection::default(), + transcript_view_top: 0, + transcript_total_lines: 0, overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -1285,6 +2306,10 @@ mod tests { active_profile: None, file_search, transcript_cells: Vec::new(), + transcript_scroll: TranscriptScroll::ToBottom, + transcript_selection: TranscriptSelection::default(), + transcript_view_top: 0, + transcript_total_lines: 0, overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -1444,6 +2469,68 @@ mod tests { assert_eq!(prefill, "follow-up (edited)"); } + #[test] + fn transcript_selection_moves_with_scroll() { + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + let mut app = make_test_app(); + app.transcript_total_lines = 3; + + let area = Rect { + x: 0, + y: 0, + width: 10, + height: 2, + }; + + // Anchor selection to logical line 1, columns 2..4. + app.transcript_selection = TranscriptSelection { + anchor: Some(TranscriptSelectionPoint { + line_index: 1, + column: 2, + }), + head: Some(TranscriptSelectionPoint { + line_index: 1, + column: 4, + }), + }; + + // First render: top of view is line 0, so line 1 maps to the second row. + app.transcript_view_top = 0; + let mut buf = Buffer::empty(area); + for x in 2..area.width { + buf[(x, 0)].set_symbol("A"); + buf[(x, 1)].set_symbol("B"); + } + + app.apply_transcript_selection(area, &mut buf); + + // No selection should be applied to the first row when the view is anchored at the top. + for x in 0..area.width { + let cell = &buf[(x, 0)]; + assert!(cell.style().add_modifier.is_empty()); + } + + // After scrolling down by one line, the same logical line should now be + // rendered on the first row, and the highlight should move with it. + app.transcript_view_top = 1; + let mut buf_scrolled = Buffer::empty(area); + for x in 2..area.width { + buf_scrolled[(x, 0)].set_symbol("B"); + buf_scrolled[(x, 1)].set_symbol("C"); + } + + app.apply_transcript_selection(area, &mut buf_scrolled); + + // After scrolling, the selection should now be applied on the first row rather than the + // second. + for x in 0..area.width { + let cell = &buf_scrolled[(x, 1)]; + assert!(cell.style().add_modifier.is_empty()); + } + } + #[tokio::test] async fn new_session_requests_shutdown_for_previous_conversation() { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels(); @@ -1485,6 +2572,19 @@ mod tests { assert!(session_summary(TokenUsage::default(), None).is_none()); } + #[test] + fn render_lines_to_ansi_pads_user_rows_to_full_width() { + let line: Line<'static> = Line::from("hi"); + let lines = vec![line]; + let meta = vec![Some((0usize, 0usize))]; + let is_user_cell = vec![true]; + let width: u16 = 10; + + let rendered = App::render_lines_to_ansi(&lines, &meta, &is_user_cell, width); + assert_eq!(rendered.len(), 1); + assert!(rendered[0].contains("hi")); + } + #[test] fn session_summary_includes_resume_hint() { let usage = TokenUsage { diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 919866b00..7ca10c6d2 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -118,6 +118,9 @@ pub(crate) struct ChatComposer { footer_hint_override: Option>, context_window_percent: Option, context_window_used_tokens: Option, + transcript_scrolled: bool, + transcript_selection_active: bool, + transcript_scroll_position: Option<(usize, usize)>, skills: Option>, dismissed_skill_popup_token: Option, } @@ -166,6 +169,9 @@ impl ChatComposer { footer_hint_override: None, context_window_percent: None, context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, skills: None, dismissed_skill_popup_token: None, }; @@ -1531,6 +1537,9 @@ impl ChatComposer { is_task_running: self.is_task_running, context_window_percent: self.context_window_percent, context_window_used_tokens: self.context_window_used_tokens, + transcript_scrolled: self.transcript_scrolled, + transcript_selection_active: self.transcript_selection_active, + transcript_scroll_position: self.transcript_scroll_position, } } @@ -1551,6 +1560,23 @@ impl ChatComposer { .map(|items| if items.is_empty() { 0 } else { 1 }) } + /// Update the footer's view of transcript scroll state for the inline viewport. + /// + /// This state is derived from the main `App`'s transcript viewport and passed + /// through the bottom pane so the footer can indicate when the transcript is + /// scrolled away from the bottom, whether a selection is active, and the + /// current `(visible_top, total)` position. + pub(crate) fn set_transcript_ui_state( + &mut self, + scrolled: bool, + selection_active: bool, + scroll_position: Option<(usize, usize)>, + ) { + self.transcript_scrolled = scrolled; + self.transcript_selection_active = selection_active; + self.transcript_scroll_position = scroll_position; + } + fn sync_popups(&mut self) { let file_token = Self::current_at_token(&self.textarea); let skill_token = self.current_skill_token(); diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs index d47ffec98..7f2b4e628 100644 --- a/codex-rs/tui2/src/bottom_pane/footer.rs +++ b/codex-rs/tui2/src/bottom_pane/footer.rs @@ -22,6 +22,9 @@ pub(crate) struct FooterProps { pub(crate) is_task_running: bool, pub(crate) context_window_percent: Option, pub(crate) context_window_used_tokens: Option, + pub(crate) transcript_scrolled: bool, + pub(crate) transcript_selection_active: bool, + pub(crate) transcript_scroll_position: Option<(usize, usize)>, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -94,6 +97,27 @@ fn footer_lines(props: FooterProps) -> Vec> { key_hint::plain(KeyCode::Char('?')).into(), " for shortcuts".dim(), ]); + if props.transcript_scrolled { + line.push_span(" · ".dim()); + line.push_span(key_hint::plain(KeyCode::PageUp)); + line.push_span("/"); + line.push_span(key_hint::plain(KeyCode::PageDown)); + line.push_span(" scroll".dim()); + line.push_span(" · ".dim()); + line.push_span(key_hint::plain(KeyCode::Home)); + line.push_span("/"); + line.push_span(key_hint::plain(KeyCode::End)); + line.push_span(" jump".dim()); + if let Some((current, total)) = props.transcript_scroll_position { + line.push_span(" · ".dim()); + line.push_span(Span::from(format!("{current}/{total}")).dim()); + } + } + if props.transcript_selection_active { + line.push_span(" · ".dim()); + line.push_span(key_hint::ctrl(KeyCode::Char('y'))); + line.push_span(" copy selection".dim()); + } vec![line] } FooterMode::ShortcutOverlay => { @@ -440,6 +464,24 @@ mod tests { is_task_running: false, context_window_percent: None, context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, + }, + ); + + snapshot_footer( + "footer_shortcuts_transcript_scrolled_and_selection", + FooterProps { + mode: FooterMode::ShortcutSummary, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: None, + transcript_scrolled: true, + transcript_selection_active: true, + transcript_scroll_position: Some((3, 42)), }, ); @@ -452,6 +494,9 @@ mod tests { is_task_running: false, context_window_percent: None, context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, }, ); @@ -464,6 +509,9 @@ mod tests { is_task_running: false, context_window_percent: None, context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, }, ); @@ -476,6 +524,9 @@ mod tests { is_task_running: true, context_window_percent: None, context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, }, ); @@ -488,6 +539,9 @@ mod tests { is_task_running: false, context_window_percent: None, context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, }, ); @@ -500,6 +554,9 @@ mod tests { is_task_running: false, context_window_percent: None, context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, }, ); @@ -512,6 +569,9 @@ mod tests { is_task_running: true, context_window_percent: Some(72), context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, }, ); @@ -524,6 +584,9 @@ mod tests { is_task_running: false, context_window_percent: None, context_window_used_tokens: Some(123_456), + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, }, ); } diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index 851668728..fbab5e14a 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -381,6 +381,17 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn set_transcript_ui_state( + &mut self, + scrolled: bool, + selection_active: bool, + scroll_position: Option<(usize, usize)>, + ) { + self.composer + .set_transcript_ui_state(scrolled, selection_active, scroll_position); + self.request_redraw(); + } + /// Show a generic list selection view with the provided items. pub(crate) fn show_selection_view(&mut self, params: list_selection_view::SelectionViewParams) { let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone()); diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_transcript_scrolled_and_selection.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_transcript_scrolled_and_selection.snap new file mode 100644 index 000000000..ee592efa2 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_transcript_scrolled_and_selection.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 100% context left · ? for shortcuts · pgup/pgdn scroll · home/end jump · 3/42 " diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 7dec1ae54..5461bea72 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -3073,6 +3073,30 @@ impl ChatWidget { pub(crate) fn clear_esc_backtrack_hint(&mut self) { self.bottom_pane.clear_esc_backtrack_hint(); } + + /// Return true when the bottom pane currently has an active task. + /// + /// This is used by the viewport to decide when mouse selections should + /// disengage auto-follow behavior while responses are streaming. + pub(crate) fn is_task_running(&self) -> bool { + self.bottom_pane.is_task_running() + } + + /// Inform the bottom pane about the current transcript scroll state. + /// + /// This is used by the footer to surface when the inline transcript is + /// scrolled away from the bottom and to display the current + /// `(visible_top, total)` scroll position alongside other shortcuts. + pub(crate) fn set_transcript_ui_state( + &mut self, + scrolled: bool, + selection_active: bool, + scroll_position: Option<(usize, usize)>, + ) { + self.bottom_pane + .set_transcript_ui_state(scrolled, selection_active, scroll_position); + } + /// Forward an `Op` directly to codex. pub(crate) fn submit_op(&self, op: Op) { // Record outbound operation for session replay fidelity. diff --git a/codex-rs/tui2/src/clipboard_copy.rs b/codex-rs/tui2/src/clipboard_copy.rs new file mode 100644 index 000000000..76718704e --- /dev/null +++ b/codex-rs/tui2/src/clipboard_copy.rs @@ -0,0 +1,79 @@ +use tracing::error; + +#[derive(Debug)] +pub enum ClipboardError { + ClipboardUnavailable(String), + WriteFailed(String), +} + +impl std::fmt::Display for ClipboardError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClipboardError::ClipboardUnavailable(msg) => { + write!(f, "clipboard unavailable: {msg}") + } + ClipboardError::WriteFailed(msg) => write!(f, "failed to write to clipboard: {msg}"), + } + } +} + +impl std::error::Error for ClipboardError {} + +pub trait ClipboardManager { + fn set_text(&mut self, text: String) -> Result<(), ClipboardError>; +} + +#[cfg(not(target_os = "android"))] +pub struct ArboardClipboardManager { + inner: Option, +} + +#[cfg(not(target_os = "android"))] +impl ArboardClipboardManager { + pub fn new() -> Self { + match arboard::Clipboard::new() { + Ok(cb) => Self { inner: Some(cb) }, + Err(err) => { + error!(error = %err, "failed to initialize clipboard"); + Self { inner: None } + } + } + } +} + +#[cfg(not(target_os = "android"))] +impl ClipboardManager for ArboardClipboardManager { + fn set_text(&mut self, text: String) -> Result<(), ClipboardError> { + let Some(cb) = &mut self.inner else { + return Err(ClipboardError::ClipboardUnavailable( + "clipboard is not available in this environment".to_string(), + )); + }; + cb.set_text(text) + .map_err(|e| ClipboardError::WriteFailed(e.to_string())) + } +} + +#[cfg(target_os = "android")] +pub struct ArboardClipboardManager; + +#[cfg(target_os = "android")] +impl ArboardClipboardManager { + pub fn new() -> Self { + ArboardClipboardManager + } +} + +#[cfg(target_os = "android")] +impl ClipboardManager for ArboardClipboardManager { + fn set_text(&mut self, _text: String) -> Result<(), ClipboardError> { + Err(ClipboardError::ClipboardUnavailable( + "clipboard text copy is unsupported on Android".to_string(), + )) + } +} + +pub fn copy_text(text: String) -> Result<(), ClipboardError> { + let mut manager = ArboardClipboardManager::new(); + manager.set_text(text) +} diff --git a/codex-rs/tui2/src/insert_history.rs b/codex-rs/tui2/src/insert_history.rs index 36ef47da5..e0e273167 100644 --- a/codex-rs/tui2/src/insert_history.rs +++ b/codex-rs/tui2/src/insert_history.rs @@ -241,7 +241,7 @@ impl ModifierDiff { } } -fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()> +pub(crate) fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()> where I: IntoIterator>, { diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index a0c034c14..758777923 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -40,6 +40,7 @@ mod ascii_animation; mod bottom_pane; mod chatwidget; mod cli; +mod clipboard_copy; mod clipboard_paste; mod color; pub mod custom_terminal; @@ -369,6 +370,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, update_action: Some(action), + session_lines: Vec::new(), }); } } @@ -408,6 +410,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, update_action: None, + session_lines: Vec::new(), }); } // if the user acknowledged windows or made an explicit decision ato trust the directory, reload the config accordingly @@ -443,6 +446,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, update_action: None, + session_lines: Vec::new(), }); } } @@ -481,6 +485,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, update_action: None, + session_lines: Vec::new(), }); } other => other, @@ -491,6 +496,12 @@ async fn run_ratatui_app( let Cli { prompt, images, .. } = cli; + // Run the main chat + transcript UI on the terminal's alternate screen so + // the entire viewport can be used without polluting normal scrollback. This + // mirrors the behavior of the legacy TUI but keeps inline mode available + // for smaller prompts like onboarding and model migration. + let _ = tui.enter_alt_screen(); + let app_result = App::run( &mut tui, auth_manager, @@ -504,7 +515,17 @@ async fn run_ratatui_app( ) .await; + let _ = tui.leave_alt_screen(); restore(); + if let Ok(exit_info) = &app_result { + let mut stdout = std::io::stdout(); + for line in exit_info.session_lines.iter() { + let _ = writeln!(stdout, "{line}"); + } + if !exit_info.session_lines.is_empty() { + let _ = writeln!(stdout); + } + } // Mark the end of the recorded session. session_log::log_session_end(); // ignore error when collecting usage – report underlying error instead diff --git a/codex-rs/tui2/src/model_migration.rs b/codex-rs/tui2/src/model_migration.rs index fa59b8fcc..90cd4b2b3 100644 --- a/codex-rs/tui2/src/model_migration.rs +++ b/codex-rs/tui2/src/model_migration.rs @@ -114,6 +114,7 @@ pub(crate) async fn run_model_migration_prompt( if let Some(event) = events.next().await { match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), + TuiEvent::Mouse(_) => {} TuiEvent::Paste(_) => {} TuiEvent::Draw => { let _ = alt.tui.draw(u16::MAX, |frame| { diff --git a/codex-rs/tui2/src/onboarding/onboarding_screen.rs b/codex-rs/tui2/src/onboarding/onboarding_screen.rs index 14999b222..3ba2619a8 100644 --- a/codex-rs/tui2/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui2/src/onboarding/onboarding_screen.rs @@ -393,6 +393,7 @@ pub(crate) async fn run_onboarding_app( while !onboarding_screen.is_done() { if let Some(event) = tui_events.next().await { match event { + TuiEvent::Mouse(_) => {} TuiEvent::Key(key_event) => { onboarding_screen.handle_key_event(key_event); } diff --git a/codex-rs/tui2/src/pager_overlay.rs b/codex-rs/tui2/src/pager_overlay.rs index 46aaba864..06a7cad46 100644 --- a/codex-rs/tui2/src/pager_overlay.rs +++ b/codex-rs/tui2/src/pager_overlay.rs @@ -14,6 +14,8 @@ use crate::tui; use crate::tui::TuiEvent; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; use ratatui::buffer::Buffer; use ratatui::buffer::Cell; use ratatui::layout::Rect; @@ -283,6 +285,24 @@ impl PagerView { Ok(()) } + fn handle_mouse_scroll(&mut self, tui: &mut tui::Tui, event: MouseEvent) -> Result<()> { + let step: usize = 3; + match event.kind { + MouseEventKind::ScrollUp => { + self.scroll_offset = self.scroll_offset.saturating_sub(step); + } + MouseEventKind::ScrollDown => { + self.scroll_offset = self.scroll_offset.saturating_add(step); + } + _ => { + return Ok(()); + } + } + tui.frame_requester() + .schedule_frame_in(Duration::from_millis(16)); + Ok(()) + } + /// Returns the height of one page in content rows. /// /// Prefers the last rendered content height (excluding header/footer chrome); @@ -506,6 +526,7 @@ impl TranscriptOverlay { } other => self.view.handle_key_event(tui, other), }, + TuiEvent::Mouse(mouse_event) => self.view.handle_mouse_scroll(tui, mouse_event), TuiEvent::Draw => { tui.draw(u16::MAX, |frame| { self.render(frame.area(), frame.buffer); @@ -565,6 +586,7 @@ impl StaticOverlay { } other => self.view.handle_key_event(tui, other), }, + TuiEvent::Mouse(mouse_event) => self.view.handle_mouse_scroll(tui, mouse_event), TuiEvent::Draw => { tui.draw(u16::MAX, |frame| { self.render(frame.area(), frame.buffer); diff --git a/codex-rs/tui2/src/skill_error_prompt.rs b/codex-rs/tui2/src/skill_error_prompt.rs index 33d3b5dce..41aaefa4c 100644 --- a/codex-rs/tui2/src/skill_error_prompt.rs +++ b/codex-rs/tui2/src/skill_error_prompt.rs @@ -58,6 +58,7 @@ pub(crate) async fn run_skill_error_prompt( if let Some(event) = events.next().await { match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), + TuiEvent::Mouse(_) => {} TuiEvent::Paste(_) => {} TuiEvent::Draw => { let _ = alt.tui.draw(u16::MAX, |frame| { diff --git a/codex-rs/tui2/src/tui.rs b/codex-rs/tui2/src/tui.rs index f9566da90..807c807c8 100644 --- a/codex-rs/tui2/src/tui.rs +++ b/codex-rs/tui2/src/tui.rs @@ -1,4 +1,3 @@ -use std::fmt; use std::io::IsTerminal; use std::io::Result; use std::io::Stdout; @@ -10,12 +9,13 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; -use crossterm::Command; use crossterm::SynchronizedUpdate; use crossterm::event::DisableBracketedPaste; use crossterm::event::DisableFocusChange; +use crossterm::event::DisableMouseCapture; use crossterm::event::EnableBracketedPaste; use crossterm::event::EnableFocusChange; +use crossterm::event::EnableMouseCapture; use crossterm::event::Event; use crossterm::event::KeyEvent; use crossterm::event::KeyboardEnhancementFlags; @@ -24,7 +24,6 @@ use crossterm::event::PushKeyboardEnhancementFlags; use crossterm::terminal::EnterAlternateScreen; use crossterm::terminal::LeaveAlternateScreen; use crossterm::terminal::supports_keyboard_enhancement; -use ratatui::backend::Backend; use ratatui::backend::CrosstermBackend; use ratatui::crossterm::execute; use ratatui::crossterm::terminal::disable_raw_mode; @@ -74,56 +73,18 @@ pub fn set_modes() -> Result<()> { ); let _ = execute!(stdout(), EnableFocusChange); + // Enable application mouse mode so scroll events are delivered as + // Mouse events instead of arrow keys. + let _ = execute!(stdout(), EnableMouseCapture); Ok(()) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct EnableAlternateScroll; - -impl Command for EnableAlternateScroll { - fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { - write!(f, "\x1b[?1007h") - } - - #[cfg(windows)] - fn execute_winapi(&self) -> Result<()> { - Err(std::io::Error::other( - "tried to execute EnableAlternateScroll using WinAPI; use ANSI instead", - )) - } - - #[cfg(windows)] - fn is_ansi_code_supported(&self) -> bool { - true - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct DisableAlternateScroll; - -impl Command for DisableAlternateScroll { - fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { - write!(f, "\x1b[?1007l") - } - - #[cfg(windows)] - fn execute_winapi(&self) -> Result<()> { - Err(std::io::Error::other( - "tried to execute DisableAlternateScroll using WinAPI; use ANSI instead", - )) - } - - #[cfg(windows)] - fn is_ansi_code_supported(&self) -> bool { - true - } -} - /// Restore the terminal to its original state. /// Inverse of `set_modes`. pub fn restore() -> Result<()> { // Pop may fail on platforms that didn't support the push; ignore errors. let _ = execute!(stdout(), PopKeyboardEnhancementFlags); + let _ = execute!(stdout(), DisableMouseCapture); execute!(stdout(), DisableBracketedPaste)?; let _ = execute!(stdout(), DisableFocusChange); disable_raw_mode()?; @@ -161,6 +122,7 @@ pub enum TuiEvent { Key(KeyEvent), Paste(String), Draw, + Mouse(crossterm::event::MouseEvent), } pub struct Tui { @@ -297,6 +259,9 @@ impl Tui { Event::Paste(pasted) => { yield TuiEvent::Paste(pasted); } + Event::Mouse(mouse_event) => { + yield TuiEvent::Mouse(mouse_event); + } Event::FocusGained => { terminal_focused.store(true, Ordering::Relaxed); crate::terminal_palette::requery_default_colors(); @@ -305,7 +270,6 @@ impl Tui { Event::FocusLost => { terminal_focused.store(false, Ordering::Relaxed); } - _ => {} } } Some(Err(_)) | None => { @@ -341,8 +305,6 @@ impl Tui { /// inline viewport for restoration when leaving. pub fn enter_alt_screen(&mut self) -> Result<()> { let _ = execute!(self.terminal.backend_mut(), EnterAlternateScreen); - // Enable "alternate scroll" so terminals may translate wheel to arrows - let _ = execute!(self.terminal.backend_mut(), EnableAlternateScroll); if let Ok(size) = self.terminal.size() { self.alt_saved_viewport = Some(self.terminal.viewport_area); self.terminal.set_viewport_area(ratatui::layout::Rect::new( @@ -359,8 +321,6 @@ impl Tui { /// Leave alternate screen and restore the previously saved inline viewport, if any. pub fn leave_alt_screen(&mut self) -> Result<()> { - // Disable alternate scroll when leaving alt-screen - let _ = execute!(self.terminal.backend_mut(), DisableAlternateScroll); let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen); if let Some(saved) = self.alt_saved_viewport.take() { self.terminal.set_viewport_area(saved); @@ -404,30 +364,13 @@ impl Tui { let size = terminal.size()?; - let mut area = terminal.viewport_area; - area.height = height.min(size.height); - area.width = size.width; - // If the viewport has expanded, scroll everything else up to make room. - if area.bottom() > size.height { - terminal - .backend_mut() - .scroll_region_up(0..area.top(), area.bottom() - size.height)?; - area.y = size.height - area.height; - } + let area = Rect::new(0, 0, size.width, height.min(size.height)); if area != terminal.viewport_area { // TODO(nornagon): probably this could be collapsed with the clear + set_viewport_area above. terminal.clear()?; terminal.set_viewport_area(area); } - if !self.pending_history_lines.is_empty() { - crate::insert_history::insert_history_lines( - terminal, - self.pending_history_lines.clone(), - )?; - self.pending_history_lines.clear(); - } - // Update the y position for suspending so Ctrl-Z can place the cursor correctly. #[cfg(unix)] { diff --git a/codex-rs/tui2/src/tui/job_control.rs b/codex-rs/tui2/src/tui/job_control.rs index 368041948..0796e93f6 100644 --- a/codex-rs/tui2/src/tui/job_control.rs +++ b/codex-rs/tui2/src/tui/job_control.rs @@ -18,8 +18,6 @@ use ratatui::layout::Rect; use crate::key_hint; -use super::DisableAlternateScroll; -use super::EnableAlternateScroll; use super::Terminal; pub const SUSPEND_KEY: key_hint::KeyBinding = key_hint::ctrl(KeyCode::Char('z')); @@ -63,8 +61,7 @@ impl SuspendContext { /// - Trigger SIGTSTP so the process can be resumed and continue drawing with the saved state. pub(crate) fn suspend(&self, alt_screen_active: &Arc) -> Result<()> { if alt_screen_active.load(Ordering::Relaxed) { - // Leave alt-screen so the terminal returns to the normal buffer while suspended; also turn off alt-scroll. - let _ = execute!(stdout(), DisableAlternateScroll); + // Leave alt-screen so the terminal returns to the normal buffer while suspended. let _ = execute!(stdout(), LeaveAlternateScreen); self.set_resume_action(ResumeAction::RestoreAlt); } else { @@ -157,11 +154,10 @@ impl PreparedResumeAction { match self { PreparedResumeAction::RealignViewport(area) => { terminal.set_viewport_area(area); + terminal.clear()?; } PreparedResumeAction::RestoreAltScreen => { execute!(terminal.backend_mut(), EnterAlternateScreen)?; - // Enable "alternate scroll" so terminals may translate wheel to arrows - execute!(terminal.backend_mut(), EnableAlternateScroll)?; if let Ok(size) = terminal.size() { terminal.set_viewport_area(Rect::new(0, 0, size.width, size.height)); terminal.clear()?;