Skip to content

Conversation

@joshka-oai
Copy link
Collaborator

@joshka-oai joshka-oai commented Dec 4, 2025

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.
  • 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.
  • 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.
  • 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.

@joshka-oai
Copy link
Collaborator Author

@codex review

Copy link
Contributor

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@joshka-oai joshka-oai force-pushed the joshka/viewport branch 7 times, most recently from fa397c8 to e17a99d Compare December 11, 2025 01:29
@joshka-oai joshka-oai force-pushed the joshka/viewport branch 4 times, most recently from 58ce53d to e72a8f2 Compare December 12, 2025 22:21
Capture the tui2 viewport/transcript model and streaming wrapping design
so future viewport work has a stable reference point.
Render the chat composer in a bottom-aligned area and draw transcript
lines in the remaining space, with the viewport reset each frame so
we control scrolling and selection behavior end-to-end.
Clear the transcript region and render each row with viewport-aware
wrapping, keeping a single blank line between non-streaming cells while
streaming continuations stay tight. Enable mouse capture so wheel
scrolling arrives as mouse events instead of terminal scrollback.
Track transcript scroll intent with TranscriptScroll while keeping the
current rendering path unchanged, and plumb mouse events through the TUI
event stream so future viewport scrolling can react to wheel input.
Run the main UI in alt screen to give the chat+transcript view the full
terminal without polluting normal scrollback.
Teach the v2 TUI to maintain a stable transcript viewport when scrolled
while preserving bottom-pinned behavior by default.

This refactors inline transcript rendering to use a shared
`build_transcript_lines` helper that flattens `HistoryCell::display_lines`
into `Line` rows alongside metadata mapping each row back to its
`(cell_index, line_in_cell)` origin (or `None` for spacer lines). The
rendering path uses this metadata with `TranscriptScroll` to compute a
top offset: when `ToBottom`, it shows the tail that fits in the
transcript area; when `Scrolled`, it anchors to the requested cell/line
if present and falls back to bottom if the anchor has been pruned.

Mouse wheel events over the transcript area now route through
`handle_mouse_event`/`scroll_transcript`, deriving a ±3-line delta,
clamping within available history, and updating `transcript_scroll` back
to `ToBottom` or a `Scrolled { cell_index, line_in_cell }` anchor based
on the nearest visible line. The draw loop clones `transcript_cells`
before entering the closure to satisfy the borrow checker, and scroll
adjustments schedule a new frame via the existing `FrameRequester`.

Snapshot behavior stays unchanged: when the user has not scrolled, the
transcript remains pinned to the bottom, and existing `codex-tui2` UI
snapshots continue to match.
Refine the draw layout so the bottom chat pane sits directly under the
transcript when history is short instead of being pegged to the bottom
of the terminal.

When the transcript occupies fewer lines than the available viewport, the
inline renderer now:
- Computes the number of visible transcript rows based on the current
  scroll state and a bounded transcript height above the composer.
- Lifts the chat area so it starts immediately below the rendered
  transcript, leaving at most a single blank spacer line, and clears only
  the region above the chat before drawing.
- Fills any remaining rows below the chat with a clear so stale content
  from previous frames does not linger when the layout changes.

This is implemented by having `render_transcript_cells` return the computed
`chat_top` row (given the desired chat height) and updating the draw loop
to use that when positioning the composer, while preserving the existing
wrapped transcript rendering and scroll behavior introduced earlier.
Snapshot behavior stays unchanged: when the user has not scrolled, the
transcript remains pinned to the bottom, and existing `codex-tui2` UI
snapshots continue to match.
Restore mouse wheel scrolling for full-screen transcript and diff overlays
without relying on terminal-specific alternate scroll modes.

`PagerView` now handles scroll wheel events by adjusting `scroll_offset`
in fixed 3-line steps and scheduling a redraw via `FrameRequester`, and
both transcript and static overlays route `TuiEvent::Mouse` through this
path so wheel input works consistently across overlay types.

On the terminal side, the TUI stays in application mouse mode throughout:
custom alternate scroll commands are removed, `set_modes` only enables
mouse capture, and suspend/alt-screen restore paths rely on
`EnterAlternateScreen`/`LeaveAlternateScreen` without toggling alt scroll.
This keeps scrolling behavior predictable across terminals while
preserving existing snapshots.
Add a TranscriptSelection state (anchor/head) to track mouse-driven
selection over the transcript viewport and render it inline.

Mouse handling now ignores overlays, clamps events to the transcript
area above the composer, and interprets left-click/drag/up to start,
update, and clear selections while clearing any selection on wheel
scroll. Selection rendering runs after transcript lines are drawn: it
scans each visible row for non-space glyphs, skips the left gutter, and
applies REVERSED styling only to the intersecting text range so padding
isn’t highlighted.
Ensure transcript selections stop auto-following while streaming, then
resume bottom-follow once the user scrolls back to the end.

Selection start/drag now checks `chat_widget.is_task_running()` and, when
streaming, converts `TranscriptScroll::ToBottom` into a fixed anchor via
`lock_transcript_scroll_to_current_view` so new output doesn’t move the
selection. The new helper derives a stable `(cell_index, line_in_cell)`
anchor from the current top row, and scroll events can still move the
viewport back to `ToBottom` when appropriate. `ChatWidget` exposes
`is_task_running` so the viewport can make this decision without poking
into bottom pane internals.
- clear the terminal in `PreparedResumeAction::RealignViewport` so resume/overlay exits do not leave old buffer content
- keep job-control viewport realignment aligned with alt-screen cleanup expectations
- render transcript lines to ANSI with line-level style merging for scrollback
- pad user rows to full width so prompt backgrounds are solid and readable
- capture `session_lines` on exit for post-TUI transcript printing
- write rendered `session_lines` to stdout after leaving alt-screen so they land in scrollback
- add a separating blank line before the final token-usage summary
- add clipboard_copy with an arboard-backed manager and platform fallback
- render transcript rows into a buffer and extract selection text without gutter chrome
- wire Ctrl+Y to copy the current screen-space selection respecting scroll
- track transcript view top + total lines while rendering the viewport
- propagate scroll state into the bottom pane for footer rendering
- show scroll shortcuts and current line position when scrolled
- map PageUp/PageDown to scroll the inline viewport by a screenful
- jump to top or bottom with Home/End and clear any active selection
- reuse viewport sizing to ignore scroll requests when the prompt fills the screen
- show a Ctrl+Y copy-selection hint in the footer when a transcript selection is active
- add a footer snapshot for the selection hint alongside scroll position
- track wrapped user rows alongside line wrapping to keep selection styles on scroll
- stop clearing selection state on mouse wheel and page-scroll actions
- restyle selected rows when rendering the inline transcript viewport
- store selection endpoints as wrapped line/column indices instead of screen coords
- map mouse positions through transcript_view_top so selection follows scroll and resize
- apply/copy selection by rebuilding the visible wrapped viewport range
- add a unit test asserting selection moves with scroll
- keep a one-row gap between short transcript history and the composer
- anchor auto-follow only after a drag extends the selection while streaming
- keep click-only selection from altering scroll behavior
- document the drag-based scroll lock in the mouse handler
- collapse nested if-let checks when resolving transcript anchors
- swap manual repeat/take loops for repeat_n in wrapped row tracking
- use infallible conversions for scroll deltas and iterator helpers
@joshka-oai
Copy link
Collaborator Author

@codex review

Copy link
Contributor

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +888 to +892
let mut clamped_y = mouse_event.row;

if clamped_y < transcript_area.y || clamped_y >= transcript_area.bottom() {
clamped_y = transcript_area.y;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore mouse events outside transcript area

In handle_mouse_event, Y coordinates outside the transcript are clamped to transcript_area.y instead of being ignored, so a click in the composer or footer will still start a transcript selection anchored at the top line. With mouse capture enabled, this leads to accidental selections whenever the user clicks below the transcript. Consider returning early when mouse_event.row is outside the transcript bounds (or only clamping during drag) so non-transcript clicks don’t mutate selection state.

Useful? React with 👍 / 👎.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring this to another PR

Comment on lines +1357 to +1361
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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Copy selection should include offscreen lines

copy_transcript_selection renders and iterates only visible_start..visible_end, so any selected lines that are outside the current viewport are silently dropped. This shows up if the user selects text, then scrolls, or if the selection spans more lines than fit on screen—Ctrl+Y will copy only the visible slice or even nothing if the selection is fully offscreen. Consider rebuilding the buffer for the full transcript (or at least the selected line range) so copy matches the selection regardless of scroll position.

Useful? React with 👍 / 👎.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring this to another PR

@joshka-oai joshka-oai marked this pull request as ready for review December 16, 2025 01:20
@joshka-oai joshka-oai merged commit b093565 into main Dec 16, 2025
27 checks passed
@joshka-oai joshka-oai deleted the joshka/viewport branch December 16, 2025 01:20
@github-actions github-actions bot locked and limited conversation to collaborators Dec 16, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants