Skip to content

チャットコンポーネントとレンダリング

源码版本rust-v0.145.0

ChatWidget は TUI のメインパネルだ:transcript(確定済み履歴 cells)、ストリーミング stream controller、底部の BottomPane(入力欄 + ポップアップスタック)、そして多数の feature ごとの状態(token、rate limit、MCP startup、skills など)を保持する。直接ターミナルに書き込むのではなく、内部状態を Renderable ツリーに折り畳んで Tui::draw に渡す。ratatui を実際に呼ぶのは Tui 層だ。

責務

  1. transcript + 底部パネルを Renderable ツリーに組み立てる:as_renderableFlexRenderable で active cell、hook cell、token activity、底部パネルをウェイト順に積む (codex-rs/tui/src/chatwidget/rendering.rs:6-58)。
  2. ストリーミング agent 出力の処理:StreamControllerStreamCore を包み、push delta、commit tick、finalize をここで行う。stream 終了時に断片的な AgentMessageCell を一つの AgentMarkdownCell に統合し、元 markdown から再レンダリングする (codex-rs/tui/src/streaming/controller.rs:475-529)。
  3. Markdown レンダリング:append_markdown / append_markdown_agent が markdown テキストを unwrap_markdown_fences(外側の ```md 包みを剥がしてテーブルを正しく描画)を経由して pulldown-cmark に渡す (codex-rs/tui/src/markdown.rs:36-69)。
  4. Diff レンダリング:DiffSummaryFileChange(Add / Delete / Update)をパス順に並べ、増減行数を集計し、シンタックスハイライトを加えて Box<dyn Renderable> として出力する (codex-rs/tui/src/diff_render.rs:298-349)。
  5. 底部パネルスタック:BottomPaneChatComposerview_stack を持ち、全ての popup(approval、slash command、file search、settings)は BottomPaneView trait の実装だ (codex-rs/tui/src/bottom_pane/mod.rs:217-252)。

設計動機

codex は ratatui の StatefulWidget パターンを使わず、独自の Renderable trait を作った。理由は二つ。一つは ratatui の Widget には render 前に desired_height を知る方法がなく、TUI は inline viewport(全画面 alt-screen ではない)ため、高さを先に計算して何行スクロールするかを決める必要があるからだ。もう一つはストリーミング出力が「先に scrollback に commit してから続きを描く」二段階レンダリングを必要とし、Renderable ツリーは先に active cell の desired_height を算出してから render するのを自然にサポートする。

StreamController が存在するのは、LLM 出力が token ストリームで、markdown テーブルは完全な表頭 + 区切り行が揃って初めてレンダリングできるからだ。StreamCoreTableHoldbackScanner を維持し、delta をスキャンして潜在的な表頭を見つけたら後続の内容を holdback し、テーブルかどうか確定するまで保留する。同時に StablePrefixLen(表頭前の安定プレフィックスのレンダリング行数)をキャッシュし、毎 delta で全段落を再レンダリングするのを避ける。これは性能と正確性の妥協だ——毎フレーム全量再レンダリングは長い出力で詰まる。

unwrap_markdown_fences は面白いハックだ:LLM はよくテーブルを ```markdown で包んでコードブロックにするが、pulldown-cmark はそれをコードブロックとして等幅フォントで描画してしまう。codex はレンダリング前にこの fence をスキャンし、fence info が md/markdown かつ body に表頭区切り行がある場合だけ外側の fence を剥がす。他の fence(rust、sh など)はそのまま残す。

BottomPaneview_stack が単一選択ではなくスタックなのは、approval、slash、file search がネストできるからだ——slash command が popup を出した後に approval が発火するなど。スタックトップの view がキーイベントを受け取り、完了したら pop する。composer は常に一番下に置かれ、入力状態を失わない。

主要ファイル

codex-rs/tui/src/chatwidget.rs:533-619ChatWidget 構造体のフィールド。stream controller から MCP startup status まで全部入っている。codex-rs/tui/src/chatwidget/rendering.rs:6-58as_renderable。widget 内部状態を FlexRenderable ツリーに折り畳む。codex-rs/tui/src/streaming/controller.rs:475-529StreamControllerpush / finalize / on_commit_tick インターフェース。codex-rs/tui/src/markdown.rs:36-69append_markdown / append_markdown_agent の入口。codex-rs/tui/src/markdown_render.rs:291-333render_markdown_text_with_width_and_cwd 系関数。実際に pulldown-cmark を呼ぶ場所。codex-rs/tui/src/diff_render.rs:298-349DiffSummaryFileChangeRenderable 実装。codex-rs/tui/src/bottom_pane/mod.rs:217-252BottomPane が composer + view_stack + ステータス行を保持。codex-rs/tui/src/bottom_pane/bottom_pane_view.rs:19-60BottomPaneView trait。全 popup の契約。codex-rs/tui/src/diff_model.rs:8-22FileChange 列挙型。Add / Delete / Update の最小データモデル。codex-rs/tui/src/history_cell/mod.rs:191-234HistoryCell trait。transcript の各レコードのインターフェース。

as_renderable はコンポーネントツリーを Renderable に折り畳む。コードは短いが情報密度が高い——active cell、hook cell、token activity、rate limit hint、底部パネルがそれぞれ flex に push される。ウェイト 1 はウィンドウに合わせて伸縮し、ウェイト 0 は固定高さだ:

rust
// chatwidget/rendering.rs:26-57 — flex 树组合
let mut flex = FlexRenderable::new();
flex.push(/*flex*/ 1, active_cell_renderable);
flex.push(/*flex*/ 0, active_hook_cell_renderable);
if let Some(cell) = self.pending_token_activity_output() {
    flex.push(/*flex*/ 1, RenderableItem::Owned(Box::new(TranscriptAreaRenderable { ... })));
}
// ...
flex.push(/*flex*/ 0, self.bottom_pane
    .as_renderable_with_composer_right_reserve(active_cell_right_reserve)
    .inset(Insets::tlbr(/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0)));

StreamController::finalize(Option<Box<dyn HistoryCell>>, Option<String>) を返す——最初はまだ commit されていない末尾の cell、二つ目は生の markdown ソース。後者は AppEvent::ConsolidateAgentMessage に送られ、トップレベルが適切なタイミングで段落全体を再レンダリングする。ストリーミング時の分段 markdown 境界でテーブルが切れるのを避けるためだ:

rust
// streaming/controller.rs:514-524 — finalize 双返回
pub(crate) fn finalize(&mut self) -> (Option<Box<dyn HistoryCell>>, Option<String>) {
    let (remaining, source) = self.core.finalize_remaining();
    if source.is_empty() {
        self.core.reset();
        return (None, None);
    }
    let out = self.emit(remaining);
    self.core.reset();
    (out, Some(source))
}

DiffSummaryBox<dyn Renderable> に変換する時はパス順に並べ、各 change は最初にパス + 増減行数を一行出し、その後 2 列インデントで diff 本体を描く。FileChange::Updateunified_diff フィールドを使い、render_change 内部でシンタックス検出器(detect_lang_for_path)がコードにハイライトを付ける:

rust
// diff_render.rs:323-348 — DiffSummary 转 Renderable
impl From<DiffSummary> for Box<dyn Renderable> {
    fn from(val: DiffSummary) -> Self {
        let mut rows: Vec<Box<dyn Renderable>> = vec![];
        let mut changes: Vec<_> = val.changes.into_iter().collect();
        changes.sort_by(|left, right| left.0.cmp(&right.0));
        for (i, (path, change)) in changes.into_iter().enumerate() {
            if i > 0 { rows.push(Box::new(RtLine::from(""))); }
            let (added, removed) = line_counts(&change);
            let mut path = RtLine::from(display_path_for(&path, val.cwd.as_path()));
            path.push_span(" ");
            path.extend(render_line_count_summary(added, removed));
            // ...
        }
        Box::new(ColumnRenderable::with(rows))
    }
}

データフロー

境界と失敗

  • stream finalize は scrollback reflow を発火:ConsolidationScrollbackReflow::Required の場合、cell は直接 history に入らず AppEvent::ConsolidateAgentMessage の処理まで遅延される。live tail を確定済み markdown cell に編入する必要があるからだ (codex-rs/tui/src/chatwidget/streaming.rs:22-65)。
  • stale width でストリーミングレンダリングがズレる:StreamController 作成時に取った width は resize 後に古くなり、app 層の resize reflow で修復が必要。さもないとストリーミング tail は次の再配置まで旧 viewport 幅で折り返し続ける (codex-rs/tui/src/streaming/controller.rs:481-494)。
  • unwrap_markdown_fencesmd/markdown fence だけ剥がす:他の言語 fence(rustsh)はコードブロックとしてそのまま残す。また body に表頭区切り行がある時だけ剥がし、誤判定を避ける (codex-rs/tui/src/markdown.rs:13-22)。
  • BottomPane の composer は失われない:view_stack のトップに popup があっても composer 状態は底に保持され、popup が pop した後は入力欄が元の内容に戻る (codex-rs/tui/src/bottom_pane/mod.rs:218-223)。
  • FileChange は最小データモデル:Add / Delete / Update の三つだけで、Update は構造化 hunk ではなく unified_diff 文字列を使う。parser は core 層にある (codex-rs/tui/src/diff_model.rs:8-22)。

まとめ

ChatWidget は「transcript + ストリーミング + 底部パネル」を一つの Renderable ツリーに束ねて TUI メインループ に描かせる。本当の markdown / diff レンダリングロジックは markdown_renderdiff_render の各サブモジュールに分散する。ストリーミング出力は StreamController とテーブル holdback で遅延と正確性を平衡させる。app-server から押し込まれたイベントがどう widget 状態になるかは App-server アーキテクチャ に続く。