Commit Graph

25 Commits

  • fix(tui): handle zellij redraw and composer rendering (#16578)
    ## TL;DR
    
    Fixes the issues when using Codex CLI with Zellij multiplexer. Before
    this PR there would be no scrollback when using it inside a zellij
    terminal.
    
    ## Problem
    
    Addresses #2558
    
    Zellij does not support ANSI scroll-region manipulation (`DECSTBM` /
    Reverse Index) or the alternate screen buffer in the way traditional
    terminals do. When codex's TUI runs inside Zellij, two things break: (1)
    inline history insertion corrupts the display because the scroll-region
    escape sequences are silently dropped or mishandled, and (2) the
    composer textarea renders with inherited background/foreground styles
    that produce unreadable text against Zellij's pane chrome.
    
    ## Mental model
    
    The fix introduces a **Zellij mode** — a runtime boolean detected once
    at startup via `codex_terminal_detection::terminal_info().is_zellij()` —
    that gates two subsystems onto Zellij-safe terminal strategies:
    
    - **History insertion** (`insert_history.rs`): Instead of using
    `DECSTBM` scroll regions and Reverse Index (`ESC M`) to slide content
    above the viewport, Zellij mode scrolls the screen by emitting `\n` at
    the bottom row and then writes history lines at absolute positions. This
    avoids every escape sequence Zellij mishandles.
    - **Viewport expansion** (`tui.rs`): When the viewport grows taller than
    available space, the standard path uses `scroll_region_up` on the
    backend. Zellij mode instead emits newlines at the screen bottom to push
    content up, then invalidates the ratatui diff buffer so the next draw is
    a full repaint.
    - **Composer rendering** (`chat_composer.rs`, `textarea.rs`): All text
    rendering in the input area uses an explicit `base_style` with
    `Color::Reset` foreground, preventing Zellij's pane styling from
    bleeding into the textarea. The prompt chevron (`›`) and placeholder
    text use explicit color constants instead of relying on `.bold()` /
    `.dim()` modifiers that render inconsistently under Zellij.
    
    ## Non-goals
    
    - This change does not fix or improve Zellij's terminal emulation
    itself.
    - It does not rearchitect the inline viewport model; it adds a parallel
    code path gated on detection.
    - It does not touch the alternate-screen disable logic (that already
    existed and continues to use `is_zellij` via the same detection).
    
    ## Tradeoffs
    
    - **Code duplication in `insert_history.rs`**: The Zellij and Standard
    branches share the line-rendering loop (color setup, span merging,
    `write_spans`) but differ in the scrolling preamble. The duplication is
    intentional — merging them would force a complex conditional state
    machine that's harder to reason about than two flat sequences.
    - **`invalidate_viewport` after every Zellij history flush or viewport
    expansion**: This forces a full repaint on every draw cycle in Zellij,
    which is more expensive than ratatui's normal diff-based rendering. This
    is necessary because Zellij's lack of scroll-region support means the
    diff buffer's assumptions about what's on screen are invalid after we
    manually move content.
    - **Explicit colors vs semantic modifiers**: Replacing `.bold()` /
    `.dim()` with `Color::Cyan` / `Color::DarkGray` / `Color::White` in the
    Zellij branch sacrifices theme-awareness for correctness. If the project
    ever adopts a theming system, Zellij styling will need to participate.
    
    ## Architecture
    
    The Zellij detection flag flows through three layers:
    
    1. **`codex_terminal_detection`** — `TerminalInfo::is_zellij()` (new
    convenience method) reads the already-detected `Multiplexer` variant.
    2. **`Tui` struct** — caches `is_zellij` at construction; passes it into
    `update_inline_viewport`, `flush_pending_history_lines`, and
    `insert_history_lines_with_mode`.
    3. **`ChatComposer` struct** — independently caches `is_zellij` at
    construction; uses it in `render_textarea` for style decisions.
    
    The two caches (`Tui.is_zellij` and `ChatComposer.is_zellij`) are read
    from the same global `OnceLock<TerminalInfo>`, so they always agree.
    
    ## Observability
    
    No new logging, metrics, or tracing is introduced. Diagnosis depends on:
    - Whether `ZELLIJ` or `ZELLIJ_SESSION_NAME` env vars are set (the
    detection heuristic).
    - Visual inspection of the rendered TUI inside Zellij vs a standard
    terminal.
    - The insta snapshot `zellij_empty_composer` captures the Zellij-mode
    render path.
    
    ## Tests
    
    - `terminal_info_reports_is_zellij` — unit test in `terminal-detection`
    confirming the convenience method.
    - `zellij_empty_composer_snapshot` — insta snapshot in `chat_composer`
    validating the Zellij render path for an empty composer.
    - `vt100_zellij_mode_inserts_history_and_updates_viewport` — integration
    test in `insert_history` verifying that Zellij-mode history insertion
    writes content and shifts the viewport.
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
  • Rename tui_app_server to tui (#16104)
    This is a follow-up to https://github.com/openai/codex/pull/15922. That
    previous PR deleted the old `tui` directory and left the new
    `tui_app_server` directory in place. This PR renames `tui_app_server` to
    `tui` and fixes up all references.
  • Remove the legacy TUI split (#15922)
    This is the part 1 of 2 PRs that will delete the `tui` /
    `tui_app_server` split. This part simply deletes the existing `tui`
    directory and marks the `tui_app_server` feature flag as removed. I left
    the `tui_app_server` feature flag in place for now so its presence
    doesn't result in an error. It is simply ignored.
    
    Part 2 will rename the `tui_app_server` directory `tui`. I did this as
    two parts to reduce visible code churn.
  • feat(tui) /clear (#12444)
    # /clear feature! 
    
    /clear will clear your terminal while preserving the context/state of
    the thread.
  • fix(tui): preserve URL clickability across all TUI views (#12067)
    ## Problem
    
    Long URLs containing `/` and `-` characters are split across multiple
    terminal lines by `textwrap`'s default hyphenation rules. This breaks
    terminal link detection: emulators can no longer identify the URL as
    clickable, and copy-paste yields a truncated fragment. The issue affects
    every view that renders user or agent text — exec output, history cells,
    markdown, the app-link setup screen, and the VT100 scrollback path.
    
    A secondary bug compounds the first: `desired_height()` calculations
    count logical lines rather than viewport rows. When a URL overflows its
    line and wraps visually, the height budget is too small, causing content
    to clip or leave gaps.
    
    Here is how the complete URL is interpreted by the terminal before
    (first line only) and after (complete URL):
    
    | Before | After |
    |---|---|
    | <img width="777" height="1002" alt="Screenshot 2026-02-17 at 7 59 11
    PM"
    src="https://github.com/user-attachments/assets/193a89a0-7e56-49c5-8b76-53499a76e7e3"
    /> | <img width="777" height="1002" alt="Screenshot 2026-02-17 at 7 58
    40 PM"
    src="https://github.com/user-attachments/assets/0b9b4c14-aafb-439f-9ffe-f6bba556f95e"
    /> |
    
    ## Mental model
    
    The TUI now treats URL-like tokens as atomic units that must never be
    split by the wrapping engine. Every call site that previously used
    `word_wrap_*` has been migrated to `adaptive_wrap_*`, which inspects
    each line for URL-like tokens and switches wrapping strategy
    accordingly:
    
    - **Non-URL lines** follow the existing `textwrap` path unchanged (word
    boundaries, optional indentation, hyphenation).
    - **URL-only lines** (with at most decorative markers like `│`, `-`,
    `1.`) are emitted unwrapped so terminal link detection works; ratatui's
    `Wrap { trim: false }` handles the final character wrap at render time.
    - **Mixed lines** (URL + substantive non-URL prose) flow through
    `adaptive_wrap_line` so prose wraps naturally at word boundaries while
    URL tokens remain unsplit.
    
    Height measurement everywhere now delegates to
    `Paragraph::line_count(width)`, which accounts for the visual row cost
    of overflowed lines. This single source of truth replaces ad-hoc line
    counting in individual cells.
    
    For terminal scrollback (the VT100 path that prints history when the TUI
    exits), URL-only lines are emitted unwrapped so the terminal's own link
    detector can find them. Mixed URL+prose lines use adaptive wrapping so
    surrounding text wraps naturally. Continuation rows are pre-cleared to
    avoid stale content artifacts.
    
    ## Non-goals
    
    - Full RFC 3986 URL parsing. The detector is a conservative heuristic
    that covers `scheme://host`, bare domains (`example.com/path`),
    `localhost:port`, and IPv4 hosts. IPv6 (`[::1]:8080`) and exotic schemes
    are intentionally excluded from v1.
    - Changing wrapping behavior for non-URL content.
    - Reflowing or reformatting existing terminal scrollback on resize.
    
    ## Tradeoffs
    
    | Decision | Upside | Downside |
    |----------|--------|----------|
    | Heuristic URL detection vs. full parser | Fast, zero-alloc on the hot
    path; conservative enough to reject file paths like `src/main.rs` |
    False negatives on obscure URL formats (they get split as before) |
    | Adaptive (three-path) wrapping | Non-URL lines are untouched — no
    behavior change, no perf cost; mixed lines wrap prose naturally while
    preserving URLs | Three wrapping strategies to reason about when
    debugging layout |
    | Row-based truncation with line-unit ellipsis | Accurate viewport
    budget; stable "N lines omitted" count across terminal widths |
    `truncate_lines_middle` is more complex (must compute per-line row cost)
    |
    | Unwrapped URL-only lines in scrollback | Terminal emulators detect
    clickable links; copy-paste gets the full URL | TUI and scrollback
    formatting diverge for URL-only lines |
    | Default `desired_height` via `Paragraph::line_count` | DRY — most
    cells inherit correct measurement | Cells with custom layout must
    remember to override |
    
    ## Architecture
    
    ```mermaid
    flowchart TD
        A["adaptive_wrap_*()"] --> B{"line_contains_url_like?"}
        B -- No URL tokens --> C["word_wrap_line<br/>(textwrap default)"]
        B -- Has URL tokens --> D{"mixed URL + prose?"}
        D -- "URL-only<br/>(+ decorative markers)" --> E["emit unwrapped<br/>(terminal char-wraps)"]
        D -- "Mixed<br/>(URL + substantive text)" --> F["adaptive_wrap_line<br/>(AsciiSpace + custom WordSplitter)"]
        C --> G["Paragraph::line_count(w)<br/>(single height truth)"]
        E --> G
        F --> G
    ```
    
    **Changed files:**
    
    | File | Role |
    |------|------|
    | `wrapping.rs` | URL detection heuristics, mixed-line detection,
    `adaptive_wrap_*` functions, custom `WordSplitter` |
    | `exec_cell/render.rs` | Row-aware `truncate_lines_middle`, adaptive
    wrapping for command/output display |
    | `history_cell.rs` | Migrate all cell types to `adaptive_wrap_*`;
    default `desired_height` via `Paragraph::line_count` |
    | `insert_history.rs` | Three-path scrollback wrapping (unwrapped
    URL-only, adaptive mixed, word-wrapped text); continuation row clearing
    |
    | `app_link_view.rs` | Adaptive wrapping for setup URL; `desired_height`
    via `Paragraph::line_count` |
    | `markdown_render.rs` | Adaptive wrapping in `finish_paragraph` |
    | `model_migration.rs` | Viewport-aware wrapping for narrow-pane
    markdown |
    | `pager_overlay.rs` | `Wrap { trim: false }` for transcript and
    streaming chunks |
    | `queued_user_messages.rs` | Migrate to `adaptive_wrap_lines` |
    | `status/card.rs` | Migrate to `adaptive_wrap_lines` |
    
    ## Observability
    
    - **Ellipsis message** in truncated exec output reports omitted count in
    logical lines (stable across resize) rather than viewport rows
    (fluctuates).
    - URL detection is deterministic and stateless — no hidden caching or
    memoization to go stale.
    - Height mismatch bugs surface immediately as visual clipping or gaps;
    the `Paragraph::line_count` path is the same code ratatui uses at render
    time, so measurement and rendering cannot diverge.
    
    ## Tests
    
    26 new unit tests across 7 files, covering:
    
    - **URL integrity**: assert a URL-like token appears on exactly one
    rendered line (not split across two).
    - **Height accuracy**: compare `desired_height()` against
    `Paragraph::line_count()` for URL-containing content.
    - **Row-aware truncation**: verify ellipsis counts logical lines and
    output fits within the row budget.
    - **Scrollback rendering**: VT100 backend tests confirm prefix and URL
    land on the same row; continuation rows are cleared; mixed URL+prose
    lines wrap prose while preserving URL tokens.
    - **Mixed URL+prose detection**: `line_has_mixed_url_and_non_url_tokens`
    correctly distinguishes lines with substantive non-URL text from lines
    with only decorative markers alongside a URL.
    - **Heuristic correctness**: positive matches (`https://...`,
    `example.com/path`, `localhost:3000/api`, `192.168.1.1:8080/health`) and
    negative matches (`src/main.rs`, `foo/bar`, `hello-world`).
    
    ## Risks and open items
    
    1. **URL-like tokens in code output** (e.g. `example.com/api` inside a
    JSON blob) will trigger URL-preserving wrap on that line. This is
    acceptable — the worst case is a slightly wider line, not broken output.
    2. **Very long non-URL tokens on a URL line** can only break at
    character boundaries (the custom splitter emits all char indices for
    non-URL words). On extremely narrow terminals this could overflow, but
    narrow terminals already degrade gracefully.
    3. **No IPv6 support** — `[::1]:8080/path` will be treated as a non-URL
    and may get split. Can be added later without API changes.
    
    Fixes #5457
  • fix(tui): propagate errors in insert_history_lines_to_writer (#4266)
    ## What?
    Fixed error handling in `insert_history_lines_to_writer` where all
    terminal operations were silently ignoring errors via `.ok()`.
    
      ## Why?
    Silent I/O failures could leave the terminal in an inconsistent state
    (e.g., scroll region not reset) with no way to debug. This violates Rust
    error handling best practices.
    
      ## How?
      - Changed function signature to return `io::Result<()>`
      - Replaced all `.ok()` calls with `?` operator to propagate errors
    - Added `tracing::warn!` in wrapper function for backward compatibility
      - Updated 15 test call sites to handle Result  with `.expect()`
    
      ## Testing
      -  Pass all tests
    
      ## Type of Change
      - [x] Bug fix (non-breaking change)
    
    ---------
    
    Signed-off-by: Huaiwu Li <lhwzds@gmail.com>
    Co-authored-by: Eric Traut <etraut@openai.com>
  • update composer + user message styling (#4240)
    Changes:
    
    - the composer and user messages now have a colored background that
    stretches the entire width of the terminal.
    - the prompt character was changed from a cyan `▌` to a bold `›`.
    - the "working" shimmer now follows the "dark gray" color of the
    terminal, better matching the terminal's color scheme
    
    | Terminal + Background        | Screenshot |
    |------------------------------|------------|
    | iTerm with dark bg | <img width="810" height="641" alt="Screenshot
    2025-09-25 at 11 44 52 AM"
    src="https://github.com/user-attachments/assets/1317e579-64a9-4785-93e6-98b0258f5d92"
    /> |
    | iTerm with light bg | <img width="845" height="540" alt="Screenshot
    2025-09-25 at 11 46 29 AM"
    src="https://github.com/user-attachments/assets/e671d490-c747-4460-af0b-3f8d7f7a6b8e"
    /> |
    | iTerm with color bg | <img width="825" height="564" alt="Screenshot
    2025-09-25 at 11 47 12 AM"
    src="https://github.com/user-attachments/assets/141cda1b-1164-41d5-87da-3be11e6a3063"
    /> |
    | Terminal.app with dark bg | <img width="577" height="367"
    alt="Screenshot 2025-09-25 at 11 45 22 AM"
    src="https://github.com/user-attachments/assets/93fc4781-99f7-4ee7-9c8e-3db3cd854fe5"
    /> |
    | Terminal.app with light bg | <img width="577" height="367"
    alt="Screenshot 2025-09-25 at 11 46 04 AM"
    src="https://github.com/user-attachments/assets/19bf6a3c-91e0-447b-9667-b8033f512219"
    /> |
    | Terminal.app with color bg | <img width="577" height="367"
    alt="Screenshot 2025-09-25 at 11 45 50 AM"
    src="https://github.com/user-attachments/assets/dd7c4b5b-342e-4028-8140-f4e65752bd0b"
    /> |
  • replace tui_markdown with a custom markdown renderer (#3396)
    Also, simplify the streaming behavior.
    
    This fixes a number of display issues with streaming markdown, and paves
    the way for better markdown features (e.g. customizable styles, syntax
    highlighting, markdown-aware wrapping).
    
    Not currently supported:
    - footnotes
    - tables
    - reference-style links
  • syntax-highlight bash lines (#3142)
    i'm not yet convinced i have the best heuristics for what to highlight,
    but this feels like a useful step towards something a bit easier to
    read, esp. when the model is producing large commands.
    
    <img width="669" height="589" alt="Screenshot 2025-09-03 at 8 21 56 PM"
    src="https://github.com/user-attachments/assets/b9cbcc43-80e8-4d41-93c8-daa74b84b331"
    />
    
    also a fairly significant refactor of our line wrapping logic.
  • prefer ratatui Stylized for constructing lines/spans (#3068)
    no functional change, just simplifying ratatui styling and adding
    guidance in AGENTS.md for future.
  • refactor onboarding screen to a separate "app" (#2524)
    this is in preparation for adding more separate "modes" to the tui, in
    particular, a "transcript mode" to view a full history once #2316 lands.
    
    1. split apart "tui events" from "app events".
    2. remove onboarding-related events from AppEvent.
    3. move several general drawing tools out of App and into a new Tui
    class
  • tui: switch to using tokio + EventStream for processing crossterm events (#2489)
    bringing the tui more into tokio-land to make it easier to factorize.
    
    fyi @bolinfest
  • Added allow-expect-in-tests / allow-unwrap-in-tests (#2328)
    This PR:
    * Added the clippy.toml to configure allowable expect / unwrap usage in
    tests
    * Removed as many expect/allow lines as possible from tests
    * moved a bunch of allows to expects where possible
    
    Note: in integration tests, non `#[test]` helper functions are not
    covered by this so we had to leave a few lingering `expect(expect_used`
    checks around
  • Re-add markdown streaming (#2029)
    Wait for newlines, then render markdown on a line by line basis. Word wrap it for the current terminal size and then spit it out line by line into the UI. Also adds tests and fixes some UI regressions.
  • Streaming markdown (#1920)
    We wait until we have an entire newline, then format it with markdown and stream in to the UI. This reduces time to first token but is the right thing to do with our current rendering model IMO. Also lets us add word wrapping!
  • Stream model responses (#1810)
    Stream models thoughts and responses instead of waiting for the whole
    thing to come through. Very rough right now, but I'm making the risk call to push through.
  • fix insert_history modifier handling (#1774)
    This fixes a bug in insert_history_lines where writing
    `Line::From(vec!["A".bold(), "B".into()])` would write "B" as bold,
    because "B" didn't explicitly subtract bold.
  • clamp render area to terminal size (#1758)
    this fixes a couple of panics that would happen when trying to render
    something larger than the terminal, or insert history lines when the top
    of the viewport is at y=0.
  • resizable viewport (#1732)
    Proof of concept for a resizable viewport.
    
    The general approach here is to duplicate the `Terminal` struct from
    ratatui, but with our own logic. This is a "light fork" in that we are
    still using all the base ratatui functions (`Buffer`, `Widget` and so
    on), but we're doing our own bookkeeping at the top level to determine
    where to draw everything.
    
    This approach could use improvement—e.g, when the window is resized to a
    smaller size, if the UI wraps, we don't correctly clear out the
    artifacts from wrapping. This is possible with a little work (i.e.
    tracking what parts of our UI would have been wrapped), but this
    behavior is at least at par with the existing behavior.
    
    
    https://github.com/user-attachments/assets/4eb17689-09fd-4daa-8315-c7ebc654986d
    
    
    cc @joshka who might have Thoughts™
  • remove conversation history widget (#1727)
    this widget is no longer used.
  • fix: correctly wrap history items (#1685)
    The overall idea here is: skip ratatui for writing into scrollback,
    because its primitives are wrong. We want to render full lines of text,
    that will be wrapped natively by the terminal, and which we never plan
    to update using ratatui (so the `Buffer` struct is overhead and in fact
    an inhibition).
    
    Instead, we use ANSI scrolling regions (link reference doc to come).
    Essentially, we:
    1. Define a scrolling region that extends from the top of the prompt
    area all the way to the top of scrollback
    2. Scroll that region up by N < (screen_height - viewport_height) lines,
    in this PR N=1
    3. Put our cursor at the top of the newly empty region
    4. Print out our new text like normal
    
    The terminal interactions here (write_spans and its dependencies) are
    mostly extracted from ratatui.
  • Easily Selectable History (#1672)
    This update replaces the previous ratatui history widget with an
    append-only log so that the terminal can handle text selection and
    scrolling. It also disables streaming responses, which we'll do our best
    to bring back in a later PR. It also adds a small summary of token use
    after the TUI exits.