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 の Line と Span が必要です。
変換の流れ:
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 次のステップ
- Ratatui Book でさらなる Widget パターンを習得
- cargo-insta Documentation でスナップショットテストを極める
Discussion