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

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

作者 David更新于 第 47 篇(共 57 篇)

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

上一篇说到,定稿的历史单元进了 App 的 transcript_cells。它们最终画在哪里,取决于 TUI 用哪一种模式放历史,这也是整个终端界面分叉最大的地方。

用户看到的样子

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

两种模式在启动时定下来

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

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,提示重启后生效。

图表加载中…

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

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

            // 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 自己不存历史,只存阅读与选择状态和有界的排版缓存。它最核心的设计是锚点:

#[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)是增量的字面搜索,查询和正文都转小写后比较。它不在按键时一口气搜完,而是每画一帧推进一点:

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》对照

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

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


上一篇:历史记录单元 · 流式 Markdown 与终端排版 · 下一篇:其它界面 · 登录引导、恢复选择、代理总览与用量面板

本页目录