From d69db8271d8a204d97f214800d41353f321b837a Mon Sep 17 00:00:00 2001 From: takecchi Date: Fri, 21 Aug 2026 09:22:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E3=82=A8=E3=83=BC=E3=82=B8=E3=82=A7?= =?UTF-8?q?=E3=83=B3=E3=83=88=E5=88=87=E6=9B=BF=E3=81=A7=E3=83=98=E3=83=83?= =?UTF-8?q?=E3=83=80=E3=81=AE=E3=83=97=E3=83=A9=E3=83=B3=20/=20=E3=83=A2?= =?UTF-8?q?=E3=83=87=E3=83=AB=20/=20=E4=BD=BF=E7=94=A8=E7=8A=B6=E6=B3=81?= =?UTF-8?q?=E3=82=82=E5=88=87=E3=82=8A=E6=9B=BF=E3=82=8F=E3=82=8B=E3=82=88?= =?UTF-8?q?=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/agent` で新規セッションの既定エージェントを切り替えても、ヘッダ(バナー)は Claude のプラン名・起動時の設定モデル・claude.ai の使用状況ゲージを出し続けていた。 ヘッダは「次に動くエージェント」の説明なので、エージェント名だけが変わって残りが 前の provider のままだと、項目ごとに読み方が食い違う。 - `showsAccountUsage` を `showsAccountInfo` に置き換え、判定を**既定エージェントだけ**に 狭めた(稼働中セッションは見ない)。プラン名と使用状況は同じ 1 回の probe が運ぶので 判定も 1 本にまとめ、プラン行とゲージを揃って出し分ける - ヘッダのモデル欄と既定エージェントを manager の購読で読む(`useDefaultModel` / `useDefaultAgent`)。`setModel` / `setDefaultAgent` が `store.notify()` するので、 切替でモデルが CLI 既定へ戻ったことも即座に反映される - 起動時の設定値を配っていた `App` / `SessionList` の `model` prop を削除(唯一の出所を manager にする) - 表示と取得は同じ純関数を通したままなので、出さないゲージのために 5 分ごとの `claude` probe は立たない(既定を Claude へ戻せば再開する) --- .claude/rules/sdk-integration.md | 2 +- CLAUDE.md | 4 +- README.md | 8 +++- docs/ARCHITECTURE.md | 27 ++++++++----- docs/TASKS.md | 12 +++--- src/app.tsx | 3 -- src/core/agent-capabilities.spec.ts | 25 ++++++------ src/core/agent-capabilities.ts | 41 +++++++++---------- src/core/session-manager.spec.ts | 36 +++++++++++++++++ src/core/session-manager.ts | 9 +++++ src/main.tsx | 11 +++-- src/ui/hooks.ts | 30 ++++++++++++++ src/ui/session-list.tsx | 37 ++++++++++------- tests/app.test.tsx | 62 ++++++++++++++++++++++++++++- 14 files changed, 228 insertions(+), 79 deletions(-) diff --git a/.claude/rules/sdk-integration.md b/.claude/rules/sdk-integration.md index 1e8ddbb..00028be 100644 --- a/.claude/rules/sdk-integration.md +++ b/.claude/rules/sdk-integration.md @@ -32,7 +32,7 @@ - その provider に無い機能は `AgentCapabilities` で表明する(`permissions` / `interrupt` / `setModel` / `resume` / `modelCatalog` / `usage` / `cost` / `transcript`)。UI は capability を 見て縮退する。**判定は純粋な `core/agent-capabilities.ts` を通す**(`supportsCapability` / - `capabilityLookup` / `agentSupports` / `showsAccountUsage`)。守ること 2 つ: + `capabilityLookup` / `agentSupports` / `showsAccountInfo`)。守ること 2 つ: - **capability が分からないときは縮退しない**(未登録の provider・`agent` を持たない古い セッションで機能を隠すと、動くはずの操作が黙って消える)。 - **「値が 0 だから自然に消える」に頼らない**。コスト・使用状況・トランスクリプト復元は diff --git a/CLAUDE.md b/CLAUDE.md index dff7bb7..18c4713 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,8 +61,8 @@ CI(`.github/workflows/ci.yml`)は `lint → typecheck → test → build`。 | セッションの状態・遷移 | `core/types.ts`(union)/ `core/status-meta.ts`(性質の表)/ `core/status-reducer.ts`(純粋 reducer) | | 別のエージェントに対応させる | `core/agent-ports.ts`(`AgentAdapter` / `AgentCapabilities` / `PermissionDecision` = DI 境界)/ `core/agent-events.ts`(`AgentEvent` の語彙 + 全 provider 共通の畳み込み `applyAgentEvent`)/ `core/claude-adapter.ts`・`core/claude-parse.ts`・`core/claude-errors.ts`(Claude 実装の 3 点セット)/ `core/codex-adapter.ts`・`core/codex-parse.ts`・`core/codex-errors.ts` + `core/codex-events.ts`(JSONL の型)・`core/codex-models.ts`・`core/codex-rollout.ts`(rollout から解決済みモデル)・`utils/codex.ts`(`codex exec` の起動 = 唯一の I/O)/ `core/grok-adapter.ts`・`core/grok-parse.ts`・`core/grok-errors.ts` + `core/grok-events.ts`(ACP メッセージの型)・`core/grok-models.ts`・`utils/grok.ts`(`grok agent stdio` の起動 = 唯一の I/O)/ 行区切り JSON の枠切りは provider 非依存の `core/jsonl.ts`(Codex / Grok 共用)/ アダプタの登録は `bootstrap/build-manager.ts` の `buildAgents` | | SDK メッセージの解釈 | `core/claude-parse.ts` **のみ**(`parseClaudeMessage`: SDKMessage → `AgentEvent[]`)+ `core/__fixtures__/*.jsonl`。Codex は `core/codex-parse.ts`(`parseCodexEvent`: `codex exec --json` の JSONL → `AgentEvent[]`)+ `core/__fixtures__/codex-*.jsonl`。Grok は `core/grok-parse.ts`(`createGrokParser`: ACP = JSON-RPC over stdio の通知 → `AgentEvent[]`)+ `core/__fixtures__/grok-*.jsonl` | -| capability による UI 縮退(コスト・使用状況・確認モード・ログ復元) | `core/agent-capabilities.ts`(`supportsCapability` = **不明なら縮退しない** / `capabilityLookup` / `agentSupports` / `showsAccountUsage`・純粋)/ `core/cost.ts` の `totalCostUsd(states, reportsCost)` / `bootstrap/usage-poller.ts` の `enabled` / `bootstrap/restore-sessions.ts` / `ui/status-footer.tsx` の `confirmSupported` | -| どのセッションが何で走っているかの表示 | `core/agent-display.ts`(`sessionAgentId` / `usesMultipleAgents`)/ `core/layout.ts` の `showsAgentColumn`(混在時だけ列を出す)/ `core/banner-lines.ts` の `agent`(ヘッダ = 既定)/ `core/scroll.ts` の `logLines(…, dividerFor)`(ログの切替区切り)/ `m.detail.followupPlaceholder(agent)`(詳細の入力欄) | +| capability による UI 縮退(コスト・プラン/使用状況・確認モード・ログ復元) | `core/agent-capabilities.ts`(`supportsCapability` = **不明なら縮退しない** / `capabilityLookup` / `agentSupports` / `showsAccountInfo` = プラン + 使用状況は**既定エージェント**で出し分け・純粋)/ `core/cost.ts` の `totalCostUsd(states, reportsCost)` / `bootstrap/usage-poller.ts` の `enabled` / `bootstrap/restore-sessions.ts` / `ui/status-footer.tsx` の `confirmSupported` | +| どのセッションが何で走っているかの表示 | `core/agent-display.ts`(`sessionAgentId` / `usesMultipleAgents`)/ `core/layout.ts` の `showsAgentColumn`(混在時だけ列を出す)/ `core/banner-lines.ts` の `agent`(ヘッダ = 既定。エージェント名・プラン・モデル・使用状況は `ui/hooks.ts` の `useDefaultAgent` / `useDefaultModel` を購読して**揃って**切り替わる)/ `core/scroll.ts` の `logLines(…, dividerFor)`(ログの切替区切り)/ `m.detail.followupPlaceholder(agent)`(詳細の入力欄) | | エージェント切替時の引き継ぎ | `core/agent-handoff.ts`(`handoffInstruction` / `lastUserInstruction`・英語固定 = AI 向け文字列)/ `core/system-prompt.ts` の `handoff` 節 / `core/session.ts` の `setAgent`(**使い捨て**で次の `open()` が消費) | | エージェントの切替(`/agent`)| `core/session-manager.ts`(一覧=既定: `getDefaultAgentId` / `setDefaultAgent`・詳細=切替: `listAgents` / `getSessionAgent` / `setSessionAgent`)/ `ui/agent-select.tsx`(`mode:'default'`=一覧 / `'session'`=詳細)/ `core/status-reducer.ts` の `agent_switched` | | エージェントの導入・ログイン検出 | `core/agent-ports.ts` の `AgentAdapter.checkAvailability` / `AgentAvailability` / `core/agent-availability.ts`(`resolveDefaultAgentId` / `noAgentInstalled`・純粋)/ `utils/claude.ts` の `detectClaudeAvailability`・`utils/codex.ts` の `detectCodexAvailability`・`utils/grok.ts` の `detectGrokAvailability`(実 I/O)/ `SessionManager.checkAgents`(集約・キャッシュ)/ `ui/hooks.ts` の `useAgentAvailability` | diff --git a/README.md b/README.md index df30e4e..5c018a1 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,10 @@ Codiva v0.3.1 3 セッション - 使用率(`%`)は Claude が返さないプランもあります。その場合はゲージを出さず残り時間だけを表示します (0% と誤読させないため)。 - API キー / Bedrock / Vertex 利用時はサブスク制限が無いため、この表示は出ません。 +- **ヘッダは「次に動くエージェント」の説明**です。`/agent` で既定を Codex / Grok に切り替えると、 + エージェント名・プラン・モデル・使用状況が揃って切り替わります(どちらもプランや使用状況を + 報告しないので、その間はプラン行とゲージが消え、モデルは `モデル: CLI 既定` に戻ります)。 + claude.ai への 5 分ごとの自動取得もその間は止まり、既定を Claude へ戻すと再開します。 - 画面下部のステータスバーはモード表示(`⏵⏵ 自動モード`)と操作ヒントだけです。プラン / 使用状況は ヘッダに集約しています(詳細ビューで見たいときは Esc で一覧へ戻ります)。 @@ -317,7 +321,7 @@ Codiva v0.3.1 3 セッション **Codex セッションの制約**(Claude セッションとの違い): - **ツール使用の許可を尋ねません。** `codex exec` の JSON 出力モードは承認要求を CLI 内部で自動的に拒否してしまい、codiva 側へ上げる手段がありません。そこで codiva は「それらしい許可ダイアログ」を出さず、**サンドボックスを唯一の安全弁**にしています(設定 `codexSandbox`。既定の `workspace-write` では書き込みがセッションの worktree 内に限定されます)。`質問あり` の状態にもなりません。 -- **コストを表示しません。** Codex はターン終了時にトークン数しか返さず、金額もアカウント全体の使用状況も運びません。ヘッダの合計コストには Codex のセッションを数えません(Claude ぶんだけの金額を「全体」として出さないため)。使用状況ゲージは Claude のアカウントの枠なので、**Codex / Grok だけで作業している間はヘッダに出ません**(取得もしません)。 +- **コストを表示しません。** Codex はターン終了時にトークン数しか返さず、金額もアカウント全体の使用状況も運びません。ヘッダの合計コストには Codex のセッションを数えません(Claude ぶんだけの金額を「全体」として出さないため)。プラン表示と使用状況ゲージは Claude のアカウントの話なので、**`/agent` で既定を Codex にしている間はヘッダに出ません**(取得もしません)。 - **フッタのモード表示が `確認モード (非対応)` になります。** 許可を尋ねられないので、`確認モード` のままだと「待っていれば聞かれる」と読めてしまうためです(`shift+tab` の切替そのものは効きます)。 - **再起動後にログが復元されません**(セッションの続きを再開すること自体はできます)。ログの再構築は Claude CLI の記録ファイルを読む仕組みで、Codex の記録は形式が異なるためです。 - `/model` の選択肢は Codex 側のモデル一覧(`codex debug models`)になります。一覧を取得できない環境では「デフォルト」だけになります(推測でモデル名を並べません)。`/agent` で provider を切り替えると、互換性のない切替前のモデル指定は CLI 既定へ戻ります。Codex は実行イベントにモデル名を含めないため、`/model` で明示したモデル名をセッション一覧に表示します。 @@ -325,7 +329,7 @@ Codiva v0.3.1 3 セッション **Grok セッションの制約**(Claude セッションとの違い): - **ツール使用の許可と質問はそのまま届きます。** Codex と違い、Grok は許可要求(`許可待ち`)と質問(`質問あり`)を codiva の双方向のやり取りで上げてくるので、いつもどおりダイアログで応答できます。 -- **コストを表示しません。** Grok はターンの終わりにトークン数しか返さず、金額もアカウント全体の使用状況も運びません。ヘッダの合計コストには Grok のセッションを数えず、使用状況ゲージ(Claude のアカウントの枠)も Grok だけで作業している間は出ません。 +- **コストを表示しません。** Grok はターンの終わりにトークン数しか返さず、金額もアカウント全体の使用状況も運びません。ヘッダの合計コストには Grok のセッションを数えず、プラン表示と使用状況ゲージ(Claude のアカウントの話)も既定を Grok にしている間は出ません。 - **再起動後にログが復元されません**(セッションの続きを再開すること自体はできます)。ログの再構築は Claude CLI の記録ファイルを読む仕組みで、Grok の記録は形式が異なるためです。 - `/model` の選択肢は Grok 側のモデル一覧になります。一覧を取得できない環境では「デフォルト」だけになります(推測でモデル名を並べません)。`/agent` で provider を切り替えると、互換性のない切替前のモデル指定は CLI 既定へ戻ります。Codex と違い Grok は**実際に動いているモデル名を自分で教えてくれる**ので、`/model` で明示していなくてもセッション一覧にモデル名が出ます。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 406fcb8..b3abdd2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -63,7 +63,7 @@ codiva/ │ │ ├── status-reducer.ts # reduce(state, CodivaEvent): SessionState(codiva 起点のイベント・純関数) │ │ ├── agent-ports.ts # エージェントの DI 境界(AgentAdapter/AgentRun/AgentCapabilities/PermissionDecision・leaf) │ │ ├── agent-events.ts # AgentEvent の語彙 + applyAgentEvent()(全 provider 共通の畳み込み・純粋) -│ │ ├── agent-capabilities.ts # capability による UI 縮退の判定(不明なら縮退しない・showsAccountUsage) +│ │ ├── agent-capabilities.ts # capability による UI 縮退の判定(不明なら縮退しない・showsAccountInfo) │ │ ├── agent-display.ts # 「どのセッションが何で走っているか」の判定(sessionAgentId / usesMultipleAgents) │ │ ├── agent-handoff.ts # 切替先へ渡す状況説明(英語固定・systemPrompt に 1 回だけ載る) │ │ ├── claude-adapter.ts # Claude 用 AgentAdapter(query() の組み立て・canUseTool の写像) @@ -319,7 +319,7 @@ provider が増えてもビュー側の分岐は増えない(未登録のエ エージェント名は固有名詞なので翻訳しない(モデル名と同じ i18n の例外)。 縮退の判定は**純粋な `core/agent-capabilities.ts`** に寄せてある(`supportsCapability` / -`capabilityLookup` / `agentSupports` / `showsAccountUsage`)。要点は 2 つ: +`capabilityLookup` / `agentSupports` / `showsAccountInfo`)。要点は 2 つ: - **capability が分からないときは縮退しない**(`supportsCapability(undefined, …) === true`)。 未登録の provider・`agent` を持たない古いセッションで機能を隠すと、動くはずの操作が黙って @@ -333,7 +333,7 @@ provider が増えてもビュー側の分岐は増えない(未登録のエ | `/model` のダイアログ | `setModel` / `modelCatalog` | `ui/session-detail.tsx` | 開かずに理由を出す・選択肢を provider 別に出し分け | | `Ctrl+C` のヒント | `interrupt` | `ui/session-detail.tsx` | ヒント行を出さない | | 合計コスト(ヘッダ) | `cost` | `core/cost.ts` の `totalCostUsd(states, reportsCost)` | 報告しない provider のセッションを合計に数えない | -| 使用状況ゲージ(ヘッダ) | `usage` | `showsAccountUsage`(一覧の表示 + `bootstrap/usage-poller.ts` の `enabled`) | 使っていなければ**出さないし取りにも行かない**(5 分ごとの probe を立てない) | +| プラン名 + 使用状況ゲージ(ヘッダ) | `usage` | `showsAccountInfo`(一覧の表示 + `bootstrap/usage-poller.ts` の `enabled`) | **既定エージェント**が報告しなければ**出さないし取りにも行かない**(5 分ごとの probe を立てない) | | 確認モードのフッタ表示 | `permissions` | `ui/status-footer.tsx` の `confirmSupported` | `確認モード (非対応)` に差し替える(下記) | | トランスクリプト復元 | `transcript` | `bootstrap/restore-sessions.ts` | その provider のセッションでは読みにも行かない | | 認証切れの文言 | —(`AgentLabel`) | 一覧・詳細・通知 | 駆動中の provider のコマンド名を出す | @@ -343,12 +343,21 @@ provider が増えてもビュー側の分岐は増えない(未登録のエ 「待っていれば聞かれる」と読めてしまっていた(ツールは確認なしに実行される)。ダイアログを 偽装しないのと同じ理由で、**モード表示の側を正直にする**。 -**使用状況ゲージを消す判定**(`showsAccountUsage`)は「新規セッションの既定エージェント、または -`archived` でないセッションのどれかが `usage` を報告する」。ゲージが表しているのは -その provider のアカウントの消費で、Codex / Grok だけで作業している人には読みようがない -(`archived` を数えないのは、乗り換えた人のヘッダにマージ済みのセッション 1 件で残り続けるのを -避けるため)。**表示と取得は同じ純関数を通す**ので、出していないゲージのために -`claude` のサブプロセスが立つことはない。 +**ヘッダは「次に動くエージェント」の説明**にする。エージェント名・プラン名・モデル・使用状況は +1 つのアカウントの話として同じ場所に並んでいるので、`/agent` で既定を切り替えたら**4 つ揃って** +入れ替わる。判定は `showsAccountInfo`(= 既定エージェントが `usage` を報告するか)1 本で、 +プラン名も枠も**同じ 1 回の probe**(`utils/usage-probe.ts`)が運ぶので分けていない。 +**表示と取得は同じ純関数を通す**ので、出していないゲージのために `claude` のサブプロセスが +立つことはない(既定を Claude へ戻せば次の poll から再開する)。 + +- かつては「既定エージェント **or** `archived` でないセッションのどれかが報告する」で判定して + いたため、Codex / Grok に切り替えても Claude のプランとゲージが残っていた。稼働中の Claude + セッションの消費が覗けるという利点はあったが、ヘッダの読み方(= 既定エージェントの説明)が + 項目ごとに食い違うほうが害が大きいと判断して、**既定エージェントだけ**を見る形に狭めた。 +- モデル欄と既定エージェントは **manager の購読**で読む(`ui/hooks.ts` の `useDefaultModel` / + `useDefaultAgent`。`setModel` / `setDefaultAgent` が `store.notify()` する)。起動時の設定値を + prop で配っていたときは、`/model` やエージェント切替(既定モデルを CLI 既定へ戻す)のあとも + 前の値が居座って、実際に使われるモデルと食い違って見えていた。 ### 6. Codex アダプタ: 1 ターン = 1 プロセス diff --git a/docs/TASKS.md b/docs/TASKS.md index cf7929c..9d8aeed 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -1313,13 +1313,15 @@ zsh: abort codiva `agent.noneInstalled` の 1 行を出す(`noAgentInstalled` が全件未導入で確定したときだけ) - [x] **残りの capability 縮退**: 判定を純粋な `core/agent-capabilities.ts` に集約 (`supportsCapability` = **不明なら縮退しない** / `capabilityLookup` / `agentSupports` / - `showsAccountUsage`)。「数字が 0 だから自然に消える」偶然に頼るのをやめ、明示的な分岐にした: + `showsAccountInfo`)。「数字が 0 だから自然に消える」偶然に頼るのをやめ、明示的な分岐にした: - `cost`: `totalCostUsd(states, reportsCost)` が報告しない provider を合計から外す (混在時に「Claude ぶんの合計」を全体として出さない) - - `usage`: 一覧が `showsAccountUsage`(既定エージェント or `archived` でないセッションの - どれかが `usage` を報告するか)でゲージを出し分け、**同じ純関数**を - `bootstrap/usage-poller.ts` の `enabled` にも渡す = 出さないゲージのために 5 分ごとの - `claude` probe を立てない + - `usage`: 一覧が `showsAccountInfo`(**既定エージェント**が `usage` を報告するか)で + プラン名とゲージを出し分け、**同じ純関数**を `bootstrap/usage-poller.ts` の `enabled` + にも渡す = 出さないゲージのために 5 分ごとの `claude` probe を立てない + (当初は「既定 or `archived` でないセッションのどれか」で判定していたが、それだと + `/agent` で Codex へ切り替えても Claude のプランと枠が残り、ヘッダの読み方が項目ごとに + 食い違ったので**既定エージェントだけ**に狭めた) - `permissions`: フッタの確認モード表示を `confirmSupported` で `確認モード (非対応)` に 差し替える(Codex では原理的に聞かれないのに「確認モード」と言い切っていた = 「待っていれば聞かれる」と読める嘘)。ダイアログ自体は偽装も抑止もしない diff --git a/src/app.tsx b/src/app.tsx index a36f69e..1cfdafe 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -29,7 +29,6 @@ type View = { mode: 'list' } | { mode: 'detail'; id: string }; export const App: FC<{ manager: SessionManager; cwd?: string; - model?: string; /** アプリのバージョン(package.json 由来)。ヘッダのワードマーク右に表示。 */ version?: string; messages?: Messages; @@ -90,7 +89,6 @@ export const App: FC<{ }> = ({ manager, cwd, - model, version, // 既定は ja。main.tsx が解決済みカタログを注入する。 messages = catalogs.ja, @@ -178,7 +176,6 @@ export const App: FC<{ onQuit={quit} cwd={cwd} branch={branch} - model={model} models={models} modelsByAgent={modelsByAgent} version={version} diff --git a/src/core/agent-capabilities.spec.ts b/src/core/agent-capabilities.spec.ts index b32db23..152d937 100644 --- a/src/core/agent-capabilities.spec.ts +++ b/src/core/agent-capabilities.spec.ts @@ -3,11 +3,11 @@ import { type AgentCapabilitySource, agentSupports, capabilityLookup, - showsAccountUsage, + showsAccountInfo, supportsCapability, } from './agent-capabilities'; import { NO_CAPABILITIES } from './agent-ports'; -import type { AgentId, SessionStatus } from './types'; +import type { AgentId } from './types'; const FULL = { permissions: true, @@ -64,17 +64,18 @@ describe('capabilityLookup / agentSupports', () => { }); }); -describe('showsAccountUsage', () => { +describe('showsAccountInfo', () => { const capabilities = capabilityLookup(AGENTS); - const session = (agent: AgentId, status: SessionStatus = 'completed') => ({ agent, status }); - it.each<[string, AgentId | undefined, { agent?: AgentId; status: SessionStatus }[], boolean]>([ - ['既定が usage を報告する', 'claude', [], true], - ['既定は報告しないがセッションが報告する', 'codex', [session('claude')], true], - ['既定もセッションも報告しない', 'codex', [session('codex'), session('grok')], false], - ['archived だけの claude は数えない', 'codex', [session('claude', 'archived')], false], - ['既定が不明なら出す', undefined, [session('codex')], true], - ])('%s', (_label, defaultAgent, sessions, expected) => { - expect(showsAccountUsage({ sessions, defaultAgent, capabilities })).toBe(expected); + // 見るのは**既定エージェントだけ**(引数に稼働中セッションを取らないのがその表明)。 + // ヘッダのアカウント節は「次に動くエージェント」の説明なので、Claude のセッションが + // 残っているからといって Codex を選んだ人に claude.ai のプランと枠を出さない。 + it.each<[string, AgentId | undefined, boolean]>([ + ['既定が usage を報告する', 'claude', true], + ['既定が報告しない(Codex)', 'codex', false], + ['既定が報告しない(Grok)', 'grok', false], + ['既定が不明なら出す', undefined, true], + ])('%s', (_label, defaultAgent, expected) => { + expect(showsAccountInfo({ defaultAgent, capabilities })).toBe(expected); }); }); diff --git a/src/core/agent-capabilities.ts b/src/core/agent-capabilities.ts index ae8fc7e..95d5e66 100644 --- a/src/core/agent-capabilities.ts +++ b/src/core/agent-capabilities.ts @@ -1,5 +1,5 @@ import type { AgentCapabilities } from './agent-ports'; -import type { AgentId, SessionStatus } from './types'; +import type { AgentId } from './types'; /** * capability による UI 縮退の判定(純粋)。 @@ -17,12 +17,6 @@ export interface AgentCapabilitySource { readonly capabilities: AgentCapabilities; } -/** `SessionState` のうちここが見る部分だけ(テストから素の値で駆動できるように)。 */ -export interface AgentUsageSession { - readonly agent?: AgentId; - readonly status: SessionStatus; -} - /** id → capabilities の引き当て(未登録の provider は undefined = 不明)。 */ export type CapabilityLookup = (agent: AgentId | undefined) => AgentCapabilities | undefined; @@ -55,26 +49,27 @@ export function agentSupports( } /** - * ヘッダの使用状況ゲージ(アカウント全体の枠)を出すか。 + * ヘッダのアカウント節(プラン名 + 使用状況ゲージ)を出すか。 + * + * 判定は**新規セッションの既定エージェントだけ**を見る。ヘッダのその節が説明して + * いるのは「次に動くエージェント」で、プラン / モデル / 使用状況は 1 つのアカウントの + * 話として並んでいるので、`/agent` で切り替えたら 3 つ揃って切り替わるのが読み方として + * 一貫している(Codex / Grok を選んでいる人に claude.ai のプラン名と枠を出しても、 + * それを埋めているのは別のツールなので読みようがない)。 * - * ゲージが表しているのは **`usage` を報告する provider のアカウント**の消費であって、 - * codiva 全体の消費ではない。Codex / Grok だけで作業している人に Claude の枠を - * 出しても読みようがなく(それを埋めているのは別のツール)、5 分ごとの probe - * サブプロセスも無駄になる。だから「新規セッションの既定」か「まだ生きている - * セッションのどれか」が `usage` を報告するときだけ出す。 + * **稼働中のセッションは見ない**。Claude のセッションが走っている最中に既定を切り替える + * とその消費は見えなくなるが、既定を戻せばまた出る — 「ヘッダは既定エージェントの説明」 + * という一貫性を、消費を覗ける便利さより優先する(かつては「既定 or archived でない + * セッションのどれか」で判定していたので、Codex に切り替えても Claude のプランと枠が + * 残り続けていた)。 * - * `archived` を数えないのは、マージ済みの過去のセッション 1 件で永久に出続けるのを - * 避けるため(provider を乗り換えた人のヘッダに残る)。 + * プラン名も使用状況も**同じ 1 回の probe**(`utils/usage-probe.ts`)が運ぶので、判定も + * 1 つに束ねてある。表示と取得はこの同じ純関数を通す(`bootstrap/usage-poller.ts` の + * `enabled`)ので、出していないゲージのために `claude` のサブプロセスが立つことはない。 */ -export function showsAccountUsage(input: { - sessions: readonly AgentUsageSession[]; +export function showsAccountInfo(input: { defaultAgent?: AgentId; capabilities: CapabilityLookup; }): boolean { - if (agentSupports(input.capabilities, input.defaultAgent, 'usage')) { - return true; - } - return input.sessions.some( - (s) => s.status !== 'archived' && agentSupports(input.capabilities, s.agent, 'usage'), - ); + return agentSupports(input.capabilities, input.defaultAgent, 'usage'); } diff --git a/src/core/session-manager.spec.ts b/src/core/session-manager.spec.ts index 9a2f1a7..73544fd 100644 --- a/src/core/session-manager.spec.ts +++ b/src/core/session-manager.spec.ts @@ -1692,6 +1692,26 @@ describe('SessionManager', () => { await flush(); expect(models).toEqual(['claude-opus-4-8', 'claude-haiku-4-5']); }); + + // ヘッダのモデル欄はこの値を購読して出す(`ui/hooks.ts` の `useDefaultModel`)。 + // 通知しないと `/model` のあとも起動時の値が居座り、実際に使われるモデルと食い違う。 + it('notifies subscribers when the default model changes (header reads it)', () => { + const manager = new SessionManager({ + worktrees: fakeWorktrees(), + queryFn: (() => { + throw new Error('unused'); + }) as never, + now: () => 1, + options: { model: 'claude-opus-4-8' }, + createSession: ({ input, onChange }) => new FakeSession(input, onChange), + }); + const listener = vi.fn(); + manager.subscribe(listener); + manager.setModel('claude-haiku-4-5'); + expect(listener).toHaveBeenCalledTimes(1); + manager.setModel('claude-haiku-4-5'); // 同じ値 = no-op(余計な再描画を起こさない) + expect(listener).toHaveBeenCalledTimes(1); + }); }); describe('repo instructions (/prompt)', () => { @@ -1938,6 +1958,22 @@ describe('SessionManager', () => { expect(manager.setDefaultAgent('claude')).toBe(false); // 既に既定 }); + // ヘッダのエージェント名・プラン・モデル・使用状況はこの値で出し分ける + // (`ui/hooks.ts` の `useDefaultAgent`)。モデル未設定のときは `setModel` が + // no-op になるので、切替そのものが必ず 1 回通知する必要がある。 + it('notifies subscribers on a default-agent change (header reads it)', () => { + const { manager } = managerWith({ + claude: fakeAdapter('claude'), + codex: fakeAdapter('codex'), + }); + const listener = vi.fn(); + manager.subscribe(listener); + expect(manager.setDefaultAgent('codex')).toBe(true); + expect(listener).toHaveBeenCalledTimes(1); // model は未設定 = setModel は no-op + expect(manager.setDefaultAgent('codex')).toBe(false); + expect(listener).toHaveBeenCalledTimes(1); // 変わらないなら再描画も起こさない + }); + it('detects availability once and caches it (concurrent calls share one probe)', async () => { const claude = fakeAdapter('claude', YES); const codex = fakeAdapter('codex', MISSING); diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index f9e8aa2..edad132 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -251,6 +251,10 @@ export class SessionManager { /** * Set the default model for sessions created from now on (via /model). Already * running sessions keep the model they started with. Persists via onModelChange. + * + * 購読者へ通知するのは、これがヘッダに出ている値だから(`useDefaultModel`)。 + * 通知しないと `/model` やエージェント切替(既定モデルを CLI 既定へ戻す)のあとも + * 起動時の値が残り、実際に使われるモデルと食い違って見える。 */ setModel(model: string | undefined): void { if (this.options.model === model) { @@ -258,6 +262,7 @@ export class SessionManager { } this.options = { ...this.options, model }; this.deps.onModelChange?.(model); + this.store.notify(); } /** The repo-wide instructions appended to new sessions' systemPrompt (undefined → none). */ @@ -641,6 +646,10 @@ export class SessionManager { if (opts?.persist !== false) { this.deps.onDefaultAgentChange?.(agentId); } + // ヘッダの「エージェント / プラン / モデル / 使用状況」はこの値で出し分けるので + // 通知する(`useDefaultAgent`)。`setModel` も notify するが、既にモデル未設定 + // だった場合はそちらが no-op なので、ここで必ず 1 回起こす。 + this.store.notify(); return true; } diff --git a/src/main.tsx b/src/main.tsx index 5f2c21d..a3cae89 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -11,7 +11,7 @@ import { resolveIgnoredFilesMode, resolveLang, type SessionManager, - showsAccountUsage, + showsAccountInfo, summarizeStatuses, } from '@/core'; import { @@ -195,11 +195,11 @@ async function main(): Promise { const stopUsagePolling = startUsagePolling({ fetch: () => fetchUsageSnapshot(claudeQuery, { cwd: repoRoot, signal: probeAbort.signal }), apply: (snapshot) => manager.applyUsage(snapshot), - // ゲージを出さない構成(Codex / Grok だけで作業している)では probe も立てない。 - // 判定は一覧の表示条件と**同じ純関数**を通す(表示と取得が食い違わないように)。 + // プランもゲージも出さない構成(既定エージェントが Codex / Grok)では probe も + // 立てない。判定は一覧の表示条件と**同じ純関数**を通す(表示と取得が食い違わない + // ように)。既定を Claude へ戻せば次の poll から再開する。 enabled: () => - showsAccountUsage({ - sessions: manager.getSnapshot(), + showsAccountInfo({ defaultAgent: manager.getDefaultAgentId(), capabilities: capabilityLookup(manager.listAgents()), }), @@ -269,7 +269,6 @@ async function main(): Promise { manager.subscribe(onChange), + () => manager.getDefaultAgentId(), + () => manager.getDefaultAgentId(), + ); +} + +/** + * 新規セッションの既定モデル(`/model` で選ぶもの。エージェント切替で CLI 既定へ戻る)を + * 購読する。ヘッダのモデル行はここから引く(起動時の設定値を prop で配ると、切替後も + * 前の provider のモデル名が残る)。 + */ +export function useDefaultModel(manager: SessionManager): string | undefined { + return useSyncExternalStore( + (onChange) => manager.subscribe(onChange), + () => manager.getModel(), + () => manager.getModel(), + ); +} + /** Subscribe to the manager's global tool-approval mode (auto ⇄ confirm). */ export function useRunMode(manager: SessionManager): RunMode { return useSyncExternalStore( diff --git a/src/ui/session-list.tsx b/src/ui/session-list.tsx index 49428f3..f296b1c 100644 --- a/src/ui/session-list.tsx +++ b/src/ui/session-list.tsx @@ -49,7 +49,7 @@ import { rowLineAtPoint, type SessionManager, sessionAgentId, - showsAccountUsage, + showsAccountInfo, showsAgentColumn, showsBranchColumn, type TrainingOptIn, @@ -76,6 +76,8 @@ import { useBoxHeight, useClock, useCommandRunner, + useDefaultAgent, + useDefaultModel, useDragSelection, useInputHistory, useLifecycleAction, @@ -148,7 +150,6 @@ export const SessionList: FC<{ * 未解決)なら表示しない。 */ branch?: string; - model?: string; /** `/model` の選択肢(Claude Code のカタログ)。undefined は取得中。 */ models?: readonly ModelOption[]; /** @@ -201,7 +202,6 @@ export const SessionList: FC<{ onQuit, cwd, branch, - model, models, modelsByAgent, version, @@ -227,7 +227,11 @@ export const SessionList: FC<{ const agents = useMemo(() => manager.listAgents(), [manager]); const capabilities = useMemo(() => capabilityLookup(agents), [agents]); const agentNames = useMemo(() => new Map(agents.map((a) => [a.id, a.displayName])), [agents]); - const defaultAgent = manager.getDefaultAgentId(); + // 新規セッションの既定(`/agent`)と、そのエージェントの既定モデル(`/model`)。 + // どちらもヘッダに出ている値なので購読で読む — 切替の瞬間に、エージェント名・プラン・ + // モデル・使用状況が揃って新しいエージェントのものへ入れ替わる。 + const defaultAgent = useDefaultAgent(manager); + const model = useDefaultModel(manager); const now = useClock(1000); // 端末幅は PR セル(行末の固定幅列)のクリック当たり判定に、端末高は一覧の // 内部スクロール(収まる行数の算出)に使う。いずれもリサイズ追従。 @@ -598,10 +602,11 @@ export const SessionList: FC<{ } }; - // 使用状況ゲージ(アカウント全体の枠)は `usage` を報告する provider を使っている - // ときだけ出す。Codex / Grok だけで作業している人に Claude の枠を出しても読みようが - // なく(埋めているのは別のツール)、5 分ごとの probe サブプロセスも無駄になる。 - const showUsage = showsAccountUsage({ sessions, defaultAgent, capabilities }); + // アカウント節(プラン名 + 使用状況ゲージ)は、`usage` を報告するエージェントが + // 新規セッションの既定になっているときだけ出す。ヘッダは「次に動くエージェント」の + // 説明なので、`/agent` で Codex / Grok に切り替えたらプランも枠も一緒に引っ込む + // (どちらも同じ 1 回の probe が運ぶので判定も 1 つ。取得側も同じ関数を通す)。 + const showAccount = showsAccountInfo({ defaultAgent, capabilities }); // ヘッダの表示行。描画(Banner)と当たり判定(bannerCaretAt)で同じ配列を使う — // 行 index = 表示行という前提を共有しているので、片方だけ差し替えると選択がズレる。 @@ -615,7 +620,9 @@ export const SessionList: FC<{ // 金額を報告しない provider のセッションは合計に数えない(`cost` capability)。 // 混ぜたときに「Claude ぶんの合計」を全体のコストとして出さないため。 totalCostUsd: totalCostUsd(sessions, (agent) => agentSupports(capabilities, agent, 'cost')), - account, + // プラン名は claude.ai のアカウントの話なので、それを報告しないエージェントが + // 既定のときは出さない(Codex を選んでいる人に Claude のプランを見せない)。 + account: showAccount ? account : undefined, updateLatest: updateInfo?.latest, }); const headerText = bannerText(headerLines); @@ -1030,7 +1037,7 @@ export const SessionList: FC<{ { - manager.setModel(model); + current={model} + models={modelsByAgent?.[defaultAgent ?? 'claude'] ?? models} + onSelect={(next) => { + manager.setModel(next); setModelSelect(false); }} onCancel={() => setModelSelect(false)} @@ -1223,7 +1230,7 @@ export const SessionList: FC<{ ) : agentSelect ? ( { setAgentSelect(false); diff --git a/tests/app.test.tsx b/tests/app.test.tsx index 97dd5b9..cc3490c 100644 --- a/tests/app.test.tsx +++ b/tests/app.test.tsx @@ -3084,12 +3084,13 @@ describe('App list view (/agent default + availability)', () => { id: AgentId, displayName: string, availability?: { installed: boolean; loggedIn: boolean | 'unknown' }, + capabilities = NO_CAPABILITIES, ): AgentAdapter { return { id, displayName, loginCommand: id, - capabilities: NO_CAPABILITIES, + capabilities, open: () => ({ async *[Symbol.asyncIterator]() { // フェイクはイベントを流さない(配線だけを見る)。 @@ -3189,6 +3190,65 @@ describe('App list view (/agent default + availability)', () => { expect(stripAnsi(lastFrame() ?? '')).toContain('コーディングエージェントが見つかりません'); }); + /** + * ヘッダのアカウント節は「次に動くエージェント」の説明。`/agent` で既定を切り替えたら + * エージェント名・プラン・モデル・使用状況が**揃って**入れ替わる(かつてはプランと + * ゲージが Claude のまま残り、モデル欄は起動時の設定値が居座っていた)。 + */ + it('/agent で既定を切り替えるとヘッダのプラン / モデル / 使用状況も入れ替わる', async () => { + const reportsUsage = { ...NO_CAPABILITIES, usage: true, setModel: true }; + const claude = fakeAdapter( + 'claude', + 'Claude', + { installed: true, loggedIn: true }, + reportsUsage, + ); + const codex = fakeAdapter('codex', 'Codex', { installed: true, loggedIn: true }); + const manager = new SessionManager({ + worktrees, + agents: { claude, codex }, + agent: claude, + now: () => 0, + }); + manager.setModel('claude-opus-4-8'); + // 起動時の probe(usage-poller → applyUsage)で入る claude.ai のプランと枠。 + manager.applyUsage({ + account: { plan: 'Claude Team' }, + usage: { + limitsAvailable: true, + windows: [{ type: 'five_hour', utilization: 50, resetsAt: 45 * 60_000 }], + }, + }); + const { app, stdin, lastFrame } = renderFullscreen(, 24, 120); + await settle(() => lastFrame() ?? ''); + const before = stripAnsi(lastFrame() ?? ''); + expect(before).toContain('プラン: Claude Team'); + expect(before).toContain('エージェント: Claude'); + expect(before).toContain('モデル: claude-opus-4-8'); + expect(before).toContain('使用状況'); + expect(before).toContain('50%'); + + stdin.write('/agent'); + await flush(); + stdin.write('\r'); // コマンド実行 → 既定エージェントの選択ダイアログ + await settle(() => lastFrame() ?? ''); + stdin.write('\x1b[B'); // ↓ → Codex + await flush(); + stdin.write('\r'); // 確定 + await settle(() => lastFrame() ?? ''); + + const after = stripAnsi(lastFrame() ?? ''); + expect(after).toContain('エージェント: Codex'); + // Codex は claude.ai のプランも使用状況も報告しない(`usage` capability なし)。 + expect(after).not.toContain('プラン: Claude Team'); + expect(after).not.toContain('使用状況'); + expect(after).not.toContain('50%'); + // モデルは CLI 既定へ戻る(provider をまたいでモデル名を持ち回らない)。 + expect(after).toContain(`モデル: ${messages.ja.banner.defaultModel}`); + expect(after).not.toContain('claude-opus-4-8'); + app.unmount(); + }); + it('does not show the setup hint when an agent is installed', async () => { const { lastFrame } = renderList({ claude: fakeAdapter('claude', 'Claude', { installed: true, loggedIn: true }),