# 全屏对话记录 · 搜索、选择与折叠

> TUI 有两种放历史的方式：把定稿的行写进终端自己的回滚，或者占住备用屏幕、由 Codex 自己滚动。本篇看两种模式怎么在启动时定下来，回滚模式怎样用滚动区域插入历史、改宽度时重建，全屏模式的 TranscriptView 怎样用锚点定位、逐帧有界地搜索、在选择时冻结快照、按工具身份折叠活动，以及更早的历史怎样按页从 app-server 取回。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/fullscreen-transcript
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# 全屏对话记录 · 搜索、选择与折叠

[上一篇](https://daiw.net/manual/codex-source/history-cells)说到，定稿的历史单元进了 `App` 的 `transcript_cells`。它们最终画在哪里，取决于 TUI 用哪一种模式放历史，这也是整个终端界面分叉最大的地方。

## 用户看到的样子

默认的“全屏对话记录”把对话画在终端的备用屏幕里：滚轮与 `PgUp`、`PgDn` 滚动，拖动鼠标选择、右键复制，`F3` 查找，`F4` 聚焦一组工具活动看细节，`Ctrl+T` 展开完整记录。另一种是“回滚”模式：Codex 只占屏幕底部，历史进终端自己的回滚记录，用终端的滚动、搜索和复制，`Ctrl+T` 临时打开一个全屏的记录视图。`/tui` 选择下次启动用哪种。操作细节见手册[斜杠命令](https://daiw.net/manual/codex/slash-commands)的“全屏对话记录”一节。

## 两种模式在启动时定下来

模式由两个条件共同决定：配置 `tui.fullscreen_transcript`（默认 `true`）要求全屏，并且备用屏幕可用，`TranscriptMode::resolve` 才给出 `Owned`，否则就是 `Terminal`。备用屏幕是否可用由另一个函数决定：

```rust
fn determine_alt_screen_mode(
    no_alt_screen: bool,
    tui_alternate_screen: AltScreenMode,
    terminal_app_over_ssh: bool,
) -> bool {
    if no_alt_screen {
        return false;
    }

    match tui_alternate_screen {
        AltScreenMode::Always => true,
        AltScreenMode::Never => false,
        AltScreenMode::Auto => !terminal_app_over_ssh,
    }
}
```

（`codex-rs/tui/src/lib.rs:2116`）

`--no-alt-screen` 最优先；`tui.alternate_screen` 的默认值 `auto` 并不总是开启：`terminal_app_over_ssh` 为真时关闭。这个值来自启动探测（`codex-rs/tui/src/terminal_probe.rs`）：只有设了 `SSH_TTY` 或 `SSH_CONNECTION`、又不在 tmux 这类多路复用器里时，TUI 才向终端发设备属性查询，回答恰好是 macOS 自带 Terminal.app 的那一对（`1;2` 与 `1;95;0`）才算数。配置结构上 `alternate_screen` 的文档注释只写了“`auto` (default): Use alternate screen.”，没提这个例外，以代码为准。

`transcript_mode.rs` 的注释强调，启动时定下的模式在整个进程里不变，切换会话也不变，这样终端回滚里不会混进只存在于全屏记录里的更新。`/tui` 只把选择写进本机 `config.toml`，提示重启后生效。

```mermaid
flowchart TB
  CFG[tui.fullscreen_transcript] --> R{TranscriptMode resolve}
  ALT[备用屏幕可用吗<br/>no-alt-screen 参数与 alternate_screen] --> R
  R -->|两者都成立| O[Owned<br/>整屏由 Codex 绘制，捕获鼠标]
  R -->|否则| T[Terminal<br/>底部内联视口]
  T --> IH[insert_history<br/>定稿行写进终端回滚]
  T -->|Ctrl+T| OV[Overlay Transcript<br/>临时进入备用屏幕]
  O --> TV[TranscriptView<br/>滚动、选择、搜索、折叠]
  OV --> TV
  TV -->|读到已加载的最早一条| PG[thread/items/list<br/>每页 100 项]
```

## 回滚模式：用滚动区域插入历史

回滚模式下 ratatui 只画底部的视口，历史行靠转义序列插到它上面。标准做法是把终端的滚动区域（DECSTBM）限定在视口上方，从区域底部逐行换行写入，旧内容被顶进终端的回滚记录，视口纹丝不动：

```rust
            // Limit the scroll region to the lines from the top of the screen to the
            // top of the viewport. With this in place, when we add lines inside this
            // area, only the lines in this area will be scrolled. We place the cursor
            // at the end of the scroll region, and add lines starting there.
            //
            // ┌─Screen───────────────────────┐
            // │┌╌Scroll region╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐│
            // │┆                            ┆│
            // │┆                            ┆│
            // │┆                            ┆│
            // │█╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘│
            // │╭─Viewport───────────────────╮│
            // ││                            ││
            // │╰────────────────────────────╯│
            // └──────────────────────────────┘
            queue!(writer, SetScrollRegion(1..area.top()))?;
```

（`codex-rs/tui/src/insert_history.rs:189`）

部分滚动区域并非处处可靠。`ScrollbackStrategy::detect`（`codex-rs/tui/src/tui/scrollback.rs`）在 Windows Terminal（或设了 `WT_SESSION` 时）改用“全屏插入”：清掉视口，从视口顶部直接写历史，再补足视口高度的空行。注释说这样在部分滚动区域不可靠时仍能保住终端的原生回滚；在 Zellij 里，交给终端自己折行的那些行也走这条路，以保留终端管理的软折行。

回滚里的行是按写入时的宽度排好的，终端一改宽度就全乱了。`app/resize_reflow.rs` 的做法是以 `transcript_cells` 为源，清掉 Codex 写过的那部分回滚，按新宽度整段重写。重写多少行有上限，`resize_reflow_cap.rs` 按终端给默认值：VS Code 1000 行，Windows Terminal 9001 行，WezTerm 3500 行，Alacritty 10000 行，认不出的终端 1000 行，可以用 `tui.terminal_resize_reflow_max_rows` 改，设 `0` 表示不限。注释的理由是，重放得比终端保留的回滚还多，只会让改宽度更卡，并不能多看到历史。恢复会话时的首屏回放也用同一个上限。

## 全屏模式：一个视口，两种入口

全屏模式下 `Tui::set_owned_screen` 进入备用屏幕并打开鼠标报告（tmux 里设了 `mouse off` 时不捕获）；每帧由 `App::render_owned_transcript` 先按输入框要的高度排好底部，再把剩下的行交给 `TranscriptView`（`codex-rs/tui/src/transcript_view.rs`）。回滚模式下按 `Ctrl+T` 打开的 `TranscriptOverlay` 里面也是同一个 `TranscriptView`：`open_transcript_overlay`（`codex-rs/tui/src/app_backtrack.rs:159`）在全屏模式下只把视图切到详细呈现，在回滚模式下才临时进入备用屏幕、新建一个覆盖层。

`TranscriptView` 自己不存历史，只存阅读与选择状态和有界的排版缓存。它最核心的设计是锚点：

```rust
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
enum EntryKey {
    Cell(usize),
    Live,
}
// ...
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Anchor {
    key: EntryKey,
    index: usize,
    offset: usize,
    // Synthetic rows share source offsets: positive biases precede source (separators),
    // while negative biases follow it (disclosure controls).
    row_bias: isize,
}
```

（`codex-rs/tui/src/transcript_view.rs:49`）

阅读位置要么是 `Latest`（跟着最新内容走），要么是 `Reading(Anchor)`。锚点记的是“哪个单元、单元里第几个字节”，单元用它 `Arc` 的指针地址标识，正在进行的尾部用 `Live` 表示。于是往前插入更早的历史不会改变编号，改宽度重新折行也不会把阅读位置变成一个毫不相干的屏幕行。折行后的文本（`transcript_view/text.rs`）每一行都记着源码位置，绘制与选择共用同一份映射；制表符按每行实际的显示位置对齐到 8 列（`text_tabs.rs`），源码字节不变。排版缓存只为最近显示过的单元保留。

## 搜索：每帧只扫一小段

`F3` 打开的查找（`transcript_view/search.rs`）是增量的字面搜索，查询和正文都转小写后比较。它不在按键时一口气搜完，而是每画一帧推进一点：

```rust
const QUERY_BYTES: usize = 4096;
const SCAN_BYTES: usize = 16 * 1024;
const ENTRIES_PER_FRAME: usize = 8;
// ...
#[derive(Clone, Copy, Default)]
enum Progress {
    #[default]
    Idle,
    Restart,
    Scanning(Cursor),
    AwaitingHistory,
    Found,
    Exhausted,
}
```

（`codex-rs/tui/src/transcript_view/search.rs:21`）

查询最长 4096 字节；每帧最多查看 8 个单元、扫描 16 KiB，还没找到就再要一帧，界面因此不会被一次长搜索卡住。从新往旧扫到已加载的最早一个单元时，状态变成 `AwaitingHistory`，由 `App` 现有的分页去取更早的历史，取回后接着扫。改宽度或切换呈现方式会让源码偏移失效，搜索随之重来。

更早的历史来自 app-server。全屏模式恢复会话时，首屏只取大约三屏的行（终端高度乘 3），最多扫描 400 项（`HISTORY_ITEM_SCAN_LIMIT`）；往上翻或搜索碰到开头时，`request_older_history_page` 发一次 `thread/items/list`，每页 100 项（`HISTORY_ITEM_PAGE_LIMIT`）。协议定义里给这个方法的注释是“primarily reads append-only rollout storage”，所以它不参与按线程的串行化，可以和正在进行的轮次并发。

## 选择与折叠

选择（`selection.rs`）面对的难题是：你拖着鼠标的时候，新的输出还在源源不断地进来。它的办法是冻结：选择期间固定单元的顺序和当时显示的版本，只把看得见和选中的排版钉住，正本历史照常前进；正在阅读的尾部或刚被替换的工具组也按同样方式保留快照（`snapshot.rs`）。松开鼠标是否自动复制由 `tui.copy_on_select` 决定，默认 `auto`，在 Ghostty 1.2 及以上、macOS 上的 Kitty、Windows Terminal、Windows 上的 VS Code 这几种自带复制快捷键的终端里关闭，其他终端开启。

折叠分两层。一层是整体呈现：紧凑呈现用单元的 `compact_hyperlink_lines`，工具活动只露出摘要；按 `Ctrl+T` 切到详细呈现，改用 `transcript_hyperlink_lines`。另一层是局部展开（`disclosure.rs`）：按 `F4` 聚焦活动后才出现展开控件，展开状态存在一个集合里，键是工具调用的身份，而不是屏幕位置，所以活动从进行中的尾部变成已提交的历史、被重新分组或回放之后，展开状态都还在。复制与全文搜索始终按源码进行，不受折叠影响。

## 和《从 LLM 到 Coding Agent》对照

那本书的[会话历史](https://daiw.net/manual/llm-to-agent/session-history)一篇说，只追加的 JSONL 天然适合流式写入、崩溃后也不丢。Codex 的全屏记录吃到了这个格式的另一个好处：历史只增不改，界面就可以只加载首屏，往回翻时再按页从 rollout 读更早的项，而不必一开始就把整段会话搬进内存。

Grok Build 在[渲染管线与 scrollback](https://daiw.net/manual/grok-build/render-pipeline)里选择保留全部历史块、自己做虚拟滚动，锚点记“第几块、第几逻辑行、第几子行”。Codex 同时保留了两条路：全屏模式与之类似，只是锚点落在源码字节偏移上，并且历史按页取回；回滚模式则把定稿的行交给终端，代价是改宽度时要整段重写。

---

上一篇：[历史记录单元 · 流式 Markdown 与终端排版](https://daiw.net/manual/codex-source/history-cells) · 下一篇：[其它界面 · 登录引导、恢复选择、代理总览与用量面板](https://daiw.net/manual/codex-source/tui-surfaces)
