WIP: Rework TUI viewport, history printing, and selection/copy (#7601)
> large behavior change to how the TUI owns its viewport, history, and suspend behavior. > Core model is in place; a few items are still being polished before this is ready to merge. We've moved this over to a new tui2 crate from being directly on the tui crate. To enable use --enable tui2 (or the equivalent in your config.toml). See https://developers.openai.com/codex/local-config#feature-flags Note that this serves as a baseline for the changes that we're making to be applied rapidly. Tui2 may not track later changes in the main tui. It's experimental and may not be where we land on things. --- ## Summary This PR moves the Codex TUI off of “cooperating” with the terminal’s scrollback and onto a model where the in‑memory transcript is the single source of truth. The TUI now owns scrolling, selection, copy, and suspend/exit printing based on that transcript, and only writes to terminal scrollback in append‑only fashion on suspend/exit. It also fixes streaming wrapping so streamed responses reflow with the viewport, and introduces configuration to control whether we print history on suspend or only on exit. High‑level goals: - Ensure history is complete, ordered, and never silently dropped. - Print each logical history cell at most once into scrollback, even with resizes and suspends. - Make scrolling, selection, and copy match the visible transcript, not the terminal’s notion of scrollback. - Keep suspend/alt‑screen behavior predictable across terminals. --- ## Core Design Changes ### Transcript & viewport ownership - Treat the transcript as a list of **cells** (user prompts, agent messages, system/info rows, streaming segments). - On each frame: - Compute a **transcript region** as “full terminal frame minus the bottom input area”. - Flatten all cells into visual lines plus metadata (which cell + which line within that cell). - Use scroll state to choose which visual line is at the top of the region. - Clear that region and draw just the visible slice of lines. - The terminal’s scrollback is no longer part of the live layout algorithm; it is only ever written to when we decide to print history. ### User message styling - User prompts now render as clear blocks with: - A blank padding line above and below. - A full‑width background for every line in the block (including the prompt line itself). - The same block styling is used when we print history into scrollback, so the transcript looks consistent whether you are in the TUI or scrolling back after exit/suspend. --- ## Scrolling, Mouse, Selection, and Copy ### Scrolling - Scrolling is defined in terms of the flattened transcript lines: - Mouse wheel scrolls up/down by fixed line increments. - PgUp/PgDn/Home/End operate on the same scroll model. - The footer shows: - Whether you are “following live output” vs “scrolled up”. - Current scroll position (line / total). - When there is no history yet, the bottom pane is **pegged high** and gradually moves down as the transcript fills, matching the existing UX. ### Selection - Click‑and‑drag defines a **linear selection** over transcript line/column coordinates, not raw screen rows. - Selection is **content‑anchored**: - When you scroll, the selection moves with the underlying lines instead of sticking to a fixed Y position. - This holds both when scrolling manually and when new content streams in, as long as you are in “follow” mode. - The selection only covers the “transcript text” area: - Left gutter/prefix (bullets, markers) is intentionally excluded. - This keeps copy/paste cleaner and avoids including structural margin characters. ### Copy (`Ctrl+Y`) - Introduce a small clipboard abstraction (`ClipboardManager`‑style) and use a cross‑platform clipboard crate under the hood. - When `Ctrl+Y` is pressed and a non‑empty selection exists: - Re‑render the transcript region off‑screen using the same wrapping as the visible viewport. - Walk the selected line/column range over that buffer to reconstruct the exact text: - Includes spaces between words. - Preserves empty lines within the selection. - Send the resulting text to the system clipboard. - Show a short status message in the footer indicating success/failure. - Copy is **best‑effort**: - Clipboard failures (headless environment, sandbox, remote sessions) are handled gracefully via status messages; they do not crash the TUI. - Copy does *not* insert a new history entry; it only affects the status bar. --- ## Streaming and Wrapping ### Previous behavior Previously, streamed markdown: - Was wrapped at a fixed width **at commit time** inside the streaming collector. - Those wrapped `Line<'static>` values were then wrapped again at display time. - As a result, streamed paragraphs could not “un‑wrap” when the terminal width increased; they were permanently split according to the width at the start of the stream. ### New behavior This PR implements the first step from `codex-rs/tui/streaming_wrapping_design.md`: - Streaming collector is constructed **without** a fixed width for wrapping. - It still: - Buffers the full markdown source for the current stream. - Commits only at newline boundaries. - Emits logical lines as new content becomes available. - Agent message cells now wrap streamed content only at **display time**, based on the current viewport width, just like non‑streaming messages. - Consequences: - Streamed responses reflow correctly when the terminal is resized. - Animation steps are per logical line instead of per “pre‑wrapped” visual line; this makes some commits slightly larger but keeps the behavior simple and predictable. Streaming responses are still represented as a sequence of logical history entries (first line + continuations) and integrate with the same scrolling, selection, and printing model. --- ## Printing History on Suspend and Exit ### High‑water mark and append‑only scrollback - Introduce a **cell‑based high‑water mark** (`printed_history_cells`) on the transcript: - Represents “how many cells at the front of the transcript have already been printed”. - Completely independent of wrapped line counts or terminal geometry. - Whenever we print history (suspend or exit): - Take the suffix of `transcript_cells` beyond `printed_history_cells`. - Render just that suffix into styled lines at the **current** width. - Write those lines to stdout. - Advance `printed_history_cells` to cover all cells we just printed. - Older cells are never re‑rendered for scrollback. They stay in whatever wrapping they had when printed, which is acceptable as long as the logical content is present once. ### Suspend (`Ctrl+Z`) - On suspend: - Leave alt screen if active and restore normal terminal modes. - Render the not‑yet‑printed suffix of the transcript and append it to normal scrollback. - Advance the high‑water mark. - Suspend the process. - On resume (`fg`): - Re‑enter the TUI mode (alt screen + input modes). - Clear the viewport region and fully redraw from in‑memory transcript and state. This gives predictable behavior across terminals without trying to maintain scrollback live. ### Exit - On exit: - Render any remaining unprinted cells once and write them to stdout. - Add an extra blank line after the final Codex history cell before printing token usage, so the transcript and usage info are visually separated. - If you never suspended, exit prints the entire transcript exactly once. - If you suspended one or more times, exit prints only the cells appended after the last suspend. --- ## Configuration: Suspend Printing This PR also adds configuration to control **when** we print history: - New TUI config option to gate printing on suspend: - At minimum: - `print_on_suspend = true` – current behavior: print new history at each suspend *and* on exit. - `print_on_suspend = false` – only print on exit. - Default is tuned to preserve current behavior, but this can be revisited based on feedback. - The config is respected in the suspend path: - If disabled, suspend only restores terminal modes and stops rendering but does not print new history. - Exit still prints the full not‑yet‑printed suffix once. This keeps the core viewport logic agnostic to preference, while letting users who care about quiet scrollback opt out of suspend printing. --- ## Tradeoffs What we gain: - A single authoritative history model (the in‑memory transcript). - Deterministic viewport rendering independent of terminal quirks. - Suspend/exit flows that: - Print each logical history cell exactly once. - Work across resizes and different terminals. - Interact cleanly with alt screen and raw‑mode toggling. - Consistent, content‑anchored scrolling, selection, and copy. - Streaming messages that reflow correctly with the viewport width. What we accept: - Scrollback may contain older cells wrapped differently than newer ones. - Streaming responses appear in scrollback as a sequence of blocks corresponding to their streaming structure, not as a single retroactively reflowed paragraph. - We do not attempt to rewrite or reflow already‑printed scrollback. For deeper rationale and diagrams, see `docs/tui_viewport_and_history.md` and `codex-rs/tui/streaming_wrapping_design.md`. --- ## Still to Do Before This PR Is Ready These are scoped to this PR (not long‑term future work): - [ ] **Streaming wrapping polish** - Double‑check all streaming paths use display‑time wrapping only. - Ensure tests cover resizing after streaming has started. - [ ] **Suspend printing config** - Finalize config shape and default (keep existing behavior vs opt‑out). - Wire config through TUI startup and document it in the appropriate config docs. - [x] **Bottom pane positioning** - Ensure the bottom pane is pegged high when there’s no history and smoothly moves down as the transcript fills, matching the current behavior across startup and resume. - [x] **Transcript mouse scrolling** - Re‑enable wheel‑based transcript scrolling on top of the new scroll model. - Make sure mouse scroll does not get confused with “alternate scroll” modes from terminals. - [x] **Mouse selection vs streaming** - When selection is active, stop auto‑scrolling on streaming so the selection remains stable on the selected content. - Ensure that when streaming continues after selection is cleared, “follow latest output” mode resumes correctly. - [ ] **Auto‑scroll during drag** - While the user is dragging a selection, auto‑scroll when the cursor is at/near the top or bottom of the transcript viewport to allow selecting beyond the current visible window. - [ ] **Feature flag / rollout** - Investigate gating the new viewport/history behavior behind a feature flag for initial rollout, so we can fall back to the old behavior if needed during early testing. - [ ] **Before/after videos** - Capture short clips showing: - Scrolling (mouse + keys). - Selection and copy. - Streaming behavior under resize. - Suspend/resume and exit printing. - Use these to validate UX and share context in the PR discussion.
This commit is contained in:
parent
412dd37956
commit
b093565bfb
18 changed files with 1921 additions and 89 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
85
codex-rs/tui2/docs/streaming_wrapping_design.md
Normal file
85
codex-rs/tui2/docs/streaming_wrapping_design.md
Normal file
|
|
@ -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.
|
||||
|
||||
454
codex-rs/tui2/docs/tui_viewport_and_history.md
Normal file
454
codex-rs/tui2/docs/tui_viewport_and_history.md
Normal file
|
|
@ -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<Arc<dyn HistoryCell>>` – 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?\*\*\*
|
||||
|
||||
---
|
||||
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -118,6 +118,9 @@ pub(crate) struct ChatComposer {
|
|||
footer_hint_override: Option<Vec<(String, String)>>,
|
||||
context_window_percent: Option<i64>,
|
||||
context_window_used_tokens: Option<i64>,
|
||||
transcript_scrolled: bool,
|
||||
transcript_selection_active: bool,
|
||||
transcript_scroll_position: Option<(usize, usize)>,
|
||||
skills: Option<Vec<SkillMetadata>>,
|
||||
dismissed_skill_popup_token: Option<String>,
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ pub(crate) struct FooterProps {
|
|||
pub(crate) is_task_running: bool,
|
||||
pub(crate) context_window_percent: Option<i64>,
|
||||
pub(crate) context_window_used_tokens: Option<i64>,
|
||||
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<Line<'static>> {
|
|||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
79
codex-rs/tui2/src/clipboard_copy.rs
Normal file
79
codex-rs/tui2/src/clipboard_copy.rs
Normal file
|
|
@ -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<arboard::Clipboard>,
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
|
@ -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<Item = &'a Span<'a>>,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<AtomicBool>) -> 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()?;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue