Skip to content

Commit 2c8e125

Browse files
authored
feat: /clear コマンドで終了済みセッションを一覧から消去 (#47)
## 概要 一覧画面のコンポーザで **`/clear`** を実行すると、これまでのセッション一覧を消去するコマンドを追加しました。 - **終了済みセッション**(`completed` / `interrupted` / `rate_limited` / `failed` / `conflict` / `archived`)を一覧から消去します。 - **実行中セッション**(`creating` / `running` / `awaiting_permission` / `awaiting_input`)は残します。ライブの SDK 会話を切ってしまわないためです。 - 消去したセッションは store と永続スナップショット(`.codiva/state.json`)の**両方**から外れるため、**codiva を再起動しても一覧に戻りません**。 - worktree・ブランチ・コミット履歴はディスク上にそのまま残します(作業自体は失われず、失われるのは codiva の一覧エントリのみ)。 - 破壊的ですが即時実行(確認ダイアログなし)。実行中セッションを残す設計で誤操作の影響を抑えています。 ## 変更点 | レイヤ | 変更 | |-------|------| | `core/commands` | `CommandAction` に `'clear'`、`COMMANDS` に `/clear` エントリを追加 | | `core/i18n` | `command.clear` を ja/en 両カタログに追加 | | `core/session-store` | 順序+状態から 1 件を外す `remove()` を追加 | | `core/session-manager` | 終端セッションのみ `stop()` → 破棄する `clear()` を追加(`isTerminalStatus` で判定)。永続対象は store 由来なので、消去分は `state.json` に書かれず復元もされない | | `ui/session-list` | `/clear` ハンドラを `useCommandRunner` に配線 | | `README` | `/clear` の説明を追記 | ## 設計判断 - **なぜ永続スナップショットに手を入れないか**: `persistableState()` は store の id を走査するため、store から外すだけで自動的に永続対象から外れる。専用の「消去済みフラグ」を持たずに「再起動しても戻らない」を満たせる。 - **なぜ worktree を残すか**: 要件どおり履歴は保持。消去したセッションの slug は解放しない(worktree がディスクに残っており slug は引き続き使用中のため)。 - **実行中を残す/確認なし**: 依頼者の選択に従った挙動。 ## テスト - [x] `core/commands.spec`: `/clear` の解決・前方一致・カタログ - [x] `core/session-store.spec`: `remove()`(該当・不明 id の no-op) - [x] `core/session-manager.spec`: 終端のみ消去・実行中は残す / 永続スナップショットから除外 / onPersist・購読通知 / 対象なし時 no-op - [x] `tests/commands.test.tsx`: パレット表示と App 経由の `/clear` 配線 - [x] `npm test`(739 passed, core/utils カバレッジ 80% 以上) - [x] `npm run typecheck` / `npm run lint` / `npm run build` ## 手動確認 TODO - [ ] 使い捨てリポジトリで起動し、複数セッションを完了させてから `/clear` → 一覧が消えることを確認 - [ ] 実行中セッションがある状態で `/clear` → 実行中のみ残ることを確認 - [ ] codiva を再起動 → 消去したセッションが一覧に戻らないことを確認
1 parent ef4be9d commit 2c8e125

10 files changed

Lines changed: 182 additions & 2 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ codiva
7777

7878
ファイルを直接編集するほか、一覧画面のコンポーザで **`/prompt`** と入力すると TUI 内エディタが開きます(現在の内容をシード。`Enter` で保存、`Shift+Enter` で改行、`Esc` で取消、空で保存すると削除)。保存内容は**以降の新規セッション**に反映されます(稼働中のセッションは起動時の指示を維持)。
7979

80-
利用できるスラッシュコマンドは、コンポーザで `/` を入力するとパレット表示されます(`/prompt``/model``/help` など)。
80+
利用できるスラッシュコマンドは、コンポーザで `/` を入力するとパレット表示されます(`/prompt``/model``/clear``/help` など)`/clear` は完了・中断・失敗など**終了済みのセッションを一覧から消去**します(実行中のセッションは残ります)。worktree やコミット履歴はディスク上に残るため作業自体は失われませんが、消去したセッションは codiva を再起動しても一覧に戻りません
8181

8282
## 開発
8383

src/core/commands.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ describe('matchCommands', () => {
6666
expect(matchCommands('/h').map((c) => c.name)).toEqual(['help']);
6767
expect(matchCommands('/mo').map((c) => c.name)).toEqual(['model']);
6868
expect(matchCommands('/pr').map((c) => c.name)).toEqual(['prompt']);
69+
expect(matchCommands('/cl').map((c) => c.name)).toEqual(['clear']);
6970
});
7071
it('does not match the retired /quit alias', () => {
7172
expect(matchCommands('/q').map((c) => c.name)).toEqual([]);
@@ -89,6 +90,9 @@ describe('runCommand', () => {
8990
it('resolves /prompt to the prompt command', () => {
9091
expect(runCommand('/prompt')).toEqual({ kind: 'run', command: findCommand('prompt') });
9192
});
93+
it('resolves /clear to the clear command', () => {
94+
expect(runCommand('/clear')).toEqual({ kind: 'run', command: findCommand('clear') });
95+
});
9296
it('treats a bare slash as help (no false unknown)', () => {
9397
expect(runCommand('/')).toEqual({ kind: 'run', command: findCommand('help') });
9498
});

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' | 'exit' | 'model' | 'diff' | 'prompt';
17+
export type CommandAction = 'help' | 'exit' | 'model' | 'diff' | 'prompt' | 'clear';
1818

1919
/** 1 つのスラッシュコマンドの定義。 */
2020
export interface CommandSpec {
@@ -36,6 +36,7 @@ export const COMMANDS: readonly CommandSpec[] = [
3636
{ name: 'model', action: 'model', describe: (m) => m.command.model },
3737
{ name: 'prompt', action: 'prompt', describe: (m) => m.command.prompt },
3838
{ name: 'diff', aliases: ['changes'], action: 'diff', describe: (m) => m.command.diff },
39+
{ name: 'clear', action: 'clear', describe: (m) => m.command.clear },
3940
{ name: 'help', aliases: ['?'], action: 'help', describe: (m) => m.command.help },
4041
{ name: 'exit', action: 'exit', describe: (m) => m.command.exit },
4142
];

src/core/i18n.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ export interface Messages {
185185
diff: string;
186186
/** /prompt の説明 */
187187
prompt: string;
188+
/** /clear の説明 */
189+
clear: string;
188190
};
189191
}
190192

@@ -322,6 +324,7 @@ const ja: Messages = {
322324
model: 'モデルを切り替え',
323325
diff: '変更差分サマリの表示を切り替え',
324326
prompt: 'リポジトリの追加指示を編集',
327+
clear: '完了したセッションを一覧から消去(履歴は残る)',
325328
},
326329
};
327330

@@ -455,6 +458,7 @@ const en: Messages = {
455458
model: 'Switch the model',
456459
diff: 'Toggle the changes summary',
457460
prompt: 'Edit the repository instructions',
461+
clear: 'Clear finished sessions from the list (history is kept)',
458462
},
459463
};
460464

src/core/session-manager.spec.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,90 @@ describe('SessionManager', () => {
229229
expect(after[1]).not.toBe(before[1]); // changed row is a new object
230230
});
231231

232+
describe('clear()', () => {
233+
it('drops finished sessions (stopped, forgotten) but keeps in-flight ones', async () => {
234+
const { manager, created } = makeManager();
235+
manager.create('done'); // 0 → completed
236+
manager.create('busy'); // 1 → running (kept)
237+
manager.create('gone'); // 2 → interrupted
238+
await flush();
239+
created[0]?.drive('completed', 'sdk-0');
240+
created[1]?.drive('running', 'sdk-1');
241+
created[2]?.drive('interrupted', 'sdk-2');
242+
243+
const cleared = manager.clear();
244+
245+
expect(cleared).toBe(2);
246+
// Only the in-flight (running) session remains in the list.
247+
expect(manager.getSnapshot().map((s) => s.title)).toEqual(['busy']);
248+
// Cleared sessions were quietly stopped (not aborted), running one untouched.
249+
expect(created[0]?.stopped).toBe(true);
250+
expect(created[2]?.stopped).toBe(true);
251+
expect(created[1]?.stopped).toBe(false);
252+
expect(created.some((s) => s.aborted)).toBe(false);
253+
});
254+
255+
it('excludes cleared sessions from the persisted snapshot (stay gone after restart)', async () => {
256+
const { manager, created } = makeManager();
257+
manager.create('done');
258+
await flush();
259+
created[0]?.drive('completed', 'sdk-0');
260+
expect(manager.persistableState().sessions).toHaveLength(1);
261+
262+
manager.clear();
263+
264+
expect(manager.persistableState().sessions).toEqual([]);
265+
});
266+
267+
it('signals a persist and notifies subscribers when it removes sessions', async () => {
268+
const onPersist = vi.fn();
269+
const created: FakeSession[] = [];
270+
const manager = new SessionManager({
271+
worktrees: fakeWorktrees(),
272+
queryFn: (() => {
273+
throw new Error('unused');
274+
}) as never,
275+
now: () => 100,
276+
onPersist,
277+
createSession: ({ input, onChange }) => {
278+
const s = new FakeSession(input, onChange);
279+
created.push(s);
280+
return s;
281+
},
282+
});
283+
manager.create('done');
284+
await flush();
285+
created[0]?.drive('completed', 'sdk-0');
286+
const listener = vi.fn();
287+
manager.subscribe(listener);
288+
onPersist.mockClear();
289+
290+
manager.clear();
291+
292+
expect(onPersist).toHaveBeenCalledTimes(1);
293+
expect(listener).toHaveBeenCalled(); // store rebuild notified subscribers
294+
});
295+
296+
it('is a no-op (no persist) when there is nothing finished to clear', async () => {
297+
const onPersist = vi.fn();
298+
const manager = new SessionManager({
299+
worktrees: fakeWorktrees(),
300+
queryFn: (() => {
301+
throw new Error('unused');
302+
}) as never,
303+
now: () => 100,
304+
onPersist,
305+
createSession: ({ input, onChange }) => new FakeSession(input, onChange),
306+
});
307+
manager.create('busy');
308+
await flush();
309+
onPersist.mockClear();
310+
expect(manager.clear()).toBe(0);
311+
expect(onPersist).not.toHaveBeenCalled();
312+
expect(manager.getSnapshot()).toHaveLength(1);
313+
});
314+
});
315+
232316
it('dispose() quietly stops every session (resumable, not marked failed)', async () => {
233317
const { manager, created } = makeManager();
234318
manager.create('a');

src/core/session-manager.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
} from './session-ports';
2323
import { SessionStore } from './session-store';
2424
import { makeSlug, makeTitle, uniqueSlug } from './slug';
25+
import { isTerminalStatus } from './status-meta';
2526
import { initialState, reduce } from './status-reducer';
2627
import type { CreateSessionInput, LogEntry, SessionState } from './types';
2728
import type { DiffStat, Worktree } from './worktree';
@@ -425,6 +426,35 @@ export class SessionManager {
425426
return result;
426427
}
427428

429+
/**
430+
* Clear finished sessions from the list (the `/clear` command). Every terminal
431+
* session (completed/interrupted/rate_limited/failed/conflict/archived) is
432+
* dropped from the store and from the persisted snapshot, so it stays gone after
433+
* a restart — persistableState() reads the store, and a session no longer there
434+
* is never written to state.json nor restored. In-flight sessions
435+
* (creating/running/awaiting_*) are kept: clearing them would orphan a live SDK
436+
* conversation. Worktrees/branches are left on disk (history is preserved); only
437+
* the codiva session entry is forgotten. The reserved slug is intentionally not
438+
* freed — the worktree still exists on disk, so its slug stays taken.
439+
* Returns the number of sessions cleared.
440+
*/
441+
clear(): number {
442+
const removed = this.store
443+
.ids()
444+
.filter((id) => isTerminalStatus(this.store.get(id)?.status ?? 'running'));
445+
if (removed.length === 0) {
446+
return 0;
447+
}
448+
for (const id of removed) {
449+
this.sessions.get(id)?.stop();
450+
this.sessions.delete(id);
451+
this.worktreeMeta.delete(id);
452+
this.store.remove(id);
453+
}
454+
this.deps.onPersist?.();
455+
return removed.length;
456+
}
457+
428458
/**
429459
* Quietly stop every session (worktrees/branches left intact) and clear
430460
* listeners. Uses stop() rather than abort() so in-flight sessions persist as

src/core/session-store.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,28 @@ describe('SessionStore', () => {
4848
expect(after[1]).not.toBe(before[1]); // changed row is a new object
4949
});
5050

51+
it('remove drops a session from order and state, keeping the rest', () => {
52+
const store = new SessionStore();
53+
store.append('1', state('1'));
54+
store.append('2', state('2'));
55+
store.append('3', state('3'));
56+
store.remove('2');
57+
expect(store.ids()).toEqual(['1', '3']);
58+
expect(store.getSnapshot().map((s) => s.id)).toEqual(['1', '3']);
59+
expect(store.has('2')).toBe(false);
60+
expect(store.get('2')).toBeUndefined();
61+
});
62+
63+
it('remove is a no-op (no notify) for an unknown id', () => {
64+
const store = new SessionStore();
65+
store.append('1', state('1'));
66+
const listener = vi.fn();
67+
store.subscribe(listener);
68+
store.remove('nope');
69+
expect(store.ids()).toEqual(['1']);
70+
expect(listener).not.toHaveBeenCalled();
71+
});
72+
5173
it('notifies subscribers on every change and stops after unsubscribe', () => {
5274
const store = new SessionStore();
5375
const listener = vi.fn();

src/core/session-store.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,17 @@ export class SessionStore {
6464
this.rebuild();
6565
}
6666

67+
/** Drop a session entirely (from both order and state). Used by clear(). */
68+
remove(id: string): void {
69+
const idx = this.order.indexOf(id);
70+
if (idx === -1) {
71+
return;
72+
}
73+
this.order.splice(idx, 1);
74+
this.states.delete(id);
75+
this.rebuild();
76+
}
77+
6778
clearListeners(): void {
6879
this.listeners.clear();
6980
}

src/ui/session-list.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@ export const SessionList: FC<{
157157
model: () => setModelSelect(true),
158158
// `/prompt` はリポジトリ追加指示(.codiva/prompt.md)のエディタを開く。
159159
prompt: () => setPromptEdit(true),
160+
// `/clear` は完了したセッションを一覧から消去する(worktree/履歴は残す)。
161+
// 実行中セッションは残るため確認は不要(core 側で終端状態のみ対象にする)。
162+
clear: () => manager.clear(),
160163
},
161164
setActionError,
162165
m.command.unknown,

tests/commands.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,27 @@ describe('slash commands', () => {
9393
expect(lastFrame() ?? '').not.toContain(messages.ja.prompt.title);
9494
});
9595

96+
it('lists /clear in the command palette', async () => {
97+
const { stdin, lastFrame } = render(<App manager={makeManager()} />);
98+
stdin.write('/clear');
99+
await flush();
100+
const frame = lastFrame() ?? '';
101+
expect(frame).toContain('/clear');
102+
expect(frame).toContain(messages.ja.command.clear); // description shown
103+
});
104+
105+
it('/clear clears the session list and creates no session', async () => {
106+
const manager = makeManager();
107+
const clear = vi.spyOn(manager, 'clear');
108+
const { stdin } = render(<App manager={manager} />);
109+
stdin.write('/clear');
110+
await flush();
111+
stdin.write('\r');
112+
await flush();
113+
expect(clear).toHaveBeenCalledOnce();
114+
expect(manager.getSnapshot()).toHaveLength(0);
115+
});
116+
96117
it('reports an unknown command as an error', async () => {
97118
const manager = makeManager();
98119
const { stdin, lastFrame } = render(<App manager={manager} />);

0 commit comments

Comments
 (0)