diff --git a/TODO.md b/TODO.md index edfc021a..bb5a651f 100644 --- a/TODO.md +++ b/TODO.md @@ -8,8 +8,9 @@ - [ ] ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md) - [ ] TUI 拡充 - [ ] フルスクリーン化によるオーバーホール → [tickets/tui-fullscreen-overhaul.md](tickets/tui-fullscreen-overhaul.md) - - [ ] inline viewport で Pod を spawn する UX → [tickets/tui-pod-spawn-ui.md](tickets/tui-pod-spawn-ui.md) - [ ] Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md) + - [ ] ホームディレクトリ配下の整理 → [tickets/home-dir-layout.md](tickets/home-dir-layout.md) + - [ ] ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md) - [ ] サブミット入力 - [ ] TUI 補完 + 型付き atom 化 → [tickets/submit-tui-completion.md](tickets/submit-tui-completion.md) - [ ] セッションログの Segment 保持 → [tickets/session-log-segments.md](tickets/session-log-segments.md) diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 02650568..c318c626 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -10,6 +10,7 @@ mod ui; use std::io; use std::path::PathBuf; +use std::process::ExitCode; use std::time::Duration; use crossterm::event::{ @@ -76,11 +77,18 @@ fn parse_args() -> Mode { } #[tokio::main] -async fn main() -> Result<(), Box> { +async fn main() -> ExitCode { let mode = parse_args(); - enable_raw_mode()?; - execute!(io::stdout(), EnableBracketedPaste)?; + if let Err(e) = enable_raw_mode() { + eprintln!("tui: failed to enter raw mode: {e}"); + return ExitCode::FAILURE; + } + if let Err(e) = execute!(io::stdout(), EnableBracketedPaste) { + let _ = disable_raw_mode(); + eprintln!("tui: {e}"); + return ExitCode::FAILURE; + } let result = match mode { Mode::Spawn => run_spawn().await, @@ -90,13 +98,28 @@ async fn main() -> Result<(), Box> { } => run_attach(pod_name, socket_override).await, }; - // Always restore the terminal, even on error or panic-after-result. + // Always restore the terminal first so any pending eprintln below + // shows up cleanly in scrollback rather than inside an active + // alternate-screen buffer. let mut stdout = io::stdout(); let _ = execute!(stdout, LeaveAlternateScreen, DisableBracketedPaste); let _ = disable_raw_mode(); let _ = execute!(stdout, crossterm::cursor::Show); - result + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + // SpawnError has already been painted into the inline + // viewport's final frame, so it's already visible in the + // user's scrollback — printing it again would be a noisy + // duplicate. Other errors (attach-mode failures, terminal + // setup hiccups, etc.) need surfacing here. + if e.downcast_ref::().is_none() { + eprintln!("tui: {e}"); + } + ExitCode::FAILURE + } + } } async fn run_attach( diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs index 0142ef55..48f4590d 100644 --- a/crates/tui/src/spawn.rs +++ b/crates/tui/src/spawn.rs @@ -135,6 +135,7 @@ pub async fn run() -> Result { name_cursor: default_name.chars().count(), name: default_name, message: None, + editing: true, }; let mut terminal = make_inline_terminal()?; @@ -153,6 +154,7 @@ pub async fn run() -> Result { break; } Some(Action::Cancel) => { + form.editing = false; form.message = Some(("cancelled".to_string(), MessageKind::Info)); terminal.draw(|f| draw_form(f, &form))?; drop(terminal); @@ -170,7 +172,11 @@ pub async fn run() -> Result { let overlay_toml = build_overlay_toml(&form); - // Phase 2: launch pod and wait for ready line. + // Phase 2: launch pod and wait for ready line. Drop the cursor + // out of the name field — subsequent frames are passive status + // updates, not input — so the cursor doesn't end up parked there + // when the inline terminal is finally dropped. + form.editing = false; form.message = Some(("starting pod...".to_string(), MessageKind::Progress)); terminal.draw(|f| draw_form(f, &form))?; @@ -425,6 +431,12 @@ struct Form { /// char-based bookkeeping in case we relax `is_safe_name_char`. name_cursor: usize, message: Option<(String, MessageKind)>, + /// True while the dialog is accepting name input. Drives whether + /// the rendered frame parks the terminal cursor inside the name + /// field — when false (post-confirm / cancel / failure frames) the + /// cursor stays out so it does not collide with the shell prompt + /// after the inline terminal is dropped. + editing: bool, } impl Form { @@ -499,9 +511,16 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) { f.render_widget(Paragraph::new(hint_line()), layout[3]); f.render_widget(Paragraph::new(message_line(form)), layout[4]); - // Place the cursor inside the name field (col 8 = " name: ".len()). - let cursor_col = 2 + "name: ".len() + form.name_cursor; - f.set_cursor_position((layout[1].x + cursor_col as u16, layout[1].y)); + if form.editing { + // Place the cursor inside the name field while the user is + // editing. Skipped on post-confirm frames so the inline + // viewport's drop leaves the cursor at the bottom of the + // rendered area rather than parked on the name line, which + // would let the shell prompt (or any later eprintln) clobber + // the rendered name field after exit. + let cursor_col = 2 + "name: ".len() + form.name_cursor; + f.set_cursor_position((layout[1].x + cursor_col as u16, layout[1].y)); + } } fn name_line(form: &Form) -> Line<'_> { @@ -578,6 +597,7 @@ mod tests { name: name.to_string(), name_cursor: name.chars().count(), message: None, + editing: true, } } diff --git a/tickets/home-dir-layout.md b/tickets/home-dir-layout.md new file mode 100644 index 00000000..0d755468 --- /dev/null +++ b/tickets/home-dir-layout.md @@ -0,0 +1,93 @@ +# ホームディレクトリ配下のディレクトリ整理 + +## 背景 + +現状、Insomnia のホーム配下のファイルは 2 つのツリーに分かれていて、規約が一貫していない: + +- `$XDG_CONFIG_HOME/insomnia/` (fallback `~/.config/insomnia/`) + - `manifest.toml` (user manifest) + - `providers.toml` / `models.toml` (catalog user override、`crates/provider/src/catalog.rs`) + - `prompts/` / `prompts.toml` (user prompts、`crates/pod/src/prompt/loader.rs`) +- `~/.insomnia/` + - `sessions/` (session store、`crates/pod/src/main.rs:default_store_dir`) + - `run/` (XDG_RUNTIME_DIR 不在時の socket fallback、`crates/pod/src/runtime/dir.rs:default_runtime_base`) + +config 系は XDG に従っているが、data / runtime 系は `~/.insomnia/` 直下に独自に積まれている。新しい機能(モデル設定 wizard 等)を足す前に、どこに何を置くかをはっきりさせたい。 + +## 方針候補 + +### 案 A: XDG 完全準拠 + +各ディレクトリを XDG Base Directory Specification に分ける: + +| 種別 | XDG 変数 | fallback | 内容 | +|---|---|---|---| +| config | `$XDG_CONFIG_HOME/insomnia/` | `~/.config/insomnia/` | manifest / catalog / prompts | +| data | `$XDG_DATA_HOME/insomnia/` | `~/.local/share/insomnia/` | sessions | +| state | `$XDG_STATE_HOME/insomnia/` | `~/.local/state/insomnia/` | logs (将来) | +| runtime | `$XDG_RUNTIME_DIR/insomnia/` | `~/.cache/insomnia/run/` 等 | socket / scope lock | + +**Pros**: 標準に従う。バックアップ対象(data)と捨てて良いもの(runtime)が分離する。 +**Cons**: ディレクトリが 3〜4 箇所に分散して把握しにくい。fallback 経路の選択も複雑。 + +### 案 B: `~/.insomnia/` 一元化 + +`~/.insomnia/` 配下に config も data も runtime も全部置く: + +``` +~/.insomnia/ + manifest.toml # 旧 ~/.config/insomnia/manifest.toml + providers.toml # 旧 ~/.config/insomnia/providers.toml + models.toml + prompts/ + sessions/ + run/ # XDG_RUNTIME_DIR があればそちら、無ければここ +``` + +XDG 系の env が設定されていればそれを優先、無ければ `~/.insomnia/` で完結。 + +**Pros**: 全部同じ場所、ファイル探索 / バックアップ / 移行が楽。`~/.config/insomnia/` を見たときに「あれ、設定ここだっけ?」とならない。 +**Cons**: XDG 規約から外れる。Linux ユーザの期待とずれる可能性。 + +### 案 C: ハイブリッド(現状の整理) + +config だけ XDG、data / runtime は `~/.insomnia/` 配下を維持。位置は変えず命名と responsibility を整理: + +``` +$XDG_CONFIG_HOME/insomnia/ # 永続的な設定(人間が手で書く / 編集する) + manifest.toml + providers.toml + models.toml + prompts/ +~/.insomnia/ # ランタイム & データ(プログラム生成・状態) + sessions/ + run/ + cache/ # 将来用 +``` + +**Pros**: 「人が編集するもの」と「プロセスが書くもの」が物理的に分かれる。現状からの移行が小さい。 +**Cons**: XDG 半準拠なので、XDG_DATA_HOME を尊重したいユーザは別途設定が必要。 + +## 設計で決めること + +- **どの案を採用するか** (A / B / C のどれか、または別案) +- **環境変数による override**: `INSOMNIA_HOME` / `INSOMNIA_CONFIG_DIR` / `INSOMNIA_DATA_DIR` / `INSOMNIA_RUNTIME_DIR` のような上書き口を入れるか +- **マイ採用しないション**: 既存の `~/.insomnia/sessions/` / `~/.config/insomnia/manifest.toml` 配置を持つユーザの data を新パスに引き継ぐか、初回起動時にメッセージだけ出して移動はユーザに任せるか +- **socket / scope_lock の扱い**: runtime はマシン再起動で消えて良いものなので XDG_RUNTIME_DIR 優先で良いか、`~/.insomnia/run/` が常に唯一の置き場で良いか +- **prompts / catalog override の置き場**: config に近いので config 側でほぼ確定だが、project ローカル prompts (`/.insomnia/prompts/`) との対応関係を整理する + +## 完了条件 + +- 各ディレクトリの責務(config / data / state / runtime)と置き場が docs か module レベル comment に明記されている +- pod / tui 各クレートの該当箇所(`pod/src/main.rs` / `pod/src/runtime/dir.rs` / `pod/src/runtime/scope_lock.rs` / `manifest/src/cascade.rs` / `provider/src/catalog.rs` / `pod/src/prompt/loader.rs`)が新規約に揃っている +- 既存ユーザ向けのマイ採用しないションパスが決まっている(自動 / 手動 / 何もしないのいずれか明文化) +- 新しいレイアウトで pod の起動と tui の spawn flow が引き続き動く + +## 範囲外 + +- Windows / macOS のネイティブ規約(`%APPDATA%` / `~/Library/Application Support` 等)への対応。本チケットは Linux / 一般的な Unix 想定 +- TUI 側で setup wizard が「どこに何を書く」表示するかの UX。本チケットの結論を `tickets/tui-user-model-setup.md` が前提として使う + +## 後続チケット + +- `tickets/tui-user-model-setup.md`: 本チケットで確定したレイアウトに従って user manifest を書き込む wizard を実装する diff --git a/tickets/tui-pod-spawn-ui.md b/tickets/tui-pod-spawn-ui.md deleted file mode 100644 index 56ab2ad4..00000000 --- a/tickets/tui-pod-spawn-ui.md +++ /dev/null @@ -1,106 +0,0 @@ -# TUI: inline viewport で Pod を spawn する UX - -## 背景 - -現在の Pod 起動はシェル直叩きで、`pod` バイナリに manifest を渡して即フルスクリーン TUI に入る形になっている (`start_pod.local.fish` 参照)。ユーザーの自然なメンタルモデルは「ワークスペースに `cd` して、最上位エージェントを 1 個立ち上げ、そのセッションでオーケストレーションを進める」というもので、現状はこの「立ち上げる」操作の前段が貧弱。 - -複数 Pod の並列実行は別ターミナル / tmux で別プロセスとして並べる運用を想定するため、TUI 自体を multi-pod 化する方向には踏み込まない。**1 シェル起動 = 1 Pod に attach** の前提を維持したまま、起動の前段だけ対話的にする。 - -manifest カスケード (`crates/pod/src/factory.rs`) の前提: - -- **user manifest** (`~/.config/insomnia/manifest.toml`): `model` と `auth` 等、ユーザー横断で固定したい設定の置き場。spawn UI はここに `model` があることを前提にする -- **project manifest** (`/.insomnia/manifest.toml`): あれば `pod.name` / `scope.allow` を上書きする -- **overlay**: spawn UI が dialog 入力からその場で組み立てて pod に渡す最高優先度のレイヤ - -spawn UI の役割は、project manifest が無いワークスペースでも overlay を組んで起動できるようにし、`.insomnia/manifest.toml` を作る手間を省くこと。 - -## 方針 - -- 起動コマンドはまず **inline viewport** でダイアログを描く(ratatui の `Viewport::Inline`) -- ダイアログで manifest と起動パラメータを確定 → Pod を spawn → そのまま **fullscreen TUI** (alternate screen buffer, `tickets/tui-fullscreen-overhaul.md`) に attach する -- ダイアログのやり取りはシェルのスクロールバックに残す。「何を spawn したか」がログとして自然に残るのがこの方式の主眼 -- キャンセル経路では fullscreen に入らずシェルに戻る - -`tui-fullscreen-overhaul.md` は attach 後の本体描画モデル、本チケットはその**前段の対話 UI と attach までの遷移**を扱う。両者は viewport の使い分け(inline → alternate screen)で繋がる。 - -## 要件 - -### エントリポイント - -- ワークスペース直下で叩いて Pod を立ち上げるコマンドを 1 本提供する(既存 `pod` バイナリの起動経路を流用するか別サブコマンドにするかは設計で決める) -- 引数なしで叩いた場合は inline ダイアログに入る -- manifest を引数で渡された場合はダイアログをスキップして直接 fullscreen に入る(既存の動作を保つ) - -### inline ダイアログ - -- ratatui の inline viewport で、シェル直下の数行に描く -- 入力項目: - - **pod.name** (1 行テキスト、編集可): デフォルト値は project manifest の `pod.name`、無ければ cwd の basename - - その他の必要設定(model、scope.allow)はカスケード or デフォルトで補う(後述) -- 確定 / キャンセルが明示的なキー操作で、誤爆しないこと -- 確定すると Pod 起動が始まる。起動中の進捗を inline 領域に流して、attach 完了の瞬間に fullscreen へ切り替える -- キャンセル時は inline 領域を畳んでシェルに戻り、Pod は spawn しない - -### 必須フィールドの埋め方 - -pod は `pod.name` / `model` / `scope.allow` がそろわないと起動しない。spawn UI はそれぞれを次のように埋める: - -- **`pod.name`**: ダイアログ入力。未入力で Enter は不可。デフォルト値は user / project manifest に既に書かれていればそれ、無ければ cwd の basename -- **`model`**: user / project どちらかのレイヤから cascade 経由で取得。どこにも無ければ pod 側の resolve が失敗するので、その stderr エラー文を inline ダイアログに表示してキャンセル相当に倒す(user manifest の編集 UI までは本チケット外) -- **`scope.allow`**: user / project どちらかのレイヤに既にあればそれをそのまま使う(overlay には追加しない)。両方とも無ければ `target = , permission = "write"` をデフォルトとして overlay に追加する - -tui は `manifest` クレートの `PodManifestConfig::from_toml` / `merge` を使って user + project の cascade を実際にマージし、その結果から「`scope.allow` が空かどうか」を読み取る。実際の最終マージ + バリデーションは pod の `PodFactory::resolve()` 側で行われるので、tui は dialog 用の事前情報を取るためだけに同じ仕組みを再実行する形になる(重い `pod` クレート全体には依存しない)。 - -### `manifest` クレートへのカスケード収集 API 移管 - -カスケードのファイルシステム規約(user manifest の XDG パス、project manifest の `.insomnia/manifest.toml` 上方探索、TOML 読み込み + パース)は、当初 pod の `factory.rs` 内 private に実装されていた。本チケットで tui が同じ規約を必要としたため、二箇所に重複させるのではなく **`manifest` クレートに公開 API として移管**する。 - -- `manifest::user_manifest_path() -> Option` -- `manifest::find_project_manifest_from(start: &Path) -> Option` -- `manifest::load_layer(path: &Path) -> Result` - -pod の `PodFactory` も tui の spawn UI もこれらを呼ぶ形に統一し、規約は manifest に一箇所で持つ。`PodFactory` 自体(builder + PromptLoader 抱える型)は引き続き pod の責務として残す。 - -確定時、ダイアログ入力 + デフォルト埋めから overlay TOML を組み、pod に `--overlay` で渡す。pod 側の cascade は user → project → overlay の順で merge されるので、project manifest に値があれば overlay の同名フィールドだけが上書きする形になる。 - -### inline → fullscreen の遷移 - -- inline viewport を畳む → alternate screen に切り替える、を**ちらつかず**に実行する -- 切り替え後、Pod の `Event::History` で履歴を組み直して通常の TUI 状態に入る(ここから先は fullscreen overhaul の責務) -- 切り替え失敗(Pod 起動失敗等)の場合は inline 領域にエラーを出して終了する。alternate screen には入らない - -### スクロールバックに残るもの - -- inline ダイアログで確定した spawn の要約(manifest path、override の要点) -- 起動失敗時のエラー -- 正常 attach した場合の「attach 開始」ログ 1 行程度 - -fullscreen TUI で描いた内容は alternate screen buffer なのでスクロールバックには残らない(既存方針通り)。 - -## 設計で決めること - -- **`pod.name` の文字種制約**: runtime dir 名に使われるのでファイルシステム安全な範囲に絞る(英数 + `-` + `_` + `.` 等) -- **scope デフォルトの permission**: `write` で良いか、対話的に `read` / `write` を切り替えさせるか -- **キーバインド**: 確定 / キャンセル / 項目移動。fullscreen 側のキーマップと衝突しないこと -- **進捗表示の粒度**: Pod 側の起動シーケンスのどのフェーズを inline に出すか -- **再 attach の入り口**: 既存 Pod に後から attach するユースケースを今回扱うか、扱うなら inline ダイアログの中に「新規 spawn / 既存 attach」の分岐を置くか別コマンドに分けるか -- **user manifest 不在時の扱い**: 「先に `~/.config/insomnia/manifest.toml` を作ってください」とエラーするか、user manifest 編集 UI までこのチケットで踏み込むか - -## 完了条件 - -- ワークスペースで該当コマンドを引数なしで叩くと inline ダイアログが立ち上がる -- ダイアログで `pod.name` を確定すると、必要なら scope.allow デフォルトを埋めた overlay が組まれて Pod が spawn され、そのまま fullscreen TUI に attach する -- project manifest が無いワークスペースでも、user manifest に model があれば spawn できる -- user manifest に model が無いと、ダイアログ内で何が足りないかが分かるエラーが出る -- ダイアログでキャンセルするとシェルに戻り、Pod は起動していない -- manifest を引数で直接渡した(または既存 Pod 名で attach した)場合はダイアログを経由せず従来通り fullscreen に入る -- 確定した spawn の要約と、起動失敗時のエラーがスクロールバックに残る -- inline → fullscreen の遷移でターミナル表示が破綻しない - -## 範囲外 - -- TUI の中で複数 Pod を tab / split / list で切り替える UI -- Pod 間メッセージパッシング、依存関係 -- 既存 Pod への再 attach(扱うかは「設計で決めること」で判断、扱わないと決まれば別チケット) -- Pod テンプレートの管理 UI(保存・編集・共有) -- リモート Pod / 分散実行 diff --git a/tickets/tui-user-model-setup.md b/tickets/tui-user-model-setup.md new file mode 100644 index 00000000..a96f7358 --- /dev/null +++ b/tickets/tui-user-model-setup.md @@ -0,0 +1,85 @@ +# TUI: ユーザーマニフェストのモデル設定 wizard + +## 背景 + +spawn UI(`tickets/tui-pod-spawn-ui.md`)が `[model]` を user / project の cascade レイヤから取る前提なので、初回起動のユーザーは事前に `~/.config/insomnia/manifest.toml` を手で書く必要がある。catalog(`crates/provider/src/catalog.rs`)に provider / model の一覧と `AuthHint`(API key の env 名や Codex OAuth 等の認証方式)が既に揃っているので、これを使って TUI 内で対話的にセットアップできるようにする。 + +`provider::catalog` の公開 API: + +- `load_providers() -> Vec`: builtin + user override マージ済み +- `load_models() -> Vec`: 同上 +- `ProviderEntry`: `id` / `display_name` / `scheme` / `auth_hint` 等 +- `ModelEntry`: `id` / `provider` / `capability` +- `AuthHint`: `None` / `ApiKey { env: Option }` / `CodexOAuth` + +これらが「UI で何を選ばせ、何を聞くか」を直接ガイドしてくれる構造になっている。 + +## 要件 + +### 起動経路 + +- 専用サブコマンド `tui setup-model`(仮)として alt-screen TUI で起動する +- 引数なしで叩くと既存の spawn flow に入るので、それとは別の入口 + +### Wizard フロー + +1. **provider 選択**: `load_providers()` の結果をリスト表示。`display_name` を見せ、上下キー + Enter で選択 +2. **model 選択**: 選んだ provider の `id` で `load_models()` をフィルタしてリスト表示。1 つだけならスキップ可 +3. **認証情報入力**: 選んだ provider の `auth_hint` で分岐 + - `None` → スキップ + - `ApiKey { env: Some(name) }` → 「環境変数 `` を使う」または「key ファイルパスを入力」を選ばせる + - `ApiKey { env: None }` → key ファイルパス入力(絶対パス推奨、ホーム展開はする) + - `CodexOAuth` → 「`codex login` で OAuth を済ませてください」案内 + `~/.codex/auth.json` の存在チェック +4. **確認画面**: 書き込み内容のプレビュー(生成される TOML)を表示、Enter で確定 / Esc でキャンセル +5. **書き込み**: `~/.config/insomnia/manifest.toml`(または `$XDG_CONFIG_HOME` 配下、`manifest::user_manifest_path()`)に `[model]` を書く + +### 書き込みフォーマット + +catalog 由来なので `ref` 形式を採用する: + +```toml +[model] +ref = "/" + +[model.auth] +kind = "api_key" +file = "/abs/path/to/key" +``` + +`AuthHint::None` の場合は `[model.auth]` を省く。`CodexOAuth` の場合は `kind = "codex_oauth"`。 + +### 既存ファイルの扱い + +`~/.config/insomnia/manifest.toml` が既に存在する場合: + +- `[model]` セクションが無い → 末尾に追加 +- `[model]` セクションが既にある → 上書き確認を出す(既存の値をプレビュー表示してから) +- ファイル全体が壊れた TOML → エラー表示してキャンセル + +### キャンセル / エラー経路 + +- どのステップでも Esc / Ctrl-C で抜けられる。書き込み前ならファイルは触らない +- catalog 読み込み失敗 / ファイル書き込み失敗は alt-screen 内でエラー表示してから終了 + +## 設計で決めること + +- **API key ファイルパスの入力 UX**: テキスト入力欄でフリーフォーム、補完なしで良いか、`~` / `$HOME` 展開するか +- **環境変数で済ませる選択肢の見せ方**: ApiKey で env 指定がある場合、デフォルト「env を使う」かデフォルト「key ファイルを使う」か +- **多 provider / 多 model 時の選択 UI**: シンプルな縦リストか、検索フィルタ付きか +- **既存 `[model]` の上書き確認の粒度**: TOML 全体 diff か、変わるキーだけハイライトか + +## 完了条件 + +- `tui setup-model` サブコマンドで wizard が起動する +- catalog から provider / model 一覧を取って表示・選択できる +- `AuthHint` の各バリアントに対応した入力 UI が動く +- 確定すると `~/.config/insomnia/manifest.toml` に `[model]` が書き込まれる +- 既存ファイルの `[model]` 上書き時は確認が出る +- セットアップ後に spawn flow(引数なし `tui` 起動)が model resolve エラー無しで Pod を spawn できる + +## 範囲外 + +- catalog 自体の編集(新規 provider / model の追加)UI。`providers.toml` / `models.toml` の手書き運用は維持 +- 複数モデル設定(`[compaction.model]` 等)の wizard 化 +- project manifest (`.insomnia/manifest.toml`) への書き込み。本チケットは user 層のみ +- spawn flow からの自動誘導(model 不在検出時に「`m` で setup wizard を起動」分岐)。本チケット完了後に spawn UI 側で別途検討