Skip to content

Conversation

@joshka-oai
Copy link
Collaborator

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

WIP: Rework TUI viewport, history printing, and selection/copy

Draft PR – 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.


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.

Pull FrameRequester out of tui.rs into its own module and make a
FrameScheduler struct. This is effectively an Actor/Handler approach
(see https://ryhl.io/blog/actors-with-tokio/). Adds tests and docs.

Small refactor of pending_viewport_area logic.
- Add App::render_transcript_cells to draw history cells in a transcript area above the chat composer.
- Use the full terminal height in the main draw loop and pin the composer to the bottom of the screen.
- Start storing history cells in App::transcript_cells when InsertHistoryCell arrives, rather than only queuing vt100 history lines.
- Wrap transcript lines using word_wrap_lines_borrowed so history uses viewport-aware soft wrapping.
- Space non-streaming history cells with a single blank line while avoiding gaps between streaming chunks.
- Enable application mouse mode and disable alternate scroll so wheel events arrive as Mouse events instead of arrow keys.
- Introduce a TranscriptScroll enum and field on App to track whether the transcript is pinned to the bottom or anchored at a specific cell/line.
- Extend TuiEvent with a Mouse variant and plumb mouse events through the TUI, stubbing them out where not yet handled.
- Enter the alternate screen around App::run so the main chat + transcript view uses the full terminal while preserving normal scrollback on exit.
- Replace ad-hoc transcript line collection with build_transcript_lines, tracking cell and line indices for each flattened line.
- Use TranscriptScroll plus transcript_view_top to anchor scrolling to a specific history cell while new content arrives or the viewport size changes.
- Implement scroll_transcript and hook it up to mouse wheel events so the transcript can be scrolled independently of the composer.
- Track a TranscriptSelection anchor/head in App so the transcript can be selected with the mouse.
- Render the active selection by applying a reversed style to the selected text region, skipping the left gutter.
- Extend handle_mouse_event to clamp positions into the transcript area, clear selection on wheel scroll, and support click-drag selection.
- Track transcript_view_top and treat transcript scrolling as content-relative so the selection and view stay aligned with history cells.
- Detect user history cells when rendering and paint the entire row with the user_message_style background for better prompt highlighting.
- Thread transcript scrolled/selection state into the bottom pane footer, showing scrollback and Ctrl+Y copy hints when appropriate.
- Map PageUp/PageDown to scroll the transcript by a full transcript viewport, independent of the composer height.
- Map Home/End to jump to the top or back to the bottom of the transcript history.
- Extend the footer shortcut summary to advertise PgUp/PgDn scroll and Home/End jump when the transcript is scrolled.
- Track transcript_total_lines and transcript_view_top in App so we can derive the current visible top line.
- Thread an optional (current, total) scroll_position through ChatWidget and BottomPane into FooterProps.
- Render a dimmed “current/total” indicator in the footer when the transcript is scrolled to give users a sense of position.
- Add streaming_wrapping_design.md to capture how streaming agent responses are rendered and wrapped today.
- Document why streamed markdown does not reflow correctly on resize due to pre-wrapping in MarkdownStreamCollector.
- Outline several design directions (width=None, streaming cell, hybrid visual-line model) and recommend starting with a minimal width=None change.
- Introduce a clipboard_copy module backed by arboard, wrapped in a small ClipboardManager abstraction.
- Implement App::copy_transcript_selection bound to Ctrl+Y, which re-renders the visible transcript into a buffer and copies the selected region as plain text.
- Surface copy success/failure in the footer via a CopyStatus enum so users get inline feedback without adding history messages.
- Extend AppExitInfo with a session_lines field containing the flattened transcript lines.
- Collect transcript_lines at the end of App::run and return them to the caller before clearing the TUI.
- Update both codex and codex-tui entrypoints to print the session transcript after leaving the alternate screen so it ends up in scrollback.
- Reuse insert_history::write_spans to render transcript lines into ANSI, preserving markdown and user-message styling when printing on exit.
- Introduce a render_lines_to_ansi helper that merges line and span styles before emitting the vt100 sequence.
- Print a blank line between the rendered transcript and the token usage summary for better separation in scrollback.
Adjust the main TUI layout so that when the transcript has
fewer lines than the available viewport, the bottom chat pane
is placed directly under the rendered transcript instead of
being pegged to the bottom of the terminal. This avoids a large
blank gap on tall terminals when a session is just starting,
while preserving the previous behavior once the transcript is
long enough to fill the space above the chat.

The transcript renderer now:
- Computes the actual number of visible transcript lines and
  uses that to determine the chat pane's top row.
- Clears only the region above the chat and then renders the
  transcript into that area.
- Returns the computed chat top position so the draw loop can
  render the chat there and clear any remaining rows below it.

Scroll state and selection continue to operate on flattened
transcript lines; this change only affects vertical placement
of the chat pane when the transcript is short.
Hook crossterm mouse wheel events into the transcript and
full-screen pager overlays so that scrolling is handled by the
TUI itself instead of relying on terminal "alternate scroll"
behavior.

- Wire TuiEvent::Mouse into TranscriptOverlay and StaticOverlay
  so PagerView updates its scroll_offset on ScrollUp/ScrollDown.
- Use the same 3-line wheel step as the main transcript view
  for consistency between inline and overlay scrolling.
- Remove the custom Enable/DisableAlternateScroll commands and
  calls; the TUI now stays in application mouse mode and treats
  wheel events uniformly across inline and alt-screen views.

This keeps scrolling behavior predictable across terminals and
restores mouse scrolling in transcript/diff/approval overlays
after disabling alternate scroll in the main TUI.
Refine mouse-driven transcript scrolling so that auto-follow
only disengages when it matters for selection:

- When a mouse selection begins while a task/stream is running
  and the view is following the bottom, convert the scroll mode
  into an anchored position so streaming output no longer moves
  the viewport under the selection.
- If streaming is not active, starting a selection leaves
  scroll mode as-is; there is nothing to auto-follow.
- If streaming continues and the user scrolls back to the
  bottom (via wheel or PgDn/End), scroll mode returns to
  "follow latest" and auto-follow resumes even if a selection
  remains.

The behavior is documented on the mouse and scroll helpers so
that the interaction between streaming, selection, and the
 state is clear to future readers.
@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 +1710 to +1714
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() != " " {
Copy link
Contributor

Choose a reason for hiding this comment

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

P1 Badge Copy strips leading indentation from selected lines

The new copy_transcript_selection logic computes text_start/text_end using the first non-space cell on each line and then clamps the selection to that range, which discards any leading spaces present inside the selected region (e.g., code indentation). When a user selects an indented code block, the clipboard text is emitted without its initial spaces, changing code formatting and potentially breaking pasted snippets. The loop at these lines intentionally skips spaces when finding first_text_x, so indentation is never copied.

Useful? React with 👍 / 👎.

Comment on lines +496 to +500
self.handle_key_event(tui, key_event).await;
}
TuiEvent::Mouse(mouse_event) => {
self.handle_mouse_event(tui, mouse_event);
}
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 Mouse selection changes aren’t redrawn

Mouse events update transcript selection state but this branch simply calls handle_mouse_event without scheduling a frame, so when the UI is otherwise idle (no streaming/animations) a click or drag produces no Draw event and the selection highlight never appears until some unrelated redraw occurs. Users can end up copying stale or invisible selections because the viewport isn’t repainted after the mouse interaction. A frame request is needed after mouse selection changes.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants