1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-10-02

シリーズ記事

はじめに

ターミナル 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 参考リンク


1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?