diff --git a/README.md b/README.md index 84004a5..36183ee 100644 --- a/README.md +++ b/README.md @@ -293,7 +293,23 @@ claude.ai の設定「**Help improve our AI models**」が ON のアカウント ファイルを直接編集するほか、一覧画面のコンポーザで **`/prompt`** と入力すると TUI 内エディタが開きます(現在の内容をシード。`Enter` で保存、`Shift+Enter` で改行、`Esc` で取消、空で保存すると削除)。保存内容は**以降の新規セッション**に反映されます(稼働中のセッションは起動時の指示を維持)。 -利用できるスラッシュコマンドは、コンポーザで `/` を入力するとパレット表示されます(`/prompt`・`/model`・`/sync`・`/fix-ci`・`/recover`・`/clear`・`/update`・`/help` など)。`/clear` は完了・中断・失敗など**終了済みのセッションを一覧から消去**します(実行中のセッションは残ります)。worktree やコミット履歴はディスク上に残るため作業自体は失われませんが、消去したセッションは codiva を再起動しても一覧に戻りません。 +利用できるスラッシュコマンドは、コンポーザで `/` を入力するとパレット表示されます(`/prompt`・`/model`・`/sync`・`/fix-ci`・`/recover`・`/remove`・`/clear`・`/update`・`/help` など)。 + +### セッションを消す(`x` / `/remove` / `/clear`) + +一覧に残っているセッションは、次の 3 通りで片付けられます(いずれも `y` / `n` の確認を挟みます)。 + +| 操作 | 対象 | 何が消えるか | +|---|---|---| +| `d`(破棄) | 選択中の 1 件 | worktree とブランチ。**行は「破棄」として一覧に残ります**(再起動すると消えます) | +| `x`(= `/remove`) | 選択中の 1 件 | worktree とブランチ+**一覧の行そのもの**。記録ごと消えます | +| `/clear` | 終了済みの**全件**(完了・中断・失敗など。実行中は対象外) | 各セッションの worktree とブランチ+一覧の行。件数を確認してから実行します | + +`x` は詳細ビューでも使えます(`Tab` で操作パネルへ切り替え)。削除するとそのセッションは開けなくなるので、自動的に一覧へ戻ります。 + +**`x` を使うのは、過去の PR に紐づくセッションを片付けたいとき**です。`d`(破棄)だと行が「破棄」として残り、そのブランチの PR がコンフリクトしていたり CI が落ちていたりすると `Ctrl+F`(一括立て直し)の候補として出続けます。`x` は行ごと消すので、一括操作の対象からも完全に外れます。 + +いずれもローカルの worktree とブランチを消すだけで、**push 済みのリモートブランチと GitHub 上の PR には触りません**(PR を閉じたい場合は GitHub 側で操作してください)。未コミットの変更が残っていても削除は強制実行されるため、残したい作業があるときは先にコミットしてください。 スラッシュを打ち忘れても、**その画面で使えるコマンド名と完全に一致する入力**(`exit` / `help` など)はそのコマンドとして実行されます。実行されるときはコマンドパレットに出るので、`Enter` の前に何が起きるか分かります。`exit の挙動を直して` のように後ろに文字が続く場合、また `?`・`changes` のような別名は通常の指示として扱うので、指示が誤ってコマンドになることはありません。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1e6d61f..67e27a2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -435,7 +435,7 @@ Claude Code の実画面に寄せる: 画面は**端末の縦幅いっぱい** `flexShrink={0}`**(横方向の縮小でアスキーアートが折り返して崩れるのを防ぐ。縦の譲り合いには効かない)。 - `SessionList`: 一覧画面。`Banner` + 一覧 + 下部 `PromptInput`/`StatusFooter`。フォーカスは `composer`(起動時既定。タイピング + 矢印キャレット移動)と `list`(↑↓選択・Enter/→ = 詳細を開く・ - m/d = マージ/破棄)の2ゾーンで Tab 切替。選択セッションの `PermissionDialog` は list フォーカス時のみ + m/d/x = マージ/破棄/削除)の2ゾーンで Tab 切替。選択セッションの `PermissionDialog` は list フォーカス時のみ アクティブ。マウスクリック(`core/mouse.ts` + `useAbsolutePosition`)で行選択・キャレット移動。 コンポーザ上のドラッグで範囲選択し、離すとクリップボードへコピー(OSC 52 = `utils/clipboard.ts`。 純粋ロジックは `core/text-selection.ts`、状態は共有フック `useDragSelection`)。詳細ビューの @@ -461,7 +461,8 @@ Claude Code の実画面に寄せる: 画面は**端末の縦幅いっぱい** 多く描くと行が虫食いで欠落する)、 `streamingText` のタイピング風プレビュー、 下部の追加指示コンポーザ(`manager.send(id, text)`)を持つ。Tab で入力↔操作パネルを切替し、 - 操作パネルで m/d = マージ/破棄。`pendingPermission` があれば `PermissionDialog` に委譲。単一 `useInput` の + 操作パネルで m/d/x = マージ/破棄/削除(`x` は行ごと消すので成功時は一覧へ戻る)。 + `pendingPermission` があれば `PermissionDialog` に委譲。単一 `useInput` の state machine(panel = input | actions)でタイピングとキー操作の衝突を防ぐ。 - `PromptInput` / `StatusFooter`: presentational。キー処理は view の単一 `useInput` に集約(ロジックは持たない)。`PromptInput` は複数行対応(純粋モデルは `core/text-buffer.ts`、キー対応は `ui/input.ts` の `editText`/`resolveEnter`)。幅を超えたテキストは**折り返す**(truncate しない): 折り返し後の表示行・キャレット位置・クリック逆算・選択範囲はすべて純粋な `core/composer-layout.ts`(`composerLayout`)が算出し、折り返し幅は Box の実測値(`useComposerWidth`)を描画・当たり判定・↑↓ 移動で共有する。IME 対応で実端末カーソルをキャレットに重ねる(`useCursor`)。 - 再描画スロットリング: SessionManager の通知を UI 側で ~100ms にスロットルする。 @@ -765,6 +766,8 @@ TUI は alt screen + マウスレポート(?1002/?1006)で動くため、異 | 復元は「メタ + SDK resume」で、ログは永続しない | state.json を小さく保つ。会話履歴は SDK の resume が持つので二重管理しない。復元直後はアイドル表示、追加指示で継続 | | 復元セッションは遅延 resume(起動時に起こさない) | セッション毎に ~1GiB のサブプロセスを起動時に乱立させない。触られたものだけ起こす | | 終了は `abort()` ではなく `stop()`(quiet) | 実行中セッションを failed にせず resumable のまま保存するため(quit と「1件破棄」を区別) | +| 「破棄(`d`)」と「削除(`x` / `/remove` / `/clear`)」を分ける | 破棄は worktree を消して行を `archived` として残す(作業の記録が見える)。だがブランチに古い PR が付いていると、その行は一括立て直し(`Ctrl+F` = `recoverableSessions`)の候補として毎回挙がり続ける。削除は store から行ごと落とすので、記録も一括操作の対象も同時に消える。`/clear` も worktree/ブランチを残さない(残すと「消したのにディスクに残る」ぶんが見えない負債になる) | +| 削除で worktree の除去に失敗したら行を残す | ディスクにディレクトリが残っているのに一覧から消すと、存在するものが見えなくなる。エラーを出して行を残し、`/clear` は成功した件数だけ数える | | 通知の発火判定は純粋関数・遷移時のみ | テスト可能にし、ストリーミングの連続更新で鳴り続けるのを防ぐ。OS I/O は utils に隔離し best-effort | | 設定検証は `toConfig()` に集約・不正値は既定へ | 設定ミスで TUI をクラッシュさせない。SDK union は実行時リテラルで検証(型が変われば型エラー) | | 分離手段は git worktree | 同一リポジトリの並列作業では最軽量。ブランチがそのまま成果物になる。Docker 等はMVPではオーバーキル | diff --git a/docs/TASKS.md b/docs/TASKS.md index 7d28622..8ef0ac2 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -783,6 +783,37 @@ UI なし。すべてユニットテストで駆動する。 > 実績メモ: lint / typecheck / test / build 緑。ヘッダの行数は増えていない(プラン + モデルの行に > 並べただけなので `bannerCaretAt` の「行 index = 表示行」も不変)。実機での体感確認はユーザーに依頼。 +## Phase 23: セッションを 1 件ずつ削除する(`x` / `/remove`) ✅ + +> 一覧から 1 件だけ消す手段が無かった。`d`(破棄)は worktree とブランチを消すが行を +> `archived` として残すため、**古い PR が付いたセッションが `Ctrl+F`(一括立て直し)の +> 候補に挙がり続ける**。`/clear` は行を消す代わりに worktree を残していたので、 +> 「消したのにディスクには残る」ぶんが見えない負債になっていた。両方を揃える。 + +- [x] `SessionManager.remove(id, opts)`: 破棄 + `store.remove` + `onPersist`(= state.json からも消える)。 + worktree が既に無い行(過去に `d` した archived)も**エラーにせず**行だけ落とす。 + 共通の後始末は private `forget(id)` に切り出し(`stop()` → `sessions`/`worktreeMeta`/`prs`/`store`) +- [x] `SessionManager.clear()` を async 化し、**worktree とブランチも削除**(`ClearOutcome` + = `{ cleared, error }`)。git はリポジトリ全体のロックを取るので**直列**。除去に失敗した行は + **残す**(ディスクにあるものを一覧から隠さない) +- [x] `CommandAction`/`COMMANDS` に `remove`、`Messages` に `command.remove` / `action.removePrompt` / + `action.clearPrompt(n)` / `detail.removeAction`(ja/en 対)。フッタヒントに `x: 削除` +- [x] `useLifecycleAction` を `merge | discard | remove | clear` に拡張(`clear` だけ id 不要)。 + `onDone(ok, action)` にして、詳細ビューは削除成功時だけ `onBack()`(消えたセッションの + 「見つかりません」を見せない) +- [x] 一覧: `x` キー + `/remove`、`/clear` は**件数付きの確認**を挟む(0 件なら聞かない)。 + 詳細: 操作パネルの `x` + `/remove` +- [x] `CommandPalette` に `flexShrink={0}`(コマンドが 1 つ増えて `/help` 一覧が縦に入らなくなると、 + Yoga が枠を縮めて行が混ざり `/diffpt` のような読めない表示になっていた) +- [x] テスト: `session-manager.spec.ts`(remove の 4 ケース / clear の worktree 削除・失敗時に行を残す)/ + `commands.spec.ts`(`/re` は recover と remove の両方に前方一致)/ `tests/commands.test.tsx` + (`/clear` の確認 → y、0 件では何もしない、`/remove`、`x` の n/y) +- [x] ドキュメント: `README.md`(「セッションを消す」節 = `d` / `x` / `/clear` の対比表)/ + `docs/ARCHITECTURE.md`(キー割当 + 設計判断 2 行) + +> 実績メモ: lint / typecheck / test 緑。リモートブランチと GitHub の PR には触らない(ローカルのみ)。 +> 未コミット変更は `force` で消えるため、確認文で明示している。実機での体感確認はユーザーに依頼。 + --- ## 各 Phase 共通の完了チェック diff --git a/src/core/commands.spec.ts b/src/core/commands.spec.ts index 57d4e0f..bdaaa1f 100644 --- a/src/core/commands.spec.ts +++ b/src/core/commands.spec.ts @@ -99,6 +99,9 @@ describe('matchCommands', () => { expect(matchCommands('/mo').map((c) => c.name)).toEqual(['model']); expect(matchCommands('/pr').map((c) => c.name)).toEqual(['prompt']); expect(matchCommands('/cl').map((c) => c.name)).toEqual(['clear']); + // `/re` は「立て直し」と「削除」の両方に当たる(レジストリ順で出す)。 + expect(matchCommands('/re').map((c) => c.name)).toEqual(['recover', 'remove']); + expect(matchCommands('/rem').map((c) => c.name)).toEqual(['remove']); }); it('does not match the retired /quit alias', () => { expect(matchCommands('/q').map((c) => c.name)).toEqual([]); @@ -125,6 +128,9 @@ describe('runCommand', () => { it('resolves /clear to the clear command', () => { expect(runCommand('/clear')).toEqual({ kind: 'run', command: findCommand('clear') }); }); + it('resolves /remove to the remove command', () => { + expect(runCommand('/remove')).toEqual({ kind: 'run', command: findCommand('remove') }); + }); it('resolves /update to the update command', () => { expect(runCommand('/update')).toEqual({ kind: 'run', command: findCommand('update') }); }); diff --git a/src/core/commands.ts b/src/core/commands.ts index 039d3b4..49ba8d8 100644 --- a/src/core/commands.ts +++ b/src/core/commands.ts @@ -20,6 +20,7 @@ export type CommandAction = | 'model' | 'diff' | 'prompt' + | 'remove' | 'clear' | 'update' | 'sync' @@ -51,6 +52,7 @@ export const COMMANDS: readonly CommandSpec[] = [ // そのまま完全一致で引ける(別名 `fixci` はハイフンを打ち忘れたとき用)。 { name: 'fix-ci', aliases: ['fixci'], action: 'fixCi', describe: (m) => m.command.fixCi }, { name: 'recover', action: 'recover', describe: (m) => m.command.recover }, + { name: 'remove', action: 'remove', describe: (m) => m.command.remove }, { name: 'clear', action: 'clear', describe: (m) => m.command.clear }, { name: 'update', action: 'update', describe: (m) => m.command.update }, { name: 'help', aliases: ['?'], action: 'help', describe: (m) => m.command.help }, diff --git a/src/core/i18n.ts b/src/core/i18n.ts index 224f916..8a4a901 100644 --- a/src/core/i18n.ts +++ b/src/core/i18n.ts @@ -47,6 +47,8 @@ export interface Messages { actionsTitle: string; mergeAction: string; discardAction: string; + /** セッションを一覧から完全に削除する操作(worktree/ブランチも消す) */ + removeAction: string; helpPending: string; helpActions: string; helpInput: string; @@ -59,6 +61,10 @@ export interface Messages { actionErrorLabel: string; mergePrompt: string; discardPrompt: string; + /** 1件削除の確認文(破棄と違い一覧の行も残らないことを伝える) */ + removePrompt: string; + /** /clear の確認文。`n` = 消える件数(worktree とブランチも消えるので必ず確認する) */ + clearPrompt: (n: number) => string; /** * 一括再開の確認文。`n` = 対象件数、`auth` = そのうち認証切れの件数。 * 認証切れには「ログインし直した」という指示文を送るので、まだログインして @@ -325,6 +331,8 @@ export interface Messages { diff: string; /** /prompt の説明 */ prompt: string; + /** /remove の説明 */ + remove: string; /** /clear の説明 */ clear: string; /** /update の説明 */ @@ -363,7 +371,7 @@ const ja: Messages = { helpComposer: 'Enter: 投入 ・ Shift+Enter: 改行 ・ Tab: 一覧へ ・ /exit: 終了 ・ Ctrl+U: 全消し ・ ↑↓: 履歴', helpList: - '↑↓: 選択 ・ Enter/→: 詳細を開く ・ p: PRを開く ・ m: マージ ・ d: 破棄 ・ Tab/Esc: 入力へ', + '↑↓: 選択 ・ Enter/→: 詳細を開く ・ p: PR ・ m: マージ ・ d: 破棄 ・ x: 削除 ・ Tab/Esc: 入力へ', helpPending: 'ダイアログで回答 ・ PgUp/PgDn: 選択移動 ・ Tab: 入力へ', moreAbove: (n) => `↑ 他 ${n} 件`, moreBelow: (n) => `↓ 他 ${n} 件`, @@ -381,8 +389,9 @@ const ja: Messages = { actionsTitle: '操作', mergeAction: 'マージ(--no-ff)', discardAction: '破棄(worktree削除)', + removeAction: '削除(一覧から消す)', helpPending: 'Esc: 一覧へ戻る', - helpActions: 'm/d: 操作 ・ ↑↓/PgUp/PgDn: ログ ・ Tab: 入力へ ・ Esc: 戻る', + helpActions: 'm/d/x: 操作 ・ ↑↓/PgUp/PgDn: ログ ・ Tab: 入力へ ・ Esc: 戻る', helpInput: 'Enter: 送信 ・ Shift+Enter: 改行 ・ ↑↓/PgUp/PgDn: ログ ・ Tab: 操作 ・ Esc: 一覧へ ・ Ctrl+U: 全消し', }, @@ -390,6 +399,9 @@ const ja: Messages = { actionErrorLabel: '操作エラー', mergePrompt: 'ベースへマージします。', discardPrompt: 'worktree とブランチを破棄します。', + removePrompt: 'このセッションを一覧から削除します(worktree とブランチも消えます)。', + clearPrompt: (n) => + `完了したセッション ${n} 件を一覧から削除します(worktree とブランチも消えます)。`, resumeAllPrompt: (n, auth) => auth > 0 ? `中断中の ${n} 件を続きから再開します(認証切れ ${auth} 件を含む — 先に別ターミナルで claude にログインしてください)。` @@ -454,7 +466,7 @@ const ja: Messages = { authInstruction: '認証切れで中断しました。ログインし直したので、中断したところから作業を続けてください。', listHint: - '↑↓: 選択 ・ r/Ctrl+R: 再開 ・ Enter/→: 詳細 ・ m: マージ ・ d: 破棄 ・ Tab/Esc: 入力へ', + '↑↓: 選択 ・ r/Ctrl+R: 再開 ・ Enter/→: 詳細 ・ m: マージ ・ d: 破棄 ・ x: 削除 ・ Tab/Esc: 入力へ', action: '再開(続行)', oneKeyHint: 'Ctrl+R: 中断したところから再開', allHint: (n) => `Ctrl+A: 中断中の ${n} 件をまとめて再開`, @@ -564,7 +576,8 @@ const ja: Messages = { model: 'モデルを切り替え', diff: '変更差分サマリの表示を切り替え', prompt: 'リポジトリの追加指示を編集', - clear: '完了したセッションを一覧から消去(履歴は残る)', + remove: '選択中のセッションを削除(worktree とブランチも消す)', + clear: '完了したセッションをまとめて削除(worktree とブランチも消す)', update: 'codiva の更新を確認して適用', sync: 'ベースブランチを取り込む(競合はセッションに解決させる)', fixCi: '失敗した CI をセッションに修正させる', @@ -588,7 +601,7 @@ const en: Messages = { helpComposer: 'Enter: submit · Shift+Enter: newline · Tab: list · /exit: quit · Ctrl+U: clear · ↑↓: history', helpList: - '↑↓: select · Enter/→: open detail · p: open PR · m: merge · d: discard · Tab/Esc: input', + '↑↓: select · Enter/→: open detail · p: PR · m: merge · d: discard · x: remove · Tab/Esc: input', helpPending: 'Answer in the dialog · PgUp/PgDn: move selection · Tab: input', moreAbove: (n) => `↑ ${n} more`, moreBelow: (n) => `↓ ${n} more`, @@ -606,8 +619,9 @@ const en: Messages = { actionsTitle: 'Actions', mergeAction: 'Merge (--no-ff)', discardAction: 'Discard (remove worktree)', + removeAction: 'Remove (drop from the list)', helpPending: 'Esc: back to list', - helpActions: 'm/d: actions · ↑↓/PgUp/PgDn: log · Tab: input · Esc: back', + helpActions: 'm/d/x: actions · ↑↓/PgUp/PgDn: log · Tab: input · Esc: back', helpInput: 'Enter: send · Shift+Enter: newline · ↑↓/PgUp/PgDn: log · Tab: actions · Esc: back · Ctrl+U: clear', }, @@ -615,6 +629,9 @@ const en: Messages = { actionErrorLabel: 'Action error', mergePrompt: 'Merge into the base branch.', discardPrompt: 'Discard the worktree and branch.', + removePrompt: 'Remove this session from the list (its worktree and branch are deleted too).', + clearPrompt: (n) => + `Remove ${n} finished session${n === 1 ? '' : 's'} from the list (worktrees and branches are deleted too).`, resumeAllPrompt: (n, auth) => auth > 0 ? `Resume all ${n} interrupted sessions from where they stopped (${auth} need a login first — log in to claude in another terminal).` @@ -677,7 +694,7 @@ const en: Messages = { authInstruction: 'This session stopped because authentication expired. I have logged back in — continue from where you left off.', listHint: - '↑↓: select · r/Ctrl+R: resume · Enter/→: open detail · m: merge · d: discard · Tab/Esc: input', + '↑↓: select · r/Ctrl+R: resume · Enter/→: open detail · m: merge · d: discard · x: remove · Tab/Esc: input', action: 'Resume (continue)', oneKeyHint: 'Ctrl+R: resume from where it stopped', allHint: (n) => `Ctrl+A: resume all ${n} interrupted sessions`, @@ -784,7 +801,8 @@ const en: Messages = { model: 'Switch the model', diff: 'Toggle the changes summary', prompt: 'Edit the repository instructions', - clear: 'Clear finished sessions from the list (history is kept)', + remove: 'Remove the selected session (worktree and branch deleted too)', + clear: 'Remove every finished session (worktrees and branches deleted too)', update: 'Check for a codiva update and apply it', sync: 'Merge the base branch in (the session resolves any conflicts)', fixCi: 'Ask the session to fix its failing CI checks', diff --git a/src/core/session-manager.spec.ts b/src/core/session-manager.spec.ts index aa57032..7091622 100644 --- a/src/core/session-manager.spec.ts +++ b/src/core/session-manager.spec.ts @@ -366,9 +366,103 @@ describe('SessionManager', () => { expect(after[1]).not.toBe(before[1]); // changed row is a new object }); + describe('remove()', () => { + it('removes the worktree AND drops the row (gone from the list and state.json)', async () => { + const remove = vi.fn(async () => {}); + const created: FakeSession[] = []; + const onPersist = vi.fn(); + const manager = new SessionManager({ + worktrees: fakeWorktrees({ remove }), + queryFn: (() => { + throw new Error('unused'); + }) as never, + now: () => 1, + onPersist, + createSession: ({ input, onChange }) => { + const s = new FakeSession(input, onChange); + created.push(s); + return s; + }, + }); + const id = manager.create('old pr'); + await flush(); + created[0]?.drive('completed', 'sdk-0'); + onPersist.mockClear(); + + const result = await manager.remove(id, { force: true }); + + expect(result.ok).toBe(true); + expect(remove).toHaveBeenCalledWith(expect.anything(), { force: true }); + // Unlike discard, no `archived` row is left behind — the session is forgotten, + // so the bulk recovery pass can never pick it up again. + expect(manager.get(id)).toBeUndefined(); + expect(manager.getSnapshot()).toEqual([]); + expect(manager.persistableState().sessions).toEqual([]); + expect(created[0]?.stopped).toBe(true); + expect(onPersist).toHaveBeenCalled(); // state.json を書き直させる + }); + + it('keeps the row when the worktree refuses to go', async () => { + const manager = new SessionManager({ + worktrees: fakeWorktrees({ + remove: async () => { + throw new Error('worktree is dirty'); + }, + }), + queryFn: (() => { + throw new Error('unused'); + }) as never, + now: () => 1, + createSession: ({ input, onChange }) => new FakeSession(input, onChange), + }); + const id = manager.create('feature'); + await flush(); + + const result = await manager.remove(id); + + expect(result.ok).toBe(false); + expect(result.error).toContain('worktree is dirty'); + expect(manager.get(id)).toBeDefined(); // still listed — the directory is still there + }); + + it('forgets a row whose worktree is already gone (discarded earlier)', async () => { + const { manager } = makeManager(); + const id = manager.create('feature'); + await flush(); + await manager.discard(id); // worktree + meta gone, row stays as archived + + const result = await manager.remove(id); + + expect(result.ok).toBe(true); + expect(manager.get(id)).toBeUndefined(); + }); + + it('remove on an unknown id returns an error', async () => { + const { manager } = makeManager(); + expect((await manager.remove('nope')).ok).toBe(false); + }); + }); + describe('clear()', () => { - it('drops finished sessions (stopped, forgotten) but keeps in-flight ones', async () => { - const { manager, created } = makeManager(); + it('drops finished sessions (worktree removed, row forgotten) but keeps in-flight ones', async () => { + const removed: string[] = []; + const created: FakeSession[] = []; + const manager = new SessionManager({ + worktrees: fakeWorktrees({ + remove: async (wt) => { + removed.push(wt.slug); + }, + }), + queryFn: (() => { + throw new Error('unused'); + }) as never, + now: () => 100, + createSession: ({ input, onChange }) => { + const s = new FakeSession(input, onChange); + created.push(s); + return s; + }, + }); manager.create('done'); // 0 → completed manager.create('busy'); // 1 → running (kept) manager.create('gone'); // 2 → interrupted @@ -377,9 +471,12 @@ describe('SessionManager', () => { created[1]?.drive('running', 'sdk-1'); created[2]?.drive('interrupted', 'sdk-2'); - const cleared = manager.clear(); + const { cleared, error } = await manager.clear(); expect(cleared).toBe(2); + expect(error).toBeUndefined(); + // Worktrees of the finished sessions are gone; the running one is untouched. + expect(removed).toEqual(['done', 'gone']); // Only the in-flight (running) session remains in the list. expect(manager.getSnapshot().map((s) => s.title)).toEqual(['busy']); // Cleared sessions were quietly stopped (not aborted), running one untouched. @@ -396,7 +493,7 @@ describe('SessionManager', () => { created[0]?.drive('completed', 'sdk-0'); expect(manager.persistableState().sessions).toHaveLength(1); - manager.clear(); + await manager.clear(); expect(manager.persistableState().sessions).toEqual([]); }); @@ -424,7 +521,7 @@ describe('SessionManager', () => { manager.subscribe(listener); onPersist.mockClear(); - manager.clear(); + await manager.clear(); expect(onPersist).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalled(); // store rebuild notified subscribers @@ -444,10 +541,44 @@ describe('SessionManager', () => { manager.create('busy'); await flush(); onPersist.mockClear(); - expect(manager.clear()).toBe(0); + expect(await manager.clear()).toEqual({ cleared: 0, error: undefined }); expect(onPersist).not.toHaveBeenCalled(); expect(manager.getSnapshot()).toHaveLength(1); }); + + it('keeps the rows whose worktree could not be removed and reports the failure', async () => { + const created: FakeSession[] = []; + const manager = new SessionManager({ + worktrees: fakeWorktrees({ + remove: async (wt) => { + if (wt.slug === 'stuck') { + throw new Error('worktree locked'); + } + }, + }), + queryFn: (() => { + throw new Error('unused'); + }) as never, + now: () => 100, + createSession: ({ input, onChange }) => { + const s = new FakeSession(input, onChange); + created.push(s); + return s; + }, + }); + manager.create('stuck'); + manager.create('fine'); + await flush(); + created[0]?.drive('completed', 'sdk-0'); + created[1]?.drive('completed', 'sdk-1'); + + const { cleared, error } = await manager.clear(); + + // The directory is still on disk, so hiding its row would be a lie. + expect(cleared).toBe(1); + expect(error).toContain('worktree locked'); + expect(manager.getSnapshot().map((s) => s.title)).toEqual(['stuck']); + }); }); it('dispose() quietly stops every session (resumable, not marked failed)', async () => { diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index a5526ed..489177f 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -104,6 +104,14 @@ export interface SessionManagerDeps { }) => SessionHandle; } +/** Outcome of `/clear`: how many rows went away, plus the first failure (if any). */ +export interface ClearOutcome { + /** Sessions actually dropped from the list. */ + cleared: number; + /** First worktree removal failure; the rows that failed are still in the list. */ + error?: string; +} + /** Fields that end up in the persisted snapshot (see persistence.toPersistedSession). */ function persistRelevantChanged(prev: SessionState, next: SessionState): boolean { return ( @@ -615,33 +623,86 @@ export class SessionManager { } /** - * Clear finished sessions from the list (the `/clear` command). Every terminal - * session (completed/interrupted/rate_limited/failed/conflict/archived) is - * dropped from the store and from the persisted snapshot, so it stays gone after - * a restart — persistableState() reads the store, and a session no longer there - * is never written to state.json nor restored. In-flight sessions - * (creating/running/awaiting_*) are kept: clearing them would orphan a live SDK - * conversation. Worktrees/branches are left on disk (history is preserved); only - * the codiva session entry is forgotten. The reserved slug is intentionally not - * freed — the worktree still exists on disk, so its slug stays taken. - * Returns the number of sessions cleared. + * Remove one session outright (`x` / `/remove`): the worktree + branch are + * deleted **and** the list row is dropped. This is the difference from discard, + * which leaves the row behind as `archived`: a lingering row keeps showing up in + * the list and — for a session whose PR is old and no longer interesting — makes + * the bulk recovery pass (`recoverableSessions`, which reads the store) offer it + * again on every Ctrl+F. Dropping the row also keeps it out of state.json + * (persistableState() reads the store), so it stays gone after a restart. + * + * A row whose worktree is already gone (a session discarded earlier) is still + * removable: missing metadata is not an error here, since forgetting the row is + * the whole point of the command. */ - clear(): number { - const removed = this.store + async remove(id: string, opts: { force?: boolean } = {}): Promise { + if (this.store.get(id) === undefined) { + return { ok: false, error: 'session not found' }; + } + const meta = this.worktreeMeta.get(id); + if (meta) { + const result = await discardSession(this.deps.worktrees, meta, this.sessions.get(id), opts); + if (!result.ok) { + return result; + } + } + this.forget(id); + this.deps.onPersist?.(); + return { ok: true }; + } + + /** + * Clear finished sessions (the `/clear` command). Every terminal session + * (completed/interrupted/rate_limited/failed/conflict/archived) has its worktree + * and branch removed and its row dropped, so nothing is left behind on disk or + * in state.json. In-flight sessions (creating/running/awaiting_*) are kept: + * clearing them would orphan a live SDK conversation. + * + * Sequential on purpose: `git worktree remove` + `git branch -D` take a + * repo-wide lock, so firing them together would make some of them fail on a lock + * they can't see (same reason as the bulk recovery pass). + * + * A worktree that refuses to go is reported and its row is **kept** — dropping + * the row would hide a directory that is still on disk. + */ + async clear(): Promise { + const targets = this.store .ids() .filter((id) => isTerminalStatus(this.store.get(id)?.status ?? 'running')); - if (removed.length === 0) { - return 0; + let cleared = 0; + let error: string | undefined; + for (const id of targets) { + const meta = this.worktreeMeta.get(id); + if (meta) { + try { + await this.deps.worktrees.remove(meta.worktree, { force: true }); + } catch (err) { + error ??= errorMessage(err); + continue; + } + } + this.forget(id); + cleared += 1; } - for (const id of removed) { - this.sessions.get(id)?.stop(); - this.sessions.delete(id); - this.worktreeMeta.delete(id); - this.prs.forget(id); - this.store.remove(id); + if (cleared > 0) { + this.deps.onPersist?.(); } - this.deps.onPersist?.(); - return removed.length; + return { cleared, error }; + } + + /** + * Drop every trace of a session from memory, list row included. Stops the SDK + * process quietly (stop(), not abort(): the status no longer matters once the row + * is gone, and abort would fire a state change nobody reads). The reserved slug + * is intentionally **not** freed — handing a brand-new session the branch name of + * one the user just deleted would be confusing, and slugs are cheap. + */ + private forget(id: string): void { + this.sessions.get(id)?.stop(); + this.sessions.delete(id); + this.worktreeMeta.delete(id); + this.prs.forget(id); + this.store.remove(id); } /** diff --git a/src/ui/command-palette.tsx b/src/ui/command-palette.tsx index ea8a4a4..ac1843e 100644 --- a/src/ui/command-palette.tsx +++ b/src/ui/command-palette.tsx @@ -22,7 +22,17 @@ export const CommandPalette: FC<{ }> = ({ title, commands, describeOverrides }) => { const m = useMessages(); return ( - + // `flexShrink={0}`: コマンドが増えて縦に入り切らなくなると Yoga はこの枠を + // **縮める**(クリップではなく行が潰れて混ざる)ため、`/help` の一覧が + // 「/diffpt」のような読めない行になる。縮む役は内部スクロールを持つ一覧側に寄せる + // (規約: ink-components.md)。 + {glyph.star} {title} diff --git a/src/ui/confirm-prompt.tsx b/src/ui/confirm-prompt.tsx index f55741a..2044dfd 100644 --- a/src/ui/confirm-prompt.tsx +++ b/src/ui/confirm-prompt.tsx @@ -3,16 +3,18 @@ import type { FC } from 'react'; import { useMessages } from './i18n-context'; import { theme } from './theme'; -/** The single-session lifecycle confirmations (both act on the selected session). */ -export type ConfirmKind = 'merge' | 'discard'; +/** The single-session lifecycle confirmations (all act on the selected session). */ +export type ConfirmKind = 'merge' | 'discard' | 'remove'; /** * Props are a discriminated union so the counts can't drift from the kind: a * `resumeAll` line must state how many sessions it will restart (and how many of - * those need a login first), and the lifecycle kinds have no counts at all. + * those need a login first), a `clear` line how many rows it will drop, and the + * single-session lifecycle kinds have no counts at all. */ export type ConfirmPromptProps = | { kind: ConfirmKind; busy: boolean } + | { kind: 'clear'; busy: boolean; count: number } | { kind: 'resumeAll'; busy: boolean; count: number; authCount: number } | { kind: 'recoverAll'; busy: boolean; syncCount: number; ciCount: number }; @@ -34,9 +36,13 @@ export const ConfirmPrompt: FC = (props) => { ? m.action.resumeAllPrompt(props.count, props.authCount) : props.kind === 'recoverAll' ? m.recover.allPrompt(props.syncCount, props.ciCount) - : props.kind === 'merge' - ? m.action.mergePrompt - : m.action.discardPrompt; + : props.kind === 'clear' + ? m.action.clearPrompt(props.count) + : props.kind === 'merge' + ? m.action.mergePrompt + : props.kind === 'discard' + ? m.action.discardPrompt + : m.action.removePrompt; return ( {prompt} {m.action.confirmRun} y /{' '} diff --git a/src/ui/hooks.ts b/src/ui/hooks.ts index 0b30fca..fd3e24b 100644 --- a/src/ui/hooks.ts +++ b/src/ui/hooks.ts @@ -9,6 +9,7 @@ import { } from 'react'; import { type AccountSummary, + type ActionResult, COMPOSER_PREFIX_CELLS, type CommandAction, type DisplayLine, @@ -763,34 +764,74 @@ export function useRecovery( return { notice, setNotice, busy, run, runAll }; } +/** + * Dispatch one lifecycle action to the manager, normalizing `/clear`'s outcome + * (a count + an optional failure) into the same ActionResult the single-session + * operations return, so the shared flow below has exactly one shape to handle. + * `id` is guaranteed present for everything but `clear` (checked by the caller). + */ +function runLifecycle( + manager: SessionManager, + id: string | undefined, + action: LifecycleKind, +): Promise { + if (action === 'clear') { + return manager + .clear() + .then((outcome) => + outcome.error === undefined ? { ok: true } : { ok: false, error: outcome.error }, + ); + } + if (id === undefined) { + return Promise.resolve({ ok: false, error: 'no session selected' }); + } + if (action === 'merge') { + return manager.merge(id); + } + // 未コミットの変更ごと消す(force)。破棄・削除はどちらも確認ダイアログを通るので、 + // ここで git に拒否されて「y を押したのに何も起きない」状態にしない。 + return action === 'discard' + ? manager.discard(id, { force: true }) + : manager.remove(id, { force: true }); +} + +/** + * The confirmed lifecycle operations. `merge` / `discard` / `remove` act on the + * selected session; `clear` is the only one that fans out (every finished session) + * and therefore the only one that ignores `id`. + */ +export type LifecycleKind = 'merge' | 'discard' | 'remove' | 'clear'; + export interface LifecycleAction { - confirm: 'merge' | 'discard' | null; - setConfirm: (confirm: 'merge' | 'discard' | null) => void; + confirm: LifecycleKind | null; + setConfirm: (confirm: LifecycleKind | null) => void; busy: boolean; actionError: string | undefined; setActionError: (error: string | undefined) => void; - run: (action: 'merge' | 'discard') => void; + run: (action: LifecycleKind) => void; } /** - * The merge/discard confirm → busy → run → error flow shared by both views. - * `run` no-ops when `id` is undefined (nothing selected). `onDone(ok)` fires after - * completion so a view can react (e.g. the detail view returns to its input panel). + * The merge/discard/remove/clear confirm → busy → run → error flow shared by both + * views. `run` no-ops when a session-scoped action has no `id` (nothing selected). + * `onDone(ok, action)` fires after completion so a view can react — the detail view + * returns to its input panel, and goes back to the list when the session it was + * showing is the one that was just removed. */ export function useLifecycleAction( manager: SessionManager, id: string | undefined, - onDone?: (ok: boolean) => void, + onDone?: (ok: boolean, action: LifecycleKind) => void, ): LifecycleAction { - const [confirm, setConfirm] = useState<'merge' | 'discard' | null>(null); + const [confirm, setConfirm] = useState(null); const [busy, setBusy] = useState(false); const [actionError, setActionError] = useState(undefined); - const run = (action: 'merge' | 'discard') => { - if (id === undefined) { + const run = (action: LifecycleKind) => { + if (id === undefined && action !== 'clear') { return; } setBusy(true); - const promise = action === 'merge' ? manager.merge(id) : manager.discard(id, { force: true }); + const promise = runLifecycle(manager, id, action); // 第 2 引数(reject ハンドラ)で受ける。`.catch()` を後段に付けると成功ハンドラ内の // 例外まで飲んでしまうため。manager 側は失敗を ActionResult に畳むが、その手前 // (abort → 通知 → 購読者)で throw されると裸の then が unhandled rejection になる。 @@ -799,7 +840,7 @@ export function useLifecycleAction( setBusy(false); setConfirm(null); setActionError(result.ok ? undefined : result.error); - onDone?.(result.ok); + onDone?.(result.ok, action); }, (err: unknown) => { setBusy(false); diff --git a/src/ui/session-detail.tsx b/src/ui/session-detail.tsx index b30f140..3802c97 100644 --- a/src/ui/session-detail.tsx +++ b/src/ui/session-detail.tsx @@ -126,14 +126,22 @@ export const SessionDetail: FC<{ const [diff, setDiff] = useState(undefined); // 変更差分サマリは既定で畳んでおき(ログの縦幅を優先)、`/diff` でトグルする。 const [showChanges, setShowChanges] = useState(false); - // 確認/実行中/エラー + マージ・破棄の実行は共有フックへ。成功時は入力パネルへ戻す。 + // 確認/実行中/エラー + マージ・破棄・削除の実行は共有フックへ。成功時は入力パネルへ戻す。 const { confirm, setConfirm, busy, actionError, setActionError, run } = useLifecycleAction( manager, id, - (ok) => { - if (ok) { - setPanel('input'); + (ok, action) => { + if (!ok) { + return; + } + // 削除すると開いている当のセッションが store から消える(= このビューは + // 「セッションが見つかりません」になる)ので一覧へ戻す。マージ/破棄は行が + // 残るので詳細に留まる。 + if (action === 'remove') { + onBack(); + return; } + setPanel('input'); }, ); // `/sync` · `/fix-ci`(このセッションの PR の立て直し)。エラー欄は共有する。 @@ -208,8 +216,10 @@ export const SessionDetail: FC<{ recovery.run(id, 'ci'); applyAnchor('bottom'); }, - // `/recover` は複数セッションが対象なので詳細ビューには置かない(ハンドラの - // 無いコマンドは昇格しないので、`recover` と打っても通常の指示として流れる)。 + // `/remove` はこのセッションを記録ごと削除する(操作パネルの `x` と同じ確認へ)。 + remove: () => setConfirm('remove'), + // `/recover` と `/clear` は複数セッションが対象なので詳細ビューには置かない + // (ハンドラの無いコマンドは昇格しないので、打っても通常の指示として流れる)。 }, setActionError, m.command.unknown, @@ -520,6 +530,8 @@ export const SessionDetail: FC<{ setConfirm('merge'); } else if (input === 'd' || input === 'D') { setConfirm('discard'); + } else if (input === 'x' || input === 'X') { + setConfirm('remove'); } else if ((input === 'r' || input === 'R') && resumable) { resume(); } @@ -697,7 +709,9 @@ export const SessionDetail: FC<{ /> ) : panel === 'actions' ? ( - {confirm ? ( + {/* `clear` は一覧ビュー専用(件数付きの variant)。詳細では立てないが、 + 共有フックの型に含まれるのでここで除外して narrowing する。 */} + {confirm && confirm !== 'clear' ? ( ) : ( <> @@ -713,6 +727,9 @@ export const SessionDetail: FC<{ m: {m.detail.mergeAction} ・{' '} d: {m.detail.discardAction} + + x: {m.detail.removeAction} + )} diff --git a/src/ui/session-list.tsx b/src/ui/session-list.tsx index 9536dde..468e8e6 100644 --- a/src/ui/session-list.tsx +++ b/src/ui/session-list.tsx @@ -23,6 +23,7 @@ import { isFullscreenViewport, isPrCellHit, isResumable, + isTerminalStatus, listView, listViewportRows, type ModelOption, @@ -309,6 +310,9 @@ export const SessionList: FC<{ // 集合はそのまま一括実行に使う(`manager.recoverable()` と同じ純関数を通す)。 const stuck = recoverableSessions(sessions); const stuckSync = stuck.filter((s) => s.kind === 'sync').length; + // `/clear` で消える件数(終端状態のみ)。判定は core の STATUS_META を通し、確認文の + // 件数と実際に消える件数を必ず一致させる(`manager.clear()` も同じ述語で選ぶ)。 + const clearable = sessions.filter((s) => isTerminalStatus(s.status)).length; /** ダイアログを閉じる(進行中の非同期結果は世代を進めて捨てる)。 */ const closeUpdate = () => { updateGen.current += 1; @@ -386,9 +390,21 @@ export const SessionList: FC<{ model: () => setModelSelect(true), // `/prompt` はリポジトリ追加指示(.codiva/prompt.md)のエディタを開く。 prompt: () => setPromptEdit(true), - // `/clear` は完了したセッションを一覧から消去する(worktree/履歴は残す)。 - // 実行中セッションは残るため確認は不要(core 側で終端状態のみ対象にする)。 - clear: () => manager.clear(), + // `/remove` は選択中のセッションを一覧から削除する(worktree とブランチも消す)。 + // `x` と同じ確認ダイアログを通す — 破棄より強い操作を無確認で走らせない。 + remove: () => { + if (target) { + setConfirm('remove'); + } + }, + // `/clear` は終端状態のセッションをまとめて削除する。worktree とブランチまで + // 消えるので必ず件数を見せて y/n を取る(実行中セッションは core 側で対象外)。 + // 0 件で「0 件を削除します」と聞かないよう、ここで門を張る。 + clear: () => { + if (clearable > 0) { + setConfirm('clear'); + } + }, // `/update` は npm レジストリを見て、更新があれば y/n を挟んで適用する。 update: checkUpdate, // `/sync` は選択中セッションの worktree へベースブランチを取り込む。競合したら @@ -818,6 +834,13 @@ export const SessionList: FC<{ setConfirm('discard'); return; } + // 破棄(`d`)との違いは「行を残さない」こと。過去に PR を出したセッションは + // archived で残ると一括立て直し(Ctrl+F)の候補に挙がり続けるので、記録ごと + // 消す入口を用意する。worktree とブランチも消えるので必ず確認を挟む。 + if (input === 'x' || input === 'X') { + setConfirm('remove'); + return; + } // Resume a session that was cut off (connection interrupted / rate limited / // login expired): sends a "continue" instruction, which restarts the SDK // query with `resume` so Claude picks up where it left off. Only meaningful @@ -1021,7 +1044,12 @@ export const SessionList: FC<{ {m.action.actionErrorLabel}: {actionError} ) : null} - {confirm ? ( + {confirm === 'clear' ? ( + + {/* 件数付きなので他の lifecycle 確認とは別 variant(kind と件数がずれない)。 */} + + + ) : confirm ? ( diff --git a/tests/commands.test.tsx b/tests/commands.test.tsx index f4a036d..be90d7f 100644 --- a/tests/commands.test.tsx +++ b/tests/commands.test.tsx @@ -2,7 +2,38 @@ import { render } from 'ink-testing-library'; import { describe, expect, it, vi } from 'vitest'; import { App } from '@/app'; import { messages } from '@/core/i18n'; -import { flush, makeManager, renderFullscreen, stripAnsi } from './helpers'; +import { SessionManager } from '@/core/session-manager'; +import { reduce } from '@/core/status-reducer'; +import { + fakeWorktrees, + flush, + makeManager, + noopSession, + renderFullscreen, + stripAnsi, +} from './helpers'; + +/** + * A manager whose sessions land in a terminal state as soon as they start — the + * only thing `/clear` acts on (`makeManager`'s sessions stay in `creating`). + */ +function makeFinishedManager(): SessionManager { + return new SessionManager({ + worktrees: fakeWorktrees, + queryFn: (() => { + throw new Error('unused'); + }) as never, + now: () => 0, + createSession: ({ input, onChange }) => { + const session = noopSession(input); + session.start = () => { + session.state = reduce(session.state, { kind: 'interrupted', at: 0 }); + onChange(session.state); + }; + return session; + }, + }); +} // Feature test for slash commands driven through the whole App. Pure parsing is // unit-tested in src/core/commands.spec.ts; this checks the UI wiring: the @@ -238,18 +269,83 @@ describe('slash commands', () => { expect(frame).toContain(messages.ja.command.clear); // description shown }); - it('/clear clears the session list and creates no session', async () => { - const manager = makeManager(); + // worktree とブランチまで消す操作になったので、件数を見せて y を取ってから実行する。 + it('/clear asks first (with the count) and clears on y', async () => { + const manager = makeFinishedManager(); const clear = vi.spyOn(manager, 'clear'); - const { stdin } = render(); + const { stdin, lastFrame } = render(); + stdin.write('finish me'); + await flush(); + stdin.write('\r'); // 1 セッション作成 → 終端状態(中断)になる + await flush(); stdin.write('/clear'); await flush(); stdin.write('\r'); await flush(); + expect(stripAnsi(lastFrame() ?? '')).toContain(messages.ja.action.clearPrompt(1)); + expect(clear).not.toHaveBeenCalled(); // 確認前は実行しない + stdin.write('y'); + await flush(); expect(clear).toHaveBeenCalledOnce(); expect(manager.getSnapshot()).toHaveLength(0); }); + it('/clear does nothing when no session has finished', async () => { + const manager = makeManager(); // sessions stay in `creating` + const clear = vi.spyOn(manager, 'clear'); + const { stdin, lastFrame } = render(); + stdin.write('/clear'); + await flush(); + stdin.write('\r'); + await flush(); + expect(clear).not.toHaveBeenCalled(); + expect(stripAnsi(lastFrame() ?? '')).not.toContain(messages.ja.action.clearPrompt(0)); + }); + + it('lists /remove in the command palette and asks before removing', async () => { + const manager = makeManager(); + const remove = vi.spyOn(manager, 'remove'); + const { stdin, lastFrame } = render(); + stdin.write('build a thing'); + await flush(); + stdin.write('\r'); // create the session /remove will target + await flush(); + stdin.write('/remove'); + await flush(); + expect(lastFrame() ?? '').toContain(messages.ja.command.remove); + stdin.write('\r'); + await flush(); + expect(stripAnsi(lastFrame() ?? '')).toContain(messages.ja.action.removePrompt); + expect(remove).not.toHaveBeenCalled(); + stdin.write('y'); + await flush(); + expect(remove).toHaveBeenCalledOnce(); + // 破棄と違い行も残らない(= 一括立て直しの対象からも外れる)。 + expect(manager.getSnapshot()).toHaveLength(0); + }); + + it('x on the selected row removes it after the confirmation', async () => { + const manager = makeManager(); + const { stdin, lastFrame } = render(); + stdin.write('build a thing'); + await flush(); + stdin.write('\r'); + await flush(); + stdin.write('\t'); // Tab → list focus(印字キーが操作キーになる) + await flush(); + stdin.write('x'); + await flush(); + expect(stripAnsi(lastFrame() ?? '')).toContain(messages.ja.action.removePrompt); + stdin.write('n'); // n で取りやめ → 行は残る + await flush(); + expect(manager.getSnapshot()).toHaveLength(1); + stdin.write('x'); + await flush(); + stdin.write('y'); + await flush(); + expect(manager.getSnapshot()).toHaveLength(0); + }); + it('reports an unknown command as an error', async () => { const manager = makeManager(); const { stdin, lastFrame } = render();