Skip to content

Commit 161c857

Browse files
authored
feat: /model コマンドでモデルを切り替える (#19)
## 概要 一覧画面のコンポーザで **`/model`** を送ると、Claude Code の `/model` と同様の モデル選択ダイアログが開き、使用モデルを切り替えられるようにしました。 ``` モデルを選択 ❯ デフォルト (推奨) ✔ — CLI 既定のモデルを使用 Opus — Opus 4.8 ・ 日常的で複雑なタスクに最適 Fable — Fable 5 ・ 最難関・長時間タスク向けの最上位 Sonnet — Sonnet 5 ・ 日常タスクを効率的に Haiku — Haiku 4.5 ・ 手早い回答に最速 ``` - 選んだモデルは **以降の新規セッションの既定** になります(実行中セッションは 起動時のモデルを維持 = 1 SDK セッション 1 モデル)。 - 選択は `~/.codiva/config.json` の `model` に保存され、次回起動にも引き継がれます。 - `デフォルト` は `model` 未設定(CLI 既定)を意味します。 ## 変更点 | レイヤ | 内容 | |--------|------| | `core/models.ts`(新規・純粋) | 選択可能なモデル一覧 `MODELS`(id ↔ モデル文字列)と `modelChoiceForConfig()` | | `core/commands.ts`(新規・純粋) | `/model` スラッシュコマンドの解析 `parseSlashCommand()` | | `core/i18n.ts` | `model` メッセージグループを ja/en に追加(見出し・説明・操作ヒント) | | `core/session-manager.ts` | `SessionOptions` を可変保持し `getModel()`/`setModel()` を追加。`setModel` は `onModelChange` で永続化通知 | | `ui/model-select.tsx`(新規) | モデル選択ダイアログ。↑↓ 移動・Enter 決定・Esc キャンセル・現在値に `✔`。`PermissionDialog` に倣ったモーダル | | `ui/session-list.tsx` | コンポーザ送信時に `/model` を横取りしてダイアログを開く。モーダル中はキーを委譲 | | `src/index.tsx`(合成ルート) | `onModelChange` を `saveConfig()` に配線し `~/.codiva/config.json` にマージ保存 | | `docs/` | ARCHITECTURE / TASKS を更新 | ## 設計メモ - アーキテクチャ規約に沿い、ロジック(モデル一覧・コマンド解析・切替)は `core` の 純関数/クラスに置き、I/O(config 保存)は合成ルートで DI 注入。 - モデル一覧は静的定義(`claude-api` の現行ラインナップ: Opus 4.8 / Fable 5 / Sonnet 5 / Haiku 4.5)。SDK の `supportedModels()` はライブ query が必要なため採用せず、 起動直後や実行中セッションが無い状態でも選べることを優先。 - i18n 規約: 表示文字列は全てカタログに集約。ブランド名(Opus 等)も含め ja/en 対で追加 (`i18n.spec.ts` のキー集合一致テストが番人)。 ## テスト - [x] `npm run typecheck` — パス - [x] `npm run lint` — パス(既存の警告3件のみ/本 PR 由来の指摘なし) - [x] `npm test` — 373 件パス(core/utils カバレッジ 80% 維持) - [x] `npm run build` — tsup バンドル成功 - 追加テスト: - `core/models.spec.ts`: 一覧の内容・`modelChoiceForConfig` のフォールバック - `core/commands.spec.ts`: `/model` 判定(前後空白・大文字小文字・非コマンドの除外) - `core/session-manager.spec.ts`: `get/setModel`・`onModelChange` の発火条件・新規セッションへの反映 - `ui/model-select.spec.tsx`: 描画・現在値マーク・選択(default→undefined)・キャンセル ### 手動確認 TODO - [ ] 使い捨てリポジトリで TUI 起動 → `/model` → 各モデル選択 → `~/.codiva/config.json` に反映されること - [ ] 切替後の新規セッションが選んだモデルで起動すること(既存セッションは維持)
1 parent de69fbc commit 161c857

15 files changed

Lines changed: 432 additions & 9 deletions

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ interface SessionState {
143143
- `dispose()`: 全セッションを **`stop()`(quiet)**(worktree は残す)。実行中でも resumable なまま。
144144
- `onTransition(prev,next)`: ステータス遷移ごとに発火(デスクトップ通知に配線)。
145145
- `onPersist()`: 永続対象が変わった合図(合成ルートで debounce 保存に配線)。`persistableState()` が state.json 用スナップショットを組み立てる。
146+
- **モデル切替(`/model`)**: `SessionOptions` を可変フィールドとして保持し、`getModel()` / `setModel(model)` で公開。`setModel` は**以降の新規セッション**に適用(実行中セッションは起動時のモデルを維持)し、`onModelChange(model)` で合成ルートに通知 → `~/.codiva/config.json` の `model` にマージ保存される。選択肢は `core/models.ts`(`MODELS`)、コマンド解析は `core/commands.ts`(`parseSlashCommand`)。
146147
- `restore(persisted)`: 起動時に前回セッションを再構築(worktree meta を再配線し、`Session` に `resume`/`restored` を渡す。id/slug を予約して衝突回避)。
147148

148149
### WorktreeManager (`core/worktree.ts`)

‎docs/TASKS.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ UI なし。すべてユニットテストで駆動する。
121121
- [x] 入力欄の複数行化(`core/text-buffer.ts` + `ui/input.ts`。Shift+Enter/末尾`\`+Enter で改行、`INPUT_MAX_ROWS` まで伸び超過は内部スクロール)
122122
- [x] アプリ再起動後のセッション復元(`.codiva/state.json` + SDK `resume`)
123123
- [x] 設定ファイル(model / effort / permissionMode / maxBudgetUsd)
124+
- [x] `/model` コマンドでモデル切替(一覧画面のコンポーザで `/model` → モデル選択ダイアログ。
125+
選択は以降の新規セッションの既定になり `~/.codiva/config.json` に保存。`core/models.ts` /
126+
`core/commands.ts` / `ui/model-select.tsx` / `SessionManager.get|setModel`)
124127
- [x] コスト表示(result の total_cost_usd 累計)
125128
- [x] includePartialMessages によるストリーミング表示(`stream_event` の text_delta を `streamingText` に連結し詳細ビューにプレビュー)
126129
- [x] デスクトップ通知(質問・完了時)

‎src/core/commands.spec.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ describe('matchCommands', () => {
6262
it('prefix-matches the typed name', () => {
6363
expect(matchCommands('/q').map((c) => c.name)).toEqual(['quit']);
6464
expect(matchCommands('/h').map((c) => c.name)).toEqual(['help']);
65+
expect(matchCommands('/mo').map((c) => c.name)).toEqual(['model']);
6566
});
6667
it('matches on aliases too', () => {
6768
expect(matchCommands('/ex').map((c) => c.name)).toEqual(['quit']);
@@ -79,6 +80,9 @@ describe('runCommand', () => {
7980
it('resolves aliases', () => {
8081
expect(runCommand('/exit')).toEqual({ kind: 'run', command: findCommand('quit') });
8182
});
83+
it('resolves /model to the model command', () => {
84+
expect(runCommand('/model')).toEqual({ kind: 'run', command: findCommand('model') });
85+
});
8286
it('treats a bare slash as help (no false unknown)', () => {
8387
expect(runCommand('/')).toEqual({ kind: 'run', command: findCommand('help') });
8488
});

‎src/core/commands.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import type { Messages } from './i18n';
1515

1616
/** コマンドが UI に要求する動作。新コマンド追加時はここに足して UI で受ける。 */
17-
export type CommandAction = 'help' | 'quit';
17+
export type CommandAction = 'help' | 'quit' | 'model';
1818

1919
/** 1 つのスラッシュコマンドの定義。 */
2020
export interface CommandSpec {
@@ -33,6 +33,7 @@ export interface CommandSpec {
3333
* 表示順はこの配列順。
3434
*/
3535
export const COMMANDS: readonly CommandSpec[] = [
36+
{ name: 'model', action: 'model', describe: (m) => m.command.model },
3637
{ name: 'help', aliases: ['?'], action: 'help', describe: (m) => m.command.help },
3738
{ name: 'quit', aliases: ['exit', 'q'], action: 'quit', describe: (m) => m.command.quit },
3839
];

‎src/core/i18n.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
* 複数形などは、型安全に保つため文字列テンプレート関数として持つ。
77
*/
88

9+
import type { ModelId } from './models';
10+
911
export type Lang = 'ja' | 'en';
1012

1113
/** サポート言語の一覧(順序は UI での並びに使える)。 */
@@ -81,6 +83,21 @@ export interface Messages {
8183
questionTitle: (index: number, total: number, header: string) => string;
8284
questionHelp: (multiSelect: boolean) => string;
8385
};
86+
/** モデル選択ダイアログ(model-select.tsx。/model コマンドで開く) */
87+
model: {
88+
/** ダイアログ見出し */
89+
title: string;
90+
/** ダイアログ下部の操作ヒント */
91+
help: string;
92+
/** 既定モデルに付ける「推奨」ラベル */
93+
recommended: string;
94+
/** 選択確定後のフッタ通知(name は選んだモデルの表示名) */
95+
saved: (name: string) => string;
96+
/** 選択肢の表示名(ブランド名。default のみ翻訳語) */
97+
names: Record<ModelId, string>;
98+
/** 選択肢の説明文 */
99+
descriptions: Record<ModelId, string>;
100+
};
84101
/** アプリ全体(index.tsx) */
85102
app: {
86103
remainingWorktrees: (n: number) => string;
@@ -120,6 +137,8 @@ export interface Messages {
120137
help: string;
121138
/** /quit の説明 */
122139
quit: string;
140+
/** /model の説明 */
141+
model: string;
123142
};
124143
}
125144

@@ -182,6 +201,26 @@ const ja: Messages = {
182201
questionHelp: (multiSelect) =>
183202
`↑↓: 選択 ・ ${multiSelect ? 'Space: トグル ・ ' : ''}Enter: 決定`,
184203
},
204+
model: {
205+
title: 'モデルを選択',
206+
help: '↑↓: 選択 ・ Enter: 決定 ・ Esc: キャンセル',
207+
recommended: '推奨',
208+
saved: (name) => `モデルを ${name} に変更しました(以降の新規セッションに適用)`,
209+
names: {
210+
default: 'デフォルト',
211+
opus: 'Opus',
212+
fable: 'Fable',
213+
sonnet: 'Sonnet',
214+
haiku: 'Haiku',
215+
},
216+
descriptions: {
217+
default: 'CLI 既定のモデルを使用',
218+
opus: 'Opus 4.8 ・ 日常的で複雑なタスクに最適',
219+
fable: 'Fable 5 ・ 最難関・長時間タスク向けの最上位',
220+
sonnet: 'Sonnet 5 ・ 日常タスクを効率的に',
221+
haiku: 'Haiku 4.5 ・ 手早い回答に最速',
222+
},
223+
},
185224
app: {
186225
remainingWorktrees: (n) =>
187226
`codiva: ${n} 個の worktree が残っています(作業内容は保持されます):`,
@@ -209,6 +248,7 @@ const ja: Messages = {
209248
unknown: (name) => (name ? `不明なコマンド: /${name}` : '不明なコマンドです'),
210249
help: 'コマンド一覧を表示',
211250
quit: 'codiva を終了',
251+
model: 'モデルを切り替え',
212252
},
213253
};
214254

@@ -271,6 +311,26 @@ const en: Messages = {
271311
questionHelp: (multiSelect) =>
272312
`↑↓: select · ${multiSelect ? 'Space: toggle · ' : ''}Enter: confirm`,
273313
},
314+
model: {
315+
title: 'Select model',
316+
help: '↑↓: select · Enter: confirm · Esc: cancel',
317+
recommended: 'recommended',
318+
saved: (name) => `Model set to ${name} (applies to new sessions)`,
319+
names: {
320+
default: 'Default',
321+
opus: 'Opus',
322+
fable: 'Fable',
323+
sonnet: 'Sonnet',
324+
haiku: 'Haiku',
325+
},
326+
descriptions: {
327+
default: 'Use the CLI default model',
328+
opus: 'Opus 4.8 · Best for everyday, complex tasks',
329+
fable: 'Fable 5 · Most capable for your hardest, longest tasks',
330+
sonnet: 'Sonnet 5 · Efficient for routine tasks',
331+
haiku: 'Haiku 4.5 · Fastest for quick answers',
332+
},
333+
},
274334
app: {
275335
remainingWorktrees: (n) =>
276336
`codiva: ${n} worktree${n === 1 ? '' : 's'} left in place (your work is preserved):`,
@@ -298,6 +358,7 @@ const en: Messages = {
298358
unknown: (name) => (name ? `Unknown command: /${name}` : 'Unknown command'),
299359
help: 'Show available commands',
300360
quit: 'Quit codiva',
361+
model: 'Switch the default model',
301362
},
302363
};
303364

‎src/core/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * from './i18n';
66
export * from './key-sequence';
77
export * from './layout';
88
export * from './model';
9+
export * from './models';
910
export * from './mouse';
1011
export * from './notify';
1112
export * from './persistence';

‎src/core/models.spec.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { MODELS, modelChoiceForConfig } from './models';
3+
4+
describe('MODELS', () => {
5+
it('starts with the CLI-default choice (unset)', () => {
6+
expect(MODELS[0]).toEqual({ id: 'default', model: undefined });
7+
});
8+
9+
it('has unique ids', () => {
10+
const ids = MODELS.map((c) => c.id);
11+
expect(new Set(ids).size).toBe(ids.length);
12+
});
13+
14+
it('maps brand ids to the current full model strings', () => {
15+
expect(MODELS.find((c) => c.id === 'opus')?.model).toBe('claude-opus-4-8');
16+
expect(MODELS.find((c) => c.id === 'fable')?.model).toBe('claude-fable-5');
17+
expect(MODELS.find((c) => c.id === 'sonnet')?.model).toBe('claude-sonnet-5');
18+
expect(MODELS.find((c) => c.id === 'haiku')?.model).toBe('claude-haiku-4-5');
19+
});
20+
});
21+
22+
describe('modelChoiceForConfig', () => {
23+
it('resolves a saved model string to its choice', () => {
24+
expect(modelChoiceForConfig('claude-sonnet-5').id).toBe('sonnet');
25+
});
26+
27+
it('falls back to default when unset', () => {
28+
expect(modelChoiceForConfig(undefined).id).toBe('default');
29+
});
30+
31+
it('falls back to default for an unknown model string', () => {
32+
expect(modelChoiceForConfig('claude-unknown-9').id).toBe('default');
33+
});
34+
});

‎src/core/models.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* 選択可能な Claude モデルの一覧(純粋)。UI(/model コマンドのモデル選択)と
3+
* 設定(config.model)の橋渡しをする。
4+
*
5+
* ここには id → SDK に渡すモデル文字列の対応だけを置く。表示名・説明文は
6+
* 翻訳対象なので i18n カタログ(core/i18n.ts の `model` グループ)に持つ。
7+
* ブランド名(Opus/Fable/…)は翻訳しないが、"Default" 等の語を含むため
8+
* 名称もカタログ側で解決する。
9+
*/
10+
11+
/** 選択肢の安定した識別子(設定保存・i18n キー・テストで使う)。 */
12+
export type ModelId = 'default' | 'opus' | 'fable' | 'sonnet' | 'haiku';
13+
14+
export interface ModelChoice {
15+
readonly id: ModelId;
16+
/** SDK query(および CLI)へ渡すモデル文字列。undefined は CLI 既定を使う。 */
17+
readonly model: string | undefined;
18+
}
19+
20+
/**
21+
* UI に並べる順の選択肢。先頭は「CLI 既定を使う」= 未設定(推奨)。
22+
* モデル文字列は claude-api の現行ラインナップ(Opus 4.8 / Fable 5 /
23+
* Sonnet 5 / Haiku 4.5)に合わせる。
24+
*/
25+
export const MODELS: readonly ModelChoice[] = [
26+
{ id: 'default', model: undefined },
27+
{ id: 'opus', model: 'claude-opus-4-8' },
28+
{ id: 'fable', model: 'claude-fable-5' },
29+
{ id: 'sonnet', model: 'claude-sonnet-5' },
30+
{ id: 'haiku', model: 'claude-haiku-4-5' },
31+
];
32+
33+
/**
34+
* 保存済み設定値(config.model)から対応する選択肢を引く。未設定・未知の値は
35+
* 「デフォルト(CLI 既定)」にフォールバックする(設定の手書きでも壊れないように)。
36+
*/
37+
export function modelChoiceForConfig(model: string | undefined): ModelChoice {
38+
return MODELS.find((c) => c.model === model) ?? { id: 'default', model: undefined };
39+
}

‎src/core/session-manager.spec.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,4 +582,59 @@ describe('SessionManager', () => {
582582
expect(manager.getSnapshot()).toBe(before);
583583
});
584584
});
585+
586+
describe('model selection (/model)', () => {
587+
it('seeds getModel from the injected options and updates on setModel', () => {
588+
const manager = new SessionManager({
589+
worktrees: fakeWorktrees(),
590+
queryFn: (() => {
591+
throw new Error('unused');
592+
}) as never,
593+
now: () => 1,
594+
options: { model: 'claude-opus-4-8' },
595+
createSession: ({ input, onChange }) => new FakeSession(input, onChange),
596+
});
597+
expect(manager.getModel()).toBe('claude-opus-4-8');
598+
manager.setModel('claude-haiku-4-5');
599+
expect(manager.getModel()).toBe('claude-haiku-4-5');
600+
});
601+
602+
it('fires onModelChange only when the model actually changes', () => {
603+
const onModelChange = vi.fn();
604+
const manager = new SessionManager({
605+
worktrees: fakeWorktrees(),
606+
queryFn: (() => {
607+
throw new Error('unused');
608+
}) as never,
609+
now: () => 1,
610+
options: { model: 'claude-opus-4-8' },
611+
onModelChange,
612+
createSession: ({ input, onChange }) => new FakeSession(input, onChange),
613+
});
614+
manager.setModel('claude-opus-4-8'); // no-op: same value
615+
expect(onModelChange).not.toHaveBeenCalled();
616+
manager.setModel(undefined); // back to CLI default
617+
expect(onModelChange).toHaveBeenCalledWith(undefined);
618+
});
619+
620+
it('applies the selected model to sessions created afterward', async () => {
621+
const models: (string | undefined)[] = [];
622+
const manager = new SessionManager({
623+
worktrees: fakeWorktrees(),
624+
// Real Session path (no createSession factory) so options reach the query.
625+
queryFn: ((params: { options: { model?: string } }) => {
626+
models.push(params.options.model);
627+
return (async function* () {})();
628+
}) as never,
629+
now: () => 1,
630+
options: { model: 'claude-opus-4-8' },
631+
});
632+
manager.create('first');
633+
await flush();
634+
manager.setModel('claude-haiku-4-5');
635+
manager.create('second');
636+
await flush();
637+
expect(models).toEqual(['claude-opus-4-8', 'claude-haiku-4-5']);
638+
});
639+
});
585640
});

‎src/core/session-manager.ts‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ export interface SessionManagerDeps {
6464
onTransition?: (prev: SessionState, next: SessionState) => void;
6565
/** Called (as a dirty signal) whenever the persistable set changes; wired to a debounced save. */
6666
onPersist?: () => void;
67+
/** Called when the default model for new sessions changes (via /model); wired to persist the config file. */
68+
onModelChange?: (model: string | undefined) => void;
6769
/** Optional PR lookup (via `gh`); when set, refreshPrs() polls each live session's branch. */
6870
lookupPr?: PrLookup;
6971
/** Factory for a session; defaults to constructing a real Session. `resume`/`restored` are set when rehydrating. */
@@ -95,9 +97,16 @@ export class SessionManager {
9597
private seq = 0;
9698
private mode: RunMode = 'auto';
9799
private readonly now: () => number;
100+
/**
101+
* Live per-session knobs forwarded to each new Session. Seeded from
102+
* deps.options (the config file) but mutable so /model can change the default
103+
* model for sessions created later in this run.
104+
*/
105+
private options: SessionOptions;
98106

99107
constructor(private readonly deps: SessionManagerDeps) {
100108
this.now = deps.now ?? Date.now;
109+
this.options = { ...deps.options };
101110
}
102111

103112
subscribe(listener: Listener): () => void {
@@ -107,6 +116,23 @@ export class SessionManager {
107116
};
108117
}
109118

119+
/** The model used for new sessions (undefined → CLI default). */
120+
getModel(): string | undefined {
121+
return this.options.model;
122+
}
123+
124+
/**
125+
* Set the default model for sessions created from now on (via /model). Already
126+
* running sessions keep the model they started with. Persists via onModelChange.
127+
*/
128+
setModel(model: string | undefined): void {
129+
if (this.options.model === model) {
130+
return;
131+
}
132+
this.options = { ...this.options, model };
133+
this.deps.onModelChange?.(model);
134+
}
135+
110136
/** Current tool-approval mode (drives the shift+tab footer indicator). */
111137
getMode(): RunMode {
112138
return this.mode;
@@ -185,7 +211,7 @@ export class SessionManager {
185211
new Session({
186212
queryFn: this.deps.queryFn,
187213
input,
188-
options: this.deps.options,
214+
options: this.options,
189215
now: this.now,
190216
policy: this.deps.policy ?? this.modePolicy,
191217
onChange: (s) => this.onSessionChange(id, s),
@@ -248,7 +274,7 @@ export class SessionManager {
248274
new Session({
249275
queryFn: this.deps.queryFn,
250276
input,
251-
options: this.deps.options,
277+
options: this.options,
252278
now: this.now,
253279
policy: this.deps.policy ?? this.modePolicy,
254280
onChange,

0 commit comments

Comments
 (0)