yoi/tickets/tui-fullscreen-overhaul.md

168 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# TUI: フルスクリーン化によるオーバーホール
## 背景
現在の TUI は **ratatui の inline viewport + `insert_before`** で動いている:
- `draw()` が描くのは画面下部の 3 行固定 (separator / status / input)
- 履歴は `terminal.insert_before()` でその上に押し出し、ターミナル側のスクロールバックに残す
- 一度 `insert_before` した行はアプリから書き換え不可
このモデルが以下の構造的課題を生んでいる:
1. **Input UX が貧弱**: Input は 1 行固定、複数行入力不可、ペーストすると長文が 1 行として流れる
2. **リサイズでセパレーターが増殖**: ターミナル横幅が変わるたびに separator 行が再生成されてスクロールバックに流れ、履歴が汚れる
3. **読み返し辛い**: TUI アプリ自身にスクロール機能がなく、ターミナル側スクロールバックに完全依存。折りたたみもフィルタリングもできない
4. **ツール呼び出しが断片化**: `ToolCallStart` / `ToolCallDone` / `ToolResult` がそれぞれ別行として積まれ、1 呼び出しを連続した 1 ブロックとして見られない。`ToolCallArgsDelta` は **破棄されている** (`crates/tui/src/app.rs`)
個別対症療法では直しきれないため、**レンダリングモデルを alternate screen buffer + 全描画保持に切り替えるオーバーホール**として扱う。旧 `tui-tool-call-ui.md` は inline viewport 維持を前提にした設計だったため、本チケットに要件を吸収して削除する。
## 方針
- ratatui を alternate screen buffer で初期化し、inline viewport を捨てる
- 全履歴を TUI アプリ内の state として保持、毎フレーム再描画
- ターミナル側のスクロールバックには何も流さない(リサイズ時の汚染と永続履歴依存を同時に解消)
- 履歴の見せ方をツール呼び出しやターン単位で集約可能にし、スクロール + 折りたたみ 3 段階で密度を変えられるようにする
- ツール呼び出しは 1 呼び出し = 1 ブロック。ツール名で dispatch する **ツール毎レンダラ** のフレームワークを持つ
- Input は複数行 / CJK / ペースト プレースホルダに対応
## 要件
### レンダリングモデル
- ratatui を alternate screen buffer で起動する。`insert_before` は使わない
- アプリ終了時はスクロールバックに戻るTUI が描いたものは残らない)
- 再接続時は既存の `Event::History` で state を再構築する
- ウィンドウリサイズは state を保ったまま再レイアウトする(セパレーター増殖等は発生しない)
### レイアウト
- 画面全体を以下で構成する:
- 上部: 履歴ビュー(可変高さ、スクロール可)
- 下部: ステータス行 (1 行) + Input エリア (可変高さ)
- Input エリアが画面下部を占有しすぎないように上限を設ける(設計で決めること)
### 履歴モデル
- 最小単位は **ブロック**。種類: GreetingCard / TurnHeader / UserMessage / AssistantText / ToolCall / Notification / CompactEvent / TurnStats
- ブロックは **ターン** にグループ化される。TurnHeader / UserMessage 〜 その turn の TurnStats までが 1 ターン
- 履歴全体が state に保持され、モードに応じて各ブロックの見た目が変わる
### スクロール
- 行単位 / ページ単位のスクロール
- ターン単位のジャンプ(前のターン / 次のターン / 先頭 / 末尾)
- 末尾追従(常に最新を表示)は新規イベント到着でトリガ。ユーザーが手動で上にスクロールしている間は追従を停止
### 折りたたみモード
3 段階、全体トグルで切替。
- **detail**: 全ブロックを完全表示。ツールブロックは引数ストリーミング + 結果全体
- **normal**: 実行中のツールブロックは detail と同じ。完了後は各ブロックが概ね 5〜6 行に収まるよう圧縮
- **overview**: 各ブロックが 1 行。ツールブロックは `ToolResult.summary` をそのまま使う
### Input エリア
- 複数行、自動折り返し
- CJK 含む Unicode 表示幅に基づく正しいカーソル位置 (`unicode-width`)
- **ペースト プレースホルダ**: クリップボードから貼り付けられた文字列はプレースホルダ `[Clipboard #N | X chars, Y lines]` として入力バッファに挿入される。実テキストは裏で保持
- プレースホルダは不可分Backspace 1 回で全体が削除される。途中カーソルでの文字単位削除は不可)
- 送信時にプレースホルダが実テキストに展開されて Pod に送られるPod 側には `#N` は見せない)
- 番号付けの規則は設計で決めること
- 既存キー操作(カーソル移動・削除)は複数行対応に拡張
### ツール UI フレームワーク
- ツール呼び出し 1 回 = 1 ブロック。`tool_use_id` で同一ブロックに集約
- 各ブロックは以下の状態を遷移する:
- **Pending**: `ToolCallStart` 受信、args 未確定
- **Streaming**: `ToolCallArgsDelta` を連結中。ライブ反映
- **Executing**: `ToolCallDone` 受信、args 確定、結果待ち
- **Done / Error**: `ToolResult` 受信
- **Incomplete**: `ToolResult` が来ないまま turn が終わった場合
- ブロックの見た目は **ツール毎のレンダラ** が決める。ツール名で dispatch、マッチしなければデフォルトレンダラ
- 並列ツール呼び出しは複数ブロックが同時に存在する。`tool_use_id` で振り分け
- Streaming 中の args は生の文字列として連結し、途中の JSON を整形しようとしない(破綻する)
### 組み込みツールのレンダラ
実装対象: Read / Write / Edit / Glob / Grep / default。
- **Read**: 同一 turn 内で連続する Read 呼び出しを **1 ブロックに集約**する。normal / detail とも「読んだファイル数 + ファイルパスのリスト」を表示、中身は出さない。集約中のライブ表示は実行順に最大 3 行のスクロールウィンドウで読んだファイルを下から追加
- **Write**: summary (Created / Overwrote をラベル色で区別) + 書き込まれた content の先頭 5 行。detail では content 全体
- **Edit**: TUI 側 **ファイル content キャッシュ** から該当ファイルを引き、`args.old_string` / `args.new_string` の置換箇所に対して **前後 3 行の unified diff** を赤 / 緑で表示
- **Glob**: `ToolResult.output` の先頭数行をそのまま表示
- **Grep**: 同上
- **default (未知ツール)**: normal で pretty JSON 化した args の先頭 3 行 + `ToolResult.output` の先頭 3 行。detail では全体
### ファイル content キャッシュ
Edit レンダラが diff を出すために TUI 側に持つ。責務は表示用で、ツール実装層の policy (`Tracker`) とは独立。
- Read レンダラが `ToolResult.output` からキャッシュに content を保存
- Write レンダラが `args.content` でキャッシュを更新
- Edit レンダラが `args.old_string` / `args.new_string` でローカル置換してキャッシュを更新
- `Event::History` 再生時も同じ順序でキャッシュを再構築する
### 既存機能の移植
以下は現行 TUI に既にある機能で、新アーキテクチャでも保持する:
- Greeting カード表示
- TurnHeader / UserMessage / AssistantText
- Notification (Warn / Error レベル)
- Compact 開始 / 完了 / 失敗
- ターン終了時の統計 (requests / tokens)
- Ctrl-C 2-tap でのアプリ終了 (Pod 自体は存続)
- shutdown_confirm 挙動
- Paused 状態での空 Enter → Resume
- `Event::History` によるセッション再接続時の履歴復元
### 履歴再生
- `Event::History` を受けたとき、過去のツール呼び出しは **最終状態のブロック** として履歴モデルに組み直される
- tool_call と tool_result の対応付け、および content キャッシュの再構築もこの経路で行う
### イベント欠落耐性
- プロバイダ起因でイベントが欠落したり順序が逆転しても panic しない
- `ToolResult` が来ないまま `TurnEnd` が来た場合、該当ブロックは Incomplete として残す(握りつぶさず視覚化する)
## 設計で決めること
- **キーバインド**: スクロール / ターン移動 / モード切替 のキー割り当て
- **折りたたみの粒度**: 全体トグルのみか、ターン単位の個別開閉も持たせるか
- **Input エリアの高さ上限**: 画面の N% か絶対行数か
- **ペースト番号付け**: TUI 起動中で通しか turn 毎にリセットするか
- **normal モードの圧縮基準**: 5〜6 行は目安。ブロック種別毎の実装上の判断基準
- **末尾追従の解除条件**: 上にスクロールした瞬間に解除するか、数行離れたら解除するか
- **Read 集約の切れ目**: 「連続」の定義(別ツール呼び出しや assistant text が挟まったら切る / `ToolResult` の受信順で切る 等)
## 前提
- `tickets/protocol-tool-result-shape.md`: `Event::ToolResult``summary: String` が追加されていること
## 完了条件
- TUI が alternate screen buffer で起動し、アプリ終了でスクロールバックに何も残らない
- ウィンドウリサイズで separator の増殖が発生しない
- 既存機能 (greeting / turn / user / assistant / tool / notification / compact / stats / ctrl-c / paused / history) がすべて保持されている
- 3 段モード (detail / normal / overview) で履歴の密度を切り替えられる
- Input が複数行 / CJK / ペースト プレースホルダに対応している
- ツール呼び出しが 1 ブロックとして表示され、start → args streaming → done → result の遷移が同じブロックの更新として見える
- 組み込み 5 ツール (Read / Write / Edit / Glob / Grep) がそれぞれ専用レンダラで表示される
- 未知ツールがデフォルトレンダラで表示される
- 並列ツール呼び出しで `tool_use_id` が正しく振り分けられる
- `ToolResult` が欠落したまま turn が終わった場合に該当ブロックが Incomplete として履歴に残り、panic しない
- `Event::History` 再生で過去のツール呼び出しがブロックとして復元され、Edit レンダラ用の content キャッシュも再構築される
## 範囲外
- ツール実行のキャンセル / 介入 UI
- 複数 Pod の spawn / 切替 UI (`tickets/tui-pod-spawn-ui.md`)
- Markdown レンダリング / シンタックスハイライト / リッチ diff レンダリング
- スクロールバック保存(仕様上出さない)
- TUI 独自の履歴永続化(再接続時は Pod 側の `Event::History` に任せる)
- 組み込みツールの `ToolOutput.content` 構造化 (protocol 拡張はスコープ外。必要になったら別チケット)