📑

Codex CLI 完全ガイド: TUI 開発とスナップショットテスト

に公開

シリーズ記事

はじめに

ターミナル UI の開発は難しい──これは事実です。

通常の GUI なら、変更を保存して F5 を押せば結果がすぐ見えます。しかし TUI では、レイアウトの崩れやカラーの問題が実行してみないと分からず、しかも println! デバッグすら画面を壊してしまいます。

そこで登場するのがスナップショットテストです。

Codex CLI の TUI は、Ratatui フレームワークと cargo-insta スナップショットテストの組み合わせにより、UI の変更を安全かつ効率的に管理しています。本記事では、その内部構造と実践的な開発ワークフローを詳解します。

⚠️ 注意: 本記事の内容は執筆時点(2025年1月時点)のものです。最新の実装は公式リポジトリをご確認ください。


1. なぜ TUI は難しいのか?

1.1 TUI 開発の 3 つの課題

課題 1: デバッグの困難さ

// ❌ これは動かない
fn render_widget(area: Rect, buf: &mut Buffer) {
    println!("Debug: area = {:?}", area);  // 画面が壊れる
    
    let text = "Hello";
    buf.set_string(area.x, area.y, text, Style::default());
}

なぜダメか?

println! は標準出力に書き込みますが、TUI は標準出力を 描画キャンバス として使用しています。デバッグ出力が画面に混ざり、レイアウトが崩壊します。

正しい方法

// ✅ ファイルロギング
use tracing::info;

fn render_widget(area: Rect, buf: &mut Buffer) {
    info!("Debug: area = {:?}", area);  // ~/.codex/logs/tui.log に出力
    
    let text = "Hello";
    buf.set_string(area.x, area.y, text, Style::default());
}

課題 2: テストの複雑さ

GUI なら、スクリーンショット比較で回帰テストができます。TUI は?

// テキストベースのレンダリング結果をどう検証する?
let widget = MyWidget::new();
widget.render(area, buf);
// buf の内容をどうテストする?

解決策: スナップショットテスト(後述)

課題 3: 端末環境の多様性

端末 色数 特殊文字 フォント
macOS Terminal 256色 Monospace
iTerm2 True Color カスタマイズ可能
Windows Terminal True Color Cascadia Code
Linux コンソール 16色 固定
tmux 256色 ⚠️ 設定依存

Codex の対策

  • カスタムカラーを避ける(テーマ互換性)
  • ANSI 標準色のみ使用
  • フォールバック機能の実装

1.2 Ratatui が解決すること

Ratatui は、これらの課題に対する Rust らしい解決策を提供します:

主な特徴

即座の再描画: フレームベースのレンダリング
Widget ベース: 再利用可能な UI コンポーネント
バックエンド非依存: Crossterm, Termion などに対応
型安全: Rust の強力な型システムを活用


2. Codex TUI のアーキテクチャ

2.1 ディレクトリ構造

2.2 イベント駆動アーキテクチャ

Codex TUI は イベント駆動 で動作します:

イベントループの実装app.rs より):

pub async fn run(
    tui: &mut tui::Tui,
    auth_manager: Arc<AuthManager>,
    config: Config,
    // ...
) -> Result<AppExitInfo> {
    // イベントチャネル作成
    let (app_event_tx, mut app_event_rx) = unbounded_channel();
    let app_event_tx = AppEventSender::new(app_event_tx);
    
    // Codex エンジンとチャット Widget を初期化
    let conversation_manager = Arc::new(
        ConversationManager::new(auth_manager.clone())
    );
    let chat_widget = ChatWidget::new(/* ... */);
    
    let mut app = Self {
        server: conversation_manager,
        chat_widget,
        // ...
    };
    
    // TUI イベントストリーム
    let tui_events = tui.event_stream();
    tokio::pin!(tui_events);
    
    // 初回描画リクエスト
    tui.frame_requester().schedule_frame();
    
    // **メインループ**: tokio::select! で2つのイベント源を監視
    while select! {
        // アプリケーションイベント
        Some(event) = app_event_rx.recv() => {
            app.handle_event(tui, event).await?
        }
        // TUI イベント(キー入力、描画要求)
        Some(event) = tui_events.next() => {
            app.handle_tui_event(tui, event).await?
        }
    } {}
    
    // 終了時の統計情報を返す
    Ok(AppExitInfo {
        token_usage: app.token_usage(),
        conversation_id: app.chat_widget.conversation_id(),
    })
}

💡 なぜ tokio::select! を使うのか?

2つのイベント源を 並行して 監視できます:

  • ユーザーがキーを押す → すぐに反応
  • Codex からストリーミング応答 → リアルタイムに表示

イベント型定義app_event.rs より):

pub enum TuiEvent {
    /// キーボード入力
    Key(KeyEvent),
    
    /// ペースト操作(Ctrl+V など)
    Paste(String),
    
    /// 再描画要求
    Draw,
}

pub enum AppEvent {
    /// 新しいセッション開始
    NewSession,
    
    /// 履歴セルの追加(メッセージ、実行結果など)
    InsertHistoryCell(Box<dyn HistoryCell>),
    
    /// コミットアニメーション開始
    StartCommitAnimation,
    
    /// コミットアニメーション停止
    StopCommitAnimation,
    
    /// アニメーションフレーム更新
    CommitTick,
    
    /// Codex エンジンからのイベント
    CodexEvent(Event),
    
    /// 会話履歴(バックトラック用)
    ConversationHistory(/* ... */),
    
    /// 終了リクエスト
    ExitRequest,
    
    /// Codex への操作送信
    CodexOp(Op),
    
    /// Diff 結果表示
    DiffResult(String),
    
    /// ファイル検索開始
    StartFileSearch(String),
    
    /// ファイル検索結果
    FileSearchResult { query: String, matches: Vec<PathBuf> },
    
    // ... その他多数
}

3. スタイルガイド:美しく一貫した UI

3.1 なぜスタイルガイドが重要か?

問題のあるコード

// 開発者 A
let title = "Status".blue().bold();

// 開発者 B  
let status = "Running".yellow().italic();

// 開発者 C
let error = "Failed".custom_color(Rgb(255, 100, 50));

結果:統一感のない、テーマ互換性のない UI

Codex のアプローチstyles.md で明確なルールを定義

3.2 公式スタイルガイド

ヘッダーとテキスト階層

// ヘッダー: bold を使用
let header = "# Section Title".bold();

// プライマリテキスト: デフォルト(スタイル指定なし)
let primary = "This is the main content";

// セカンダリテキスト: dim を使用
let secondary = "Additional information".dim();

前景色のセマンティクス

用途 ANSI カラー 実装例
デフォルト - "Normal text"
ユーザー入力/選択 cyan "Press Enter to continue".cyan()
成功/追加 green "✓ Test passed".green()
エラー/削除 red "✗ Build failed".red()
Codex (エージェント) magenta "Thinking...".magenta()

実装例

use ratatui::style::Stylize;

// ✅ 推奨
let success = "Complete!".green();
let error = "Failed".red();
let user_input = "Enter command: ".cyan();
let agent = "Let me think...".magenta();
let hint = "Tip: Use Ctrl+C to cancel".dim();

// ❌ 非推奨
let warning = "Warning".yellow();  // スタイルガイドで未使用
let info = "Info".blue();          // スタイルガイドで未使用
let custom = "Text".custom_color(Rgb(123, 45, 67));  // テーマ非互換

避けるべき色

// ❌ これらの色は使用禁止

// black と white: ターミナルテーマに任せる
let bad1 = "Text".black();
let bad2 = "Text".white();

// blue と yellow: 現在のスタイルガイドで未使用
let bad3 = "Text".blue();
let bad4 = "Text".yellow();

// カスタムカラー: テーマ互換性の問題
let bad5 = "Text".fg(Color::Rgb(255, 128, 0));

3.3 Clippy による強制

clippy.toml でスタイルガイド違反を自動検出:

# 非推奨カラーの使用を警告
disallowed-methods = [
    { 
        path = "ratatui::style::Color::Blue", 
        reason = "Use cyan or default instead. See styles.md" 
    },
    { 
        path = "ratatui::style::Color::Yellow", 
        reason = "Not in style guide. Use green for success or red for errors." 
    },
    { 
        path = "ratatui::style::Color::Black", 
        reason = "Let terminal theme handle this. Use reset if needed." 
    },
    { 
        path = "ratatui::style::Color::White", 
        reason = "Let terminal theme handle this. Use reset if needed." 
    },
]

実行

$ cargo clippy -p codex-tui

warning: use of a disallowed method `ratatui::style::Color::Blue`
  --> src/my_widget.rs:42:5
   |
42 |     .fg(Color::Blue)
   |     ^^^^^^^^^^^^^^^^
   |
   = note: Use cyan or default instead. See styles.md

warning: 1 warning emitted

4. ChatWidget: 会話の心臓部

4.1 ChatWidget の責務

ChatWidget は Codex TUI の中核で、以下を管理します:

構造体定義

pub(crate) struct ChatWidget {
    // 設定
    config: Config,
    
    // Codex エンジン
    conversation: Arc<Codex>,
    
    // UI コンポーネント
    bottom_pane: BottomPane,
    composer: TextArea<'static>,
    status_indicator: StatusIndicator,
    
    // 状態管理
    stream_state: StreamState,
    approval_state: Option<ApprovalState>,
    history_cells: Vec<Arc<dyn HistoryCell>>,
    
    // トークン使用量
    token_usage: TokenUsage,
    
    // ... その他
}

4.2 リアルタイムストリーミングの魔法

エージェントからの応答は リアルタイムで レンダリングされます:

実装markdown_stream.rs より):

pub struct MarkdownStream {
    /// 累積されたテキスト
    accumulated_text: String,
    
    /// レンダリング済みの行
    rendered_lines: Vec<Line<'static>>,
    
    /// Markdown パーサー
    parser: Parser<'static, 'static>,
}

impl MarkdownStream {
    pub fn new() -> Self {
        Self {
            accumulated_text: String::new(),
            rendered_lines: Vec::new(),
            parser: Parser::new(""),
        }
    }
    
    /// ストリーミングチャンクを追加
    pub fn push_chunk(&mut self, chunk: &str, width: u16) {
        // 累積
        self.accumulated_text.push_str(chunk);
        
        // 増分パース
        self.parser = Parser::new_ext(
            &self.accumulated_text,
            Options::all()
        );
        
        // 即座に再レンダリング
        self.rendered_lines = markdown_to_lines(&self.parser, width);
    }
    
    pub fn lines(&self) -> &[Line<'static>] {
        &self.rendered_lines
    }
}

💡 なぜ毎回全体を再パースするのか?

Markdown のコンテキスト依存構文(リスト、コードブロックなど)を正しく処理するため、部分パースでは不十分です:

これは`インラインコード`です

```rust
// コードブロック
fn main() {
`​``

↑ バッククォートの数で意味が変わる

4.3 HistoryCell トレイト: 柔軟な履歴管理

会話履歴の各要素は HistoryCell トレイトを実装:

pub trait HistoryCell: Send + Sync {
    /// 指定幅でレンダリング
    fn display_lines(&self, width: u16) -> Vec<Line<'static>>;
    
    /// ストリーミングの継続か?
    fn is_stream_continuation(&self) -> bool {
        false
    }
    
    /// セルの種類(デバッグ用)
    fn cell_type(&self) -> &'static str {
        "unknown"
    }
}

実装例

// ユーザーメッセージ
pub struct UserHistoryCell {
    pub message: String,
}

impl HistoryCell for UserHistoryCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        vec![
            Line::from(vec![
                Span::raw("You: ".dim()),
                Span::raw(&self.message),
            ])
        ]
    }
    
    fn cell_type(&self) -> &'static str {
        "user_message"
    }
}

// エージェントメッセージ
pub struct AgentMessageCell {
    lines: Vec<Line<'static>>,
    is_continuation: bool,
}

impl HistoryCell for AgentMessageCell {
    fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
        self.lines.clone()
    }
    
    fn is_stream_continuation(&self) -> bool {
        self.is_continuation
    }
    
    fn cell_type(&self) -> &'static str {
        "agent_message"
    }
}

// コマンド実行セル
pub struct ExecCommandCell {
    command: String,
    status: ExecStatus,
}

impl HistoryCell for ExecCommandCell {
    fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
        let status_icon = match self.status {
            ExecStatus::Running => "⏳",
            ExecStatus::Success => "✓",
            ExecStatus::Failed => "✗",
        };
        
        vec![
            Line::from(vec![
                Span::raw(status_icon),
                Span::raw(" $ ").cyan(),
                Span::raw(&self.command),
            ])
        ]
    }
    
    fn cell_type(&self) -> &'static str {
        "exec_command"
    }
}

5. Markdown レンダリングエンジン

5.1 なぜカスタムレンダラーが必要か?

pulldown-cmark は Markdown を AST (Abstract Syntax Tree) にパースしますが、HTML 出力しか提供しません。TUI では Ratatui の LineSpan が必要です。

変換の流れ

Markdown テキスト
    ↓ pulldown-cmark
AST (Event のストリーム)
    ↓ markdown_render.rs
Ratatui Line & Span
    ↓ Ratatui
ターミナル出力

5.2 実装の詳細

pub fn markdown_to_lines<'a>(
    parser: &Parser<'a, '_>,
    width: u16,
) -> Vec<Line<'static>> {
    let mut lines = Vec::new();
    let mut current_line = Vec::new();
    let mut in_code_block = false;
    let mut current_style = Style::default();
    
    for event in parser.clone() {
        match event {
            // ───────────────────────────────────
            // ヘッダー
            // ───────────────────────────────────
            Event::Start(Tag::Heading { level, .. }) => {
                // Markdown のヘッダーレベルを保持
                let prefix = "#".repeat(level as usize);
                current_line.push(Span::styled(
                    format!("{} ", prefix),
                    Style::default().bold(),
                ));
                current_style = Style::default().bold();
            }
            
            Event::End(TagEnd::Heading(_)) => {
                // ヘッダー終了
                current_style = Style::default();
                lines.push(Line::from(current_line.clone()));
                current_line.clear();
            }
            
            // ───────────────────────────────────
            // テキスト
            // ───────────────────────────────────
            Event::Text(text) => {
                if in_code_block {
                    // コードブロック内: cyan
                    current_line.push(Span::styled(
                        text.to_string(),
                        Style::default().fg(Color::Cyan),
                    ));
                } else {
                    // 通常テキスト: 現在のスタイルを適用
                    current_line.push(Span::styled(
                        text.to_string(),
                        current_style,
                    ));
                }
            }
            
            // ───────────────────────────────────
            // インラインコード
            // ───────────────────────────────────
            Event::Code(code) => {
                current_line.push(Span::raw("`"));
                current_line.push(Span::styled(
                    code.to_string(),
                    Style::default().fg(Color::Cyan),
                ));
                current_line.push(Span::raw("`"));
            }
            
            // ───────────────────────────────────
            // コードブロック
            // ───────────────────────────────────
            Event::Start(Tag::CodeBlock(_)) => {
                in_code_block = true;
                lines.push(Line::from(current_line.clone()));
                current_line.clear();
            }
            
            Event::End(TagEnd::CodeBlock) => {
                in_code_block = false;
                lines.push(Line::from(current_line.clone()));
                current_line.clear();
            }
            
            // ───────────────────────────────────
            // 強調
            // ───────────────────────────────────
            Event::Start(Tag::Strong) => {
                current_style = current_style.add_modifier(Modifier::BOLD);
            }
            
            Event::End(TagEnd::Strong) => {
                current_style = current_style.remove_modifier(Modifier::BOLD);
            }
            
            Event::Start(Tag::Emphasis) => {
                current_style = current_style.add_modifier(Modifier::ITALIC);
            }
            
            Event::End(TagEnd::Emphasis) => {
                current_style = current_style.remove_modifier(Modifier::ITALIC);
            }
            
            // ───────────────────────────────────
            // 改行
            // ───────────────────────────────────
            Event::SoftBreak | Event::HardBreak => {
                lines.push(Line::from(current_line.clone()));
                current_line.clear();
            }
            
            // ───────────────────────────────────
            // その他
            // ───────────────────────────────────
            _ => {
                // リスト、リンクなどの処理...
            }
        }
    }
    
    // 最後の行を追加
    if !current_line.is_empty() {
        lines.push(Line::from(current_line));
    }
    
    lines
}

5.3 自動折り返し

長い行は textwrap で折り返します:

use textwrap::wrap;

pub fn wrap_lines(lines: Vec<Line>, width: u16) -> Vec<Line<'static>> {
    let mut wrapped = Vec::new();
    
    for line in lines {
        // Line を文字列に変換
        let text: String = line.spans.iter()
            .map(|s| s.content.as_ref())
            .collect();
        
        if text.len() <= width as usize {
            // 折り返し不要
            wrapped.push(line);
        } else {
            // textwrap で折り返し
            let wrapped_texts = wrap(&text, width as usize);
            
            for wrapped_text in wrapped_texts {
                wrapped.push(Line::from(wrapped_text.to_string()));
            }
        }
    }
    
    wrapped
}

折り返しの例

幅: 40文字
入力: "これは非常に長いテキストで、ターミナルの幅を超えています。自動的に折り返されます。"

出力:
これは非常に長いテキストで、ターミ
ナルの幅を超えています。自動的に折
り返されます。

6. スナップショットテストの実践

6.1 なぜスナップショットテストか?

従来のテスト

#[test]
fn test_markdown_render() {
    let markdown = "# Hello\n\nThis is **bold**.";
    let parser = Parser::new(markdown);
    let lines = markdown_to_lines(&parser, 80);
    
    // ❌ 手動で期待値を書くのは大変
    assert_eq!(lines[0].spans[0].content, "# ");
    assert_eq!(lines[0].spans[1].content, "Hello");
    assert!(lines[0].spans[0].style.add_modifier.contains(Modifier::BOLD));
    // ... 数十行続く
}

スナップショットテスト

#[test]
fn test_markdown_render() {
    let markdown = "# Hello\n\nThis is **bold**.";
    let parser = Parser::new(markdown);
    let lines = markdown_to_lines(&parser, 80);
    
    // ✅ 簡潔!
    assert_snapshot!(format!("{:#?}", lines));
}

初回実行で自動的にスナップショットが保存され、以降の実行で比較されます。

6.2 cargo-insta のセットアップ

インストール

cargo install cargo-insta

依存関係追加Cargo.toml):

[dev-dependencies]
insta = "1.43.2"
pretty_assertions = "1.4.1"

6.3 スナップショットテストの作成

テスト例tests/markdown_render_tests.rs):

use insta::assert_snapshot;
use pulldown_cmark::{Options, Parser};
use codex_tui::markdown_render::markdown_to_lines;

/// すべてのテストで使う共通レンダラ。
/// - 先頭の余計な改行を削除
/// - 改行コードを LF に正規化
/// - pulldown_cmark のオプションを固定
/// - `lines` のシリアライズ方法を統一(`Debug` で1行ずつ)
fn render(markdown: &str, width: usize) -> String {
    let normalized = markdown
        .trim_start_matches('\n')
        .replace("\r\n", "\n")
        .replace('\r', "\n");

    let mut opts = Options::empty();
    // 必要なオプションのみ固定して差分を減らす(必要に応じて調整)
    opts.insert(Options::ENABLE_STRIKETHROUGH);
    opts.insert(Options::ENABLE_TABLES);
    opts.insert(Options::ENABLE_TASKLISTS);
    opts.insert(Options::ENABLE_FOOTNOTES);

    let parser = Parser::new_ext(&normalized, opts);

    // `markdown_to_lines` の戻りが Vec<Line> を想定
    let lines = markdown_to_lines(&parser, width);

    // すべてのテストで同じシリアライズに統一
    lines
        .iter()
        .map(|line| format!("{:?}", line))
        .collect::<Vec<_>>()
        .join("\n")
}

#[test]
fn test_headings() {
    let markdown = r#"
# H1 Header
## H2 Header
### H3 Header
"#;

    let rendered = render(markdown, 80);
    assert_snapshot!("headings_render", rendered);
}

#[test]
fn test_code_block() {
    let markdown = r#"
Inline `code` and block:

```rust
fn main() {
    println!("Hello");
}
`​``
"#;

    let rendered = render(markdown, 80);
    assert_snapshot!("code_block_render", rendered);
}

#[test]
fn test_emphasis() {
    let markdown = "This is **bold** and *italic* text.";

    let rendered = render(markdown, 80);
    assert_snapshot!("emphasis_render", rendered);
}

/*
補足:
- 以前はテストごとにシリアライズ方法(`format!("{:#?}", lines)` と 1行ずつ `Debug`)が異なるため、
  スナップショット差分が発生しやすかった問題を解消しています。
- 先頭改行や CRLF を正規化して、OS/エディタ差の影響を最小化しています。
- `Parser::new_ext` + 固定 Options で、機能フラグ差による出力ブレを抑制しています。

使い方:
- 初回は `cargo insta review` でスナップショットを確定してください。
- その後の変更で不一致が出た場合は、意図通りなら再度 `cargo insta review` で更新、
  意図しない場合は実装/テストを見直してください。
*/


6.4 スナップショットの承認フロー

1. 初回実行

$ cargo test -p codex-tui test_headings

running 1 test
test test_headings ... ok

スナップショットファイルが自動生成:

tests/snapshots/markdown_render_tests__headings.snap

2. コード変更後の実行

$ cargo test -p codex-tui test_headings

running 1 test
test test_headings ... FAILED

failures:

---- test_headings stdout ----
Snapshot does not match. Run `cargo insta review` to review changes.

failures:
    test_headings

3. 差分確認

$ cargo insta pending-snapshots -p codex-tui

Pending snapshots:
  - markdown_render_tests__headings (changed)
    tests/snapshots/markdown_render_tests__headings.snap

4. レビュー

$ cargo insta review -p codex-tui

インタラクティブなレビュー画面:

Reviewing 1 snapshot(s):

test_headings
────────────────────────────────────────
OLD:
  Line { spans: [Span { content: "# ", style: BOLD }] }
  
NEW:
  Line { spans: [Span { content: "## ", style: BOLD }] }

────────────────────────────────────────
[a]ccept [r]eject [s]kip [q]uit: 
  • a: 変更を承認
  • r: 変更を拒否(テストは失敗のまま)
  • s: スキップして次へ
  • q: 終了

5. 承認

$ cargo insta accept -p codex-tui

Accepting 1 snapshot(s):
  ✓ markdown_render_tests__headings

7. 実践的な開発ワークフロー

7.1 新しい Widget の追加

Step 1: Widget 構造定義

// src/widgets/my_widget.rs
use ratatui::widgets::{Widget, Block, Borders};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;

pub struct MyWidget {
    pub title: String,
    pub items: Vec<String>,
    pub selected: usize,
}

impl Widget for MyWidget {
    fn render(self, area: Rect, buf: &mut Buffer) {
        // 枠線を描画
        let block = Block::default()
            .borders(Borders::ALL)
            .title(self.title.bold().cyan());
        
        let inner = block.inner(area);
        block.render(area, buf);
        
        // アイテムをレンダリング
        for (i, item) in self.items.iter().enumerate() {
            if i >= inner.height as usize {
                break;
            }
            
            let y = inner.y + i as u16;
            
            if i == self.selected {
                // 選択中: 背景色を変更
                buf.set_string(
                    inner.x,
                    y,
                    format!("> {}", item),
                    Style::default().bg(Color::DarkGray).cyan(),
                );
            } else {
                buf.set_string(
                    inner.x,
                    y,
                    format!("  {}", item),
                    Style::default(),
                );
            }
        }
    }
}

Step 2: テスト作成

// tests/my_widget_tests.rs
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use insta::assert_snapshot;

#[test]
fn test_my_widget_basic() {
    let backend = TestBackend::new(40, 10);
    let mut terminal = Terminal::new(backend).unwrap();
    
    terminal.draw(|frame| {
        let widget = MyWidget {
            title: "My Widget".to_string(),
            items: vec![
                "Item 1".to_string(),
                "Item 2".to_string(),
                "Item 3".to_string(),
            ],
            selected: 1,
        };
        
        frame.render_widget(widget, frame.area());
    }).unwrap();
    
    // バッファの内容をスナップショット
    let buffer = terminal.backend().buffer().clone();
    assert_snapshot!(format!("{:?}", buffer));
}

Step 3: スナップショット承認

cargo test -p codex-tui test_my_widget_basic
cargo insta review -p codex-tui
cargo insta accept -p codex-tui

7.2 既存 Widget の変更

シナリオ: ヘッダーのスタイルを変更

Before:

let header = format!("# {}", title);
Line::from(header.bold())

After:

let header = format!("## {}", title);  // H1 → H2
Line::from(header.bold())

ワークフロー

# 1. コード変更
$ vim src/markdown_render.rs

# 2. テスト実行
$ cargo test -p codex-tui
# 差分が検出される

# 3. 差分確認
$ cargo insta pending-snapshots -p codex-tui
Pending snapshots:
  - markdown_render_tests__headings (changed)

# 4. レビュー
$ cargo insta review -p codex-tui
# 差分を確認して accept/reject

# 5. 承認
$ cargo insta accept -p codex-tui

7.3 CI/CD での自動化

GitHub Actions 例

name: TUI Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Install Rust
        uses: dtolnay/rust-toolchain@stable
      
      - name: Cache cargo
        uses: actions/cache@v3
        with:
          path: |
            ~/.cargo/bin/
            ~/.cargo/registry/
            target/
          key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
      
      - name: Install cargo-insta
        run: cargo install cargo-insta --locked
      
      - name: Run tests
        run: cargo test -p codex-tui
      
      - name: Check for uncommitted snapshots
        run: |
          # 未コミットのスナップショット変更を検出
          if cargo insta pending-snapshots -p codex-tui | grep "Pending"; then
            echo "❌ Found uncommitted snapshot changes"
            echo "Run: cargo insta review -p codex-tui"
            exit 1
          else
            echo "✅ All snapshots are up to date"
          fi

8. デバッグテクニック

8.1 ファイルロギング

use tracing::{info, debug, warn, error};

fn render_widget(area: Rect, buf: &mut Buffer) {
    // ~/.codex/logs/tui.log に出力
    info!("Rendering widget at area: {:?}", area);
    debug!("Buffer size: {}x{}", buf.area.width, buf.area.height);
    
    // レンダリング処理
    // ...
    
    if some_error {
        error!("Failed to render: {}", error_msg);
    }
}

ログ確認

# リアルタイム監視
$ tail -f ~/.codex/logs/tui.log

# ログレベル設定
$ RUST_LOG=debug codex
$ RUST_LOG=codex_tui=trace codex

8.2 TestBackend による可視化

#[cfg(test)]
use ratatui::backend::TestBackend;

#[test]
fn debug_widget_layout() {
    let backend = TestBackend::new(80, 24);
    let mut terminal = Terminal::new(backend).unwrap();
    
    terminal.draw(|frame| {
        let widget = MyComplexWidget::new();
        frame.render_widget(widget, frame.area());
    }).unwrap();
    
    // バッファ内容を表示
    println!("{}", terminal.backend().buffer());
    
    // または各セルを検証
    let buffer = terminal.backend().buffer();
    assert_eq!(buffer.get(0, 0).symbol(), "┌");
    assert_eq!(buffer.get(1, 0).symbol(), "─");
}

8.3 スナップショット差分の読み方

色付き差分の例

--- old
+++ new
@@ -5,7 +5,7 @@
     spans: [
       Span {
-        content: "Old text",
+        content: "New text",
         style: Style {
           fg: Some(Cyan),
-          add_modifier: BOLD,
+          add_modifier: ITALIC,
         }
       }
     ]

重要なフィールド

  • content: テキスト内容
  • style.fg: 前景色
  • style.bg: 背景色
  • add_modifier: スタイル修飾子(BOLD, ITALIC など)

9. パフォーマンス最適化

9.1 不要な再描画の削減

問題: ストリーミング中に毎回再描画すると CPU 使用率が急上昇

解決: Frame Requester パターン

pub struct FrameRequester {
    tx: UnboundedSender<()>,
    last_request: Arc<Mutex<Instant>>,
}

impl FrameRequester {
    pub fn schedule_frame(&self) {
        let mut last = self.last_request.lock().unwrap();
        let now = Instant::now();
        
        // 前回のリクエストから 16ms (60 FPS) 経過していない場合はスキップ
        if now.duration_since(*last) < Duration::from_millis(16) {
            return;
        }
        
        *last = now;
        let _ = self.tx.send(());
    }
}

使用例

impl ChatWidget {
    fn on_stream_chunk(&mut self, chunk: &str, frame_req: &FrameRequester) {
        // ストリーミングチャンクを追加
        self.stream_state.push_chunk(chunk);
        
        // 再描画リクエスト(自動的にスロットリング)
        frame_req.schedule_frame();
    }
}

9.2 大きな履歴の効率的表示

仮想スクロール

pub struct VirtualList {
    items: Vec<Arc<dyn HistoryCell>>,
    viewport_start: usize,
    viewport_height: usize,
}

impl VirtualList {
    /// 表示範囲のアイテムのみ取得
    pub fn visible_items(&self) -> &[Arc<dyn HistoryCell>] {
        let end = (self.viewport_start + self.viewport_height)
            .min(self.items.len());
        &self.items[self.viewport_start..end]
    }
    
    /// スクロール
    pub fn scroll_down(&mut self, lines: usize) {
        self.viewport_start = (self.viewport_start + lines)
            .min(self.items.len().saturating_sub(self.viewport_height));
    }
    
    pub fn scroll_up(&mut self, lines: usize) {
        self.viewport_start = self.viewport_start.saturating_sub(lines);
    }
}

効果

履歴: 10,000 アイテム
ビューポート: 50 行

従来: 10,000 アイテム全てをレンダリング → 遅い
仮想スクロール: 50 アイテムのみレンダリング → 高速

10. ベストプラクティス集

10.1 スナップショットテストの粒度

// ✅ 良い例:特定の機能をテスト
#[test]
fn test_code_block_syntax_highlighting() {
    let markdown = "```rust\nfn main() {}\n```";
    assert_snapshot!(render_markdown(markdown));
}

#[test]
fn test_heading_levels() {
    let markdown = "# H1\n## H2\n### H3";
    assert_snapshot!(render_markdown(markdown));
}

// ❌ 悪い例:全体を一度にテスト
#[test]
fn test_entire_conversation() {
    let conversation = load_1000_line_conversation();
    assert_snapshot!(conversation);  // 差分が見づらい
}

10.2 決定的な出力

// ✅ 良い例:固定値を使用
#[test]
fn test_timestamped_message() {
    let fixed_time = DateTime::parse_from_rfc3339("2025-01-01T00:00:00Z")
        .unwrap();
    let cell = TimestampedCell::new("Message", fixed_time);
    assert_snapshot!(format!("{:?}", cell));
}

// ❌ 悪い例:現在時刻を使用
#[test]
fn test_timestamped_message_bad() {
    let cell = TimestampedCell::new("Message", Utc::now());
    assert_snapshot!(format!("{:?}", cell));
    // 実行ごとにスナップショットが変わる
}

10.3 読みやすいスナップショット

// ✅ 良い例:整形された出力
assert_snapshot!(format!("{:#?}", widget));

// 出力:
// Widget {
//     title: "Example",
//     items: [
//         "Item 1",
//         "Item 2",
//     ],
// }

// ❌ 悪い例:1行の巨大な出力
assert_snapshot!(format!("{:?}", widget));

// 出力:
// Widget { title: "Example", items: ["Item 1", "Item 2"] }

11. まとめ

11.1 TUI 開発の要点

Ratatui: 型安全なターミナル UI フレームワーク
スタイルガイド: 一貫したカラーとフォーマット
イベント駆動: 非同期イベントループ
Markdown レンダリング: リアルタイムストリーミング対応
スナップショットテスト: UI 変更の安全な管理

11.2 開発フロー

1. スタイルガイドに従って Widget を実装
    ↓
2. スナップショットテストで動作を固定
    ↓
3. コード変更
    ↓
4. cargo test で差分検出
    ↓
5. cargo insta review で差分確認
    ↓
6. accept/reject を判断
    ↓
7. CI で自動検証

11.3 トラブルシューティングチェックリスト

  • スタイルガイド違反? → cargo clippy -p codex-tui
  • レイアウト崩れ? → TestBackend でバッファ確認
  • 色がおかしい? → ANSI 標準色を使用しているか確認
  • テスト失敗? → cargo insta review で差分確認
  • パフォーマンス悪化? → Frame Requester を確認

11.4 次のステップ

11.5 参考リンク


Discussion