From ce7153f6e8f4697f4b340068e742ec17a1a762c9 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 28 Apr 2026 16:23:09 +0900 Subject: [PATCH] =?UTF-8?q?tui-thinking-display=E5=AE=8C=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 1 - tickets/tui-thinking-display.md | 94 -------------------------- tickets/tui-thinking-display.review.md | 80 ---------------------- 3 files changed, 175 deletions(-) delete mode 100644 tickets/tui-thinking-display.md delete mode 100644 tickets/tui-thinking-display.review.md diff --git a/TODO.md b/TODO.md index ac94e92f..eb3a447c 100644 --- a/TODO.md +++ b/TODO.md @@ -20,4 +20,3 @@ - [ ] 使用頻度メトリクス + Knowledge 化候補レポート → [tickets/memory-usage-metrics.md](tickets/memory-usage-metrics.md) - [ ] GC(定期再評価) → [tickets/memory-gc.md](tickets/memory-gc.md) - ワークスペースのメモリーをLintするヘッドレスCLI -- [ ] Thinking ブロックの TUI 表示 → [tickets/tui-thinking-display.md](tickets/tui-thinking-display.md) diff --git a/tickets/tui-thinking-display.md b/tickets/tui-thinking-display.md deleted file mode 100644 index aae3a2bf..00000000 --- a/tickets/tui-thinking-display.md +++ /dev/null @@ -1,94 +0,0 @@ -# Thinking ブロックの TUI 表示 - -## 背景 - -Reasoning(extended thinking)系の対応は llm-worker レイヤまで降りている: - -- Anthropic の `thinking`、OpenAI Responses の `reasoning_text` / `reasoning_summary_text`、Gemini の thinking はいずれも `DeltaContent::Thinking(String)` に正規化され、Timeline 層には `ThinkingBlockKind` の Start / Delta / Stop が流れている -- history 上は `Item::Reasoning { text, summary, encrypted_content, ... }` として保持され、session-store にも persist されている - -ところが上位層への通り道が無い: - -- `Worker` の closure 公開 API には `on_thinking_block` が無い(`on_text_block` / `on_tool_use_block` のみ) -- `protocol::Event` に Thinking 系イベントが無い -- pod controller でブリッジしていない -- TUI に Thinking 用ブロックが無い - -結果として、provider が thinking 平文を返していても TUI からは「無音で時間が過ぎる」状態になる。実行中であることが見えず、終わった後に「どれくらい考えていたか」も残らない。 - -provider ごとに本文を流せるかは異なる: - -- **Anthropic**: extended thinking は平文で流れる -- **OpenAI Responses**: モデル / 設定によって本文(`reasoning_text`)が流れない場合がある。`reasoning_summary` は流れることがある -- **Gemini**: thinking は流れるが provider 設定依存 - -「平文があれば流す。無くても thinking 中であることは見せる」というのが基本方針。 - -## 方針 - -- llm-worker → protocol → pod controller → TUI に Thinking の通り道を作る -- worker は `DeltaContent::Thinking` 由来の本文をそのまま渡す。本文を出さない provider のときは Delta が来ないだけで Start / Done は届く -- TUI は実行中とその後の両方を残す: - - **実行中**: `Thinking... (Xs)` ヘッダ + 本文があれば直近 1 行のライブ表示 - - **終了後**: `Thought for Xs` として履歴に残す。`detail` モードでは累積本文を展開 -- token 数表記は当面入れない(`UsageEvent` に reasoning 分離が無く、別チケットで `Usage` を拡張するまで保留) - -## 要件 - -### Worker API - -- `Worker::on_thinking_block(setup)` を `on_text_block` と対称に追加。setup は per-block で 1 回呼ばれ、`block.on_delta(|text|)` / `block.on_stop(|full_text|)` を登録できる - -### Protocol - -- `Event::ThinkingStart`、`Event::ThinkingDelta { text }`、`Event::ThinkingDone { text }` を追加(`text` には完成形を載せる、`TextDone` と同じ流儀) -- 本文を返さない provider では Delta が 0 件のまま Start → Done が届く(破綻しない) -- 1 turn に複数の thinking block が来る可能性がある(provider 都合)。各ブロックは独立して扱う - -### Pod Controller - -- `worker.on_thinking_block` で上記 3 イベントに変換して `event_tx` に流す - -### TUI - -- 新ブロック種別 `Block::Thinking` を持つ -- 実行中は以下のように表示: - ``` - Thinking... (10s) - <累積本文の末尾を 1 行に切り詰めたもの> - ``` -- 終了後は `Thought for 12s` を残す。`detail` モードでは本文をそのまま展開して読める -- `overview` モードは 1 行(例: `Thought for 12s`) -- 経過時間表示のため、Thinking ブロックが実行中の間は再描画が定期的に走る必要がある(粒度は 1Hz 程度で十分) -- ライブ 1 行の選び方は「累積テキストの最後の改行以降を取り、表示幅で切り詰める」を MVP とする -- 同一 turn 内の複数 thinking block は別ブロックとして表示される - -### イベント欠落耐性 - -- `ThinkingDone` が来ないまま `TurnEnd` が来た場合、対応する `Block::Thinking` は経過時間を凍結した状態で履歴に残す。`ToolCall` 側の `Incomplete` と同じ思想 - -### History 再生 - -- `Event::History` の `Item::Reasoning { text, ... }` を `Block::Thinking { text, finished: true }` として復元する(経過時間は持たないので `Thought` のみで時間表示は省く) - -## 範囲外 - -- `UsageEvent` の reasoning_tokens 分離。Anthropic は `output_tokens` に thinking を含み、OpenAI Responses は `output_tokens_details.reasoning_tokens` を別途返すが、現状の `UsageEvent` ではそれを分離していない。本チケットでは token 数表示そのものを行わない -- Anthropic Redacted Thinking(暗号化 blob)の表示。現状 plaintext 経路のみ流れる -- Thinking 本文の Markdown レンダリング / シンタックスハイライト -- thinking を context から prune する話(別軸) - -## 完了条件 - -- Anthropic で extended thinking を有効にした session で「Thinking... (Xs) + 本文 1 行」が live で見え、終了後に `Thought for Xs` として履歴に残る -- 本文を流さない provider 設定でも `Thinking...` ヘッダが表示され、終了後に `Thought for ...` が残る -- `detail` モードで thinking 本文が全行展開できる -- 同一 turn に複数 thinking block が来てもそれぞれ独立に表示される -- `Event::History` 再生で過去の thinking が `Block::Thinking { finished: true }` として復元される -- `ThinkingDone` 欠落でも panic せず、Incomplete 相当の表示で残る -- 既存のテキスト / ツール / notification / compact 表示が壊れない - -## Review -- 状態: Approve -- レビュー詳細: [./tui-thinking-display.review.md](./tui-thinking-display.review.md) -- 日付: 2026-04-28 diff --git a/tickets/tui-thinking-display.review.md b/tickets/tui-thinking-display.review.md deleted file mode 100644 index 6aa5305a..00000000 --- a/tickets/tui-thinking-display.review.md +++ /dev/null @@ -1,80 +0,0 @@ -# Review: Thinking ブロックの TUI 表示 - -## 前提・要件の確認 - -### Worker API -- `Worker::on_thinking_block(setup)` は `crates/llm-worker/src/worker.rs:245-253` に追加、`on_text_block` (231-238) と完全に対称な setup シグネチャ。`block.on_delta` / `block.on_stop` は `ThinkingBlockScope` (`callback.rs:108-133`) で提供。`Send + Sync + 'static` 制約も同等。 -- 内部実装 `ClosureThinkingBlockHandler` は `Handler` を実装し (`callback.rs:146-171`)、`TextBlockEvent` のハンドラ (74-96) と一語一語まで同形。バッファ蓄積 → Stop で `on_stop(&buffer)` という流れで、本文を出さない provider のときも `on_stop("")` が呼ばれる。 - -### Protocol -- 3 イベント追加: `Event::ThinkingStart` / `ThinkingDelta { text }` / `ThinkingDone { text }` (`crates/protocol/src/lib.rs:181-195`)。doc コメントに「Delta 0 件のまま Start → Done が来る場合がある」「複数 thinking block を許容」を明示。 -- `event_thinking_roundtrip` (`lib.rs:487-512`) で 3 variant の serde 往復と `event` フィールドの snake_case (`thinking_start`) を検証。要件の「TextDone と同じ流儀で text に完成形を載せる」も実装と整合。 - -### Pod Controller -- `worker.on_thinking_block` で 3 イベントへブリッジ (`crates/pod/src/controller.rs:144-162`)。setup 内で `tx.send(Event::ThinkingStart)` を無条件に発火し、その後 `block.on_delta` / `block.on_stop` を登録するという順序。setup 自体が Start ハンドリング時に呼ばれるので「ブロックごとに 1 度だけ」という意図と一致。コメント (146-148) で意図を残しているのも好印象。 - -### TUI -- `Block::Thinking(ThinkingBlock)` を `crates/tui/src/block.rs:25` に追加。`ThinkingBlock { text, state }` と `ThinkingState::{Streaming{started_at}, Finished{elapsed_secs}, Incomplete{elapsed_secs}}` (`block.rs:40-57`) で要件 3 状態を表現。`started_at: Instant` は履歴復元には不要なので enum variant に持たせる選択は妥当。 -- イベントハンドラ (`crates/tui/src/app.rs:118-147`): - - `ThinkingStart`: `assistant_streaming = false` で text streaming を切ってから push (アシスタント本文の途中に thinking が割り込んでも以降の `TextDelta` は新規 `AssistantText` ブロックになる)。 - - `ThinkingDelta`: `last_streaming_thinking_mut` で直近の Streaming に append。`TurnHeader` でストップして前 turn を漁らない実装は意図通り。 - - `ThinkingDone`: 空テキストで来た場合は累積バッファを優先(Anthropic は Done が full text 持ちで、Delta も流れる二重ケース対策として `if b.text.is_empty()` 分岐を入れている)。`elapsed = started_at.elapsed()` を Streaming 時のみ採取し、それ以外(History 由来)は `None` で残す。 -- `TurnEnd` 時 `mark_orphan_thinking_incomplete` を呼ぶ (`app.rs:151`、定義 327-341)。`Streaming` を `Incomplete{elapsed_secs: Some(...)}` に転落させる仕様で、要件「経過時間を凍結した状態で履歴に残す」と整合。`TurnHeader` で打ち切るので前 turn 情報を破壊しない。 -- History 再生 (`app.rs:467-486`): `type: "reasoning"` を `Block::Thinking { Finished{elapsed_secs: None} }` で復元。`text` が空なら `summary` 配列を改行 join、これも仕様 (`Item::Reasoning { text, summary, ... }` の両表現対応) 通り。 -- レンダリング (`crates/tui/src/ui.rs:545-605`): - - `Streaming` → `Thinking... (Xs)` ヘッダ + 末尾行プレビュー (Normal)。 - - `Finished` → `Thought for Xs` (`elapsed=None` 時は `Thought` のみ) + 冒頭行プレビュー (Normal)。 - - `Incomplete` → `Thinking interrupted (Xs)` でツールの Incomplete と同質の表現。仕様で表現は固定されていないが、視覚的に「凍結された Thinking」が分かるので妥当。 - - `Mode::Overview` はヘッダ 1 行のみ、`Mode::Detail` は本文全行。3 モードとも要件通り。 - - 1 行プレビューは `width.saturating_sub(2)` を `truncate_with_ellipsis` に渡しており、左の `" "` インデント分を引く配慮あり。 -- `MessageKind::Thinking` (Magenta + ITALIC、`ui.rs:840`, `851-853`) で他カテゴリと区別可能。 -- `fmt_elapsed` (`ui.rs:628-634`) は 60s 未満 `{n}s`、それ以上 `{m}m{ss}s`。仕様の `1m23s` と一致。 - -### イベント欠落耐性 -- `ThinkingDone` 欠落時は `mark_orphan_thinking_incomplete` で `Incomplete` に落ちる。`ThinkingDelta` のみ来て `ThinkingStart` が来ないケースは `last_streaming_thinking_mut` が `None` を返して silently drop(前ブロックを書き換えない)。`ThinkingStart` のみで `ThinkingDelta` も `ThinkingDone` も来ない provider は要件範囲内で「本文無し Streaming → TurnEnd で Incomplete 化」という動作になる。 -- panic 経路は無い (unwrap / index 直接アクセス無し)。 - -### History 再生 -- `restore_history` の reasoning ハンドリングは要件通り。`Finished{elapsed_secs: None}` で `Thought` (時間なし) として表示される。 - -### ライブタイマー再描画 -- `crates/tui/src/main.rs:206-227` の run_loop は 50ms (`Duration::from_millis(50)`) ごとに `event::poll` を打ち、その後 `terminal.draw` を毎ループ末尾で呼ぶ (239)。つまり ~20Hz で常時再描画される構成。仕様要件「1Hz 程度で十分」を 20 倍上回る粒度で確実に満たす。新規にタイマーを足さなくて良いという判断は妥当。 - -## アーキテクチャ・スコープ - -- **層分離**: llm-worker は handler / closure / scope を「Thinking 用に対称コピー」するだけで、provider 別の解釈や TUI 都合の分岐を一切持ち込んでいない。protocol は wire 表現の追加のみ、pod controller は単なるアダプタ、TUI 側に状態機械が閉じている。`feedback_llm_worker_scope.md`「llm-worker は低レベル基盤に留める」に則っている。 -- **Event::ThinkingStart の追加 (TextDelta/TextDone との非対称性)**: 既存パターンでは AssistantText は最初の Delta で lazy 生成だが、Thinking は本文を持たない provider があるため Start イベントが必要。意図的な非対称で、`crates/protocol/src/lib.rs:181-187` の doc コメントにも「Delta が optional」と明記されている。チケット要件(本文を出さない provider でも `Thinking...` ヘッダを出す)に直結し、後付けでも除去でも済まない。設計判断として妥当。 -- **`ThinkingState::Streaming { started_at: Instant }`**: `Instant` は serialize 不可だが、Streaming 状態のブロックが永続化される経路は無い (history は `Reasoning { text, summary }` で再生 → `Finished{None}` 確定) ため問題なし。enum variant に直接持たせる選択は、`Finished{elapsed_secs}` への遷移時に値が一意に確定するので素直。 -- **`mark_orphan_thinking_incomplete` と `mark_orphan_tool_calls_incomplete` の構造類似**: ToolCall 側 (357-374) は「最初に Done/Error なブロックに当たったら break」する一方、Thinking 側 (327-341) は `TurnHeader` まで素通しで全 `Streaming` を Incomplete 化する。Thinking ブロックが turn 内で複数並ぶことは仕様で許容されているため、走査して全部潰すこちらの実装が正しい。微妙な差だがコメントで意図を残しておくと将来助かる(Nits)。 -- **無関係な改変なし**: Cargo.toml / 依存追加なし、新規 crate 無し、別 ticket スコープへの侵食なし。範囲外として明記された 4 点(reasoning_tokens、Redacted Thinking、Markdown レンダリング、prune)には一切手を出していない。 - -## 指摘事項 - -### Blocking -なし。 - -### Non-blocking / Follow-up - -- **`ThinkingDone` の text マージロジックが provider 仕様の取り違えに脆弱** — `app.rs:132-137` で `b.text.is_empty()` のときだけ `text` (Done payload) を差し込む。Anthropic は Delta も流しつつ Done に full text を載せるので、両方来た場合は Delta 蓄積が優先される (= Done payload を捨てる) 実装。`ClosureThinkingBlockHandler` 側でも buffer は Delta から作っているので、buffer と Done text が乖離する provider が現れた場合(例: Done が「整形済み」で Delta が「raw」など)に検出が遅れる可能性。現状の正規化レイヤ (`DeltaContent::Thinking`) はそのケースを起こさない作りなので実害なし。doc に「Delta が来ていれば Delta 優先」とコメントを 1 行残しておくと将来助かる。 -- **`last_streaming_thinking_mut` が「最も新しい Streaming」を返す前提** — `app.rs:314-325` は最後尾から `Streaming` を見つけたら即返す。仕様上 thinking block は逐次的なので問題ないが、provider が並列的に thinking を流してきたら混信する。現在の Timeline 実装では同時に Streaming な thinking block が 2 つ存在しないため健全。ガード(block index でリンク)は YAGNI で良いが、protocol に「同時に Streaming は 1 件まで」を明文化するなら別チケットで。 -- **`mark_orphan_thinking_incomplete` の break 条件** — ToolCall 版は「Done/Error にぶつかったら break」して走査短縮する一方、Thinking 版は `TurnHeader` まで全部見る。turn 内の Finished な thinking block を毎回 visit しても overhead は実質ゼロだが、コメントで「全 streaming を潰したいので break しない」と一言残せると意図が読み取りやすい (`crates/tui/src/app.rs:327-341`)。 - -### Nits -- `crates/tui/src/ui.rs:609-616` `trailing_line_preview` doc に「the live "what is it thinking now" 1-liner」とあるが、`text.rsplit_once('\n').map(|(_, tail)| tail).unwrap_or(text).trim_end()` は 末尾が改行で終わる Streaming 中の bufer に対して空文字を返す(rsplit_once は最後の改行で分割するので tail が空)。発生条件は「直前まで本文があり、最後の delta で改行のみが来た瞬間」というレアケース。preview が一瞬空になるだけで害は無い。気になるなら `rsplit('\n').find(|l| !l.is_empty())` でも可。 -- `Event::ThinkingStart` 用 setup callback の中で `tx.send(Event::ThinkingStart)` を呼ぶスタイル (`controller.rs:149`) は、closure の意味的には「setup callback はブロックの metadata を受け取るためのもの」というニュアンスからは少しズレる。`on_thinking_block_start(|| ...)` のような専用フックを切る案もあるが、ToolUse 側も同様 (`controller.rs:166-169`) で start 通知を setup で打っているため一貫性は取れている。現状でよい。 -- `crates/tui/src/block.rs:48-50` の `Streaming` doc コメントで「`started_at` is `None` only for blocks materialised from `Event::History`」と書かれているが、`started_at` は `Instant`(`Option` ではない)で、History 由来は `Finished{None}` として作られる。コメントが古い設計を引きずっている。読み手を惑わすので `started_at: Instant` の意味だけにトリムすると親切。 - -## 完了条件のマッピング - -- Anthropic の extended thinking で「`Thinking... (Xs)` + 本文 1 行」が live: `ThinkingStart` + `ThinkingDelta` で `ThinkingState::Streaming` ブロックに append、`render_thinking` Normal 分岐 (`ui.rs:584-602`) で末尾行プレビュー、`run_loop` の 50ms 再描画でタイマー更新。OK。 -- 終了後 `Thought for Xs` 残存: `ThinkingDone` ハンドラで `Finished{elapsed_secs: Some(...)}`、ヘッダ表現 (`ui.rs:554-557`)。OK。 -- 本文を流さない provider でもヘッダが立つ: pod controller の setup 内 unconditional `ThinkingStart` 発火 (`controller.rs:149`)。OK。 -- `detail` モードで本文全行展開: `ui.rs:576-583`。OK。 -- 同一 turn 複数 thinking block: ハンドラは「直近の Streaming」しか触らないので、Done → Start で次のブロックを push する流れで独立して扱われる。OK。 -- History 再生で `Finished{None}`: `app.rs:467-486`。OK。 -- `ThinkingDone` 欠落で panic せず Incomplete 表示: `mark_orphan_thinking_incomplete` + Incomplete レンダリング (`ui.rs:558-561`)。OK。 -- 既存表示の非回帰: cargo build / test workspace pass を信頼。Event::ThinkingStart の追加が `Event` 列挙の網羅マッチに影響しないか念のため確認 → app.rs / protocol テスト以外で `match Event` する場所は影響なし(ハンドラはすべて閉じた match を更新済み)。 - -## 判断 - -**Approve** — チケットの方針 / 要件 / 完了条件はいずれも実装に直接対応しており、範囲外として宣言した 4 項目にも踏み込んでいない。`Event::ThinkingStart` の新設は「本文を出さない provider でもヘッダを立てる」という機能要件のために必要で、TextDelta との非対称性は意図的であり doc にも明記されている。アーキテクチャ上の歪みなし、Blocking 指摘事項なし。Non-blocking はいずれもコメント補強や将来検討で、実装そのままで完了させて良い。