diff --git a/.changeset/delete-session-from-picker.md b/.changeset/delete-session-from-picker.md new file mode 100644 index 00000000000..48e0b61b145 --- /dev/null +++ b/.changeset/delete-session-from-picker.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Delete sessions from the session picker: press Ctrl+X on a session, then y to confirm. diff --git a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts index 0fc77224f40..cddb55377ef 100644 --- a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts @@ -13,6 +13,7 @@ import { import { formatSessionLabel } from '#/migration/index'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; +import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; export interface SessionRow { @@ -81,7 +82,7 @@ function sessionSearchText(session: SessionRow): string { export class SessionPickerComponent extends Container implements Focusable { private sessions: SessionRow[]; private currentSessionId: string; - private onSelect: (session: SessionRow) => void; + private onSelect: (session: SessionRow) => void | Promise; private onCancel: () => void; private onToggleScope?: (selectedSessionId: string) => void; private maxVisibleSessions: number; @@ -92,6 +93,8 @@ export class SessionPickerComponent extends Container implements Focusable { private hasMore: boolean; private loadingMore: boolean; private list: SearchableList; + private deleteState?: { session: SessionRow; phase: 'confirm' | 'deleting' }; + private selectInFlight = false; focused = false; @@ -102,7 +105,7 @@ export class SessionPickerComponent extends Container implements Focusable { scope?: 'cwd' | 'all'; initialSelectedSessionId?: string; pageSize?: number; - onSelect: (session: SessionRow) => void; + onSelect: (session: SessionRow) => void | Promise; onCancel: () => void; onCtrlC?: () => void; onCtrlD?: () => void; @@ -116,6 +119,8 @@ export class SessionPickerComponent extends Container implements Focusable { onLoadMore?: () => void; /** Fired when a search query becomes active while pages remain unfetched. */ onSearchDrain?: () => void; + /** Fired after the user confirms deletion with `y`; the picker clears its delete state once the request settles. */ + onDeleteRequest?: (session: SessionRow) => Promise; }) { super(); this.sessions = opts.sessions; @@ -143,12 +148,14 @@ export class SessionPickerComponent extends Container implements Focusable { this.visibleCount = Math.min(this.sessions.length, initialLoadedPages * this.pageSize); this.onCtrlC = opts.onCtrlC; this.onCtrlD = opts.onCtrlD; + this.onDeleteRequest = opts.onDeleteRequest; } private readonly onCtrlC?: () => void; private readonly onCtrlD?: () => void; private readonly onLoadMore?: () => void; private readonly onSearchDrain?: () => void; + private readonly onDeleteRequest?: (session: SessionRow) => Promise; /** Appends a freshly fetched page, keeping the cursor and active query. */ appendSessions(rows: SessionRow[]): void { @@ -210,6 +217,13 @@ export class SessionPickerComponent extends Container implements Focusable { } handleInput(data: string): void { + if (this.deleteState !== undefined) { + this.handleDeleteInput(data); + return; + } + // A selection runs resume/switch asynchronously; input during that window + // (e.g. Ctrl+X delete) would race the session swap. + if (this.selectInFlight) return; if (matchesKey(data, Key.ctrl('c'))) { this.onCtrlC?.(); return; @@ -222,6 +236,14 @@ export class SessionPickerComponent extends Container implements Focusable { this.onToggleScope?.(this.list.selected()?.id ?? this.currentSessionId); return; } + if (matchesKey(data, Key.ctrl('x'))) { + const selected = this.list.selected(); + if (selected !== undefined && this.onDeleteRequest !== undefined) { + this.deleteState = { session: selected, phase: 'confirm' }; + this.invalidate(); + } + return; + } if (matchesKey(data, Key.escape)) { if (this.list.clearQuery()) { this.visibleCount = Math.min(this.filteredSessions().length, this.pageSize); @@ -232,7 +254,16 @@ export class SessionPickerComponent extends Container implements Focusable { } if (matchesKey(data, Key.enter)) { const session = this.list.selected(); - if (session) this.onSelect(session); + if (session) { + const selection = this.onSelect(session); + if (selection !== undefined) { + this.selectInFlight = true; + const clear = (): void => { + this.selectInFlight = false; + }; + void selection.then(clear, clear); + } + } return; } @@ -242,6 +273,54 @@ export class SessionPickerComponent extends Container implements Focusable { } } + private handleDeleteInput(data: string): void { + const state = this.deleteState; + if (state === undefined || state.phase === 'deleting') return; + const k = printableChar(data); + if (matchesKey(data, Key.escape) || k === 'n' || k === 'N') { + this.deleteState = undefined; + this.invalidate(); + return; + } + if (k === 'y' || k === 'Y') { + this.deleteState = { session: state.session, phase: 'deleting' }; + this.invalidate(); + const sessionId = state.session.id; + const clear = (): void => { + if (this.deleteState?.session.id !== sessionId) return; + this.deleteState = undefined; + this.invalidate(); + }; + // then(clear, clear): rejections settle too — the host has already surfaced the failure. + void this.onDeleteRequest?.(state.session).then(clear, clear); + } + } + + private renderDeleteStateLine(width: number): string { + const state = this.deleteState; + if (state === undefined) return ''; + const rawTitle = (state.session.title ?? state.session.id).trim() || state.session.id; + const label = singleLine( + formatSessionLabel({ title: rawTitle, metadata: state.session.metadata }), + ); + const prefix = state.phase === 'confirm' ? 'Delete session "' : 'Deleting session "'; + const suffix = state.phase === 'confirm' ? '"? [y/N]' : '"…'; + const labelBudget = Math.max(0, width - visibleWidth(prefix) - visibleWidth(suffix)); + const shown = truncateToWidth(label, labelBudget, ELLIPSIS); + // The suffix carries the confirm/cancel keys: it survives by truncating + // the head (prefix + label) instead of the composed line. + const head = truncateToWidth( + prefix + shown, + Math.max(0, width - visibleWidth(suffix)), + ELLIPSIS, + ); + const styled = + state.phase === 'confirm' + ? currentTheme.boldFg('warning', head + suffix) + : currentTheme.fg('textMuted', head + suffix); + return truncateToWidth(styled, width, ELLIPSIS); + } + override render(width: number): string[] { return this.renderLines(width).map((line) => truncateToWidth(line, width, ELLIPSIS)); } @@ -294,6 +373,7 @@ export class SessionPickerComponent extends Container implements Focusable { ...(view.query.length > 0 ? ['Backspace clear'] : []), '↑↓ navigate', scopeHint, + ...(this.onDeleteRequest !== undefined ? ['Ctrl+X delete'] : []), 'Enter select', 'Esc cancel', ].filter((item): item is string => item !== undefined); @@ -361,6 +441,11 @@ export class SessionPickerComponent extends Container implements Focusable { lines.push(currentTheme.fg('textMuted', truncateToWidth(footer, width, ELLIPSIS))); } + if (this.deleteState !== undefined) { + lines.push(''); + lines.push(this.renderDeleteStateLine(width)); + } + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines; } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 3caa7876f78..92e8e12ab27 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -3928,23 +3928,7 @@ export class KimiTUI { }): Promise { this.sessionPickerOptions = options; await this.fetchSessions('cwd'); - this.mountSessionPicker({ - applyStartupModes: options.applyStartupModes, - onCancel: () => { - this.hideSessionPicker(); - if (options.closeOnCancel) void this.stop(); - }, - onCtrlC: options.forwardEditorExit - ? () => { - this.state.editor.onCtrlC?.(); - } - : undefined, - onCtrlD: options.forwardEditorExit - ? () => { - this.state.editor.onCtrlD?.(); - } - : undefined, - }); + this.remountSessionPicker(); } private async toggleSessionPickerScope(selectedSessionId: string): Promise { @@ -3953,8 +3937,12 @@ export class KimiTUI { await this.fetchSessions(nextScope); if (requestToken !== this.sessionPickerScopeRequestToken) return; if (this.state.activeDialog !== 'session-picker') return; + this.remountSessionPicker(selectedSessionId); + } + + private remountSessionPicker(initialSelectedSessionId?: string): void { this.mountSessionPicker({ - initialSelectedSessionId: selectedSessionId, + initialSelectedSessionId, applyStartupModes: this.sessionPickerOptions.applyStartupModes, onCancel: () => { this.hideSessionPicker(); @@ -3981,6 +3969,68 @@ export class KimiTUI { this.restoreEditor(); } + private async deleteSessionFromPicker(session: SessionRow): Promise { + // Invalidate any pending scope-toggle remount: it would replace the picker + // that is about to lock itself for the delete. + this.sessionPickerScopeRequestToken += 1; + try { + await this.waitForLazyCreation(); + if (session.id === this.state.appState.sessionId && this.session !== undefined) { + await this.deleteCurrentSessionFromPicker(session); + return; + } + await this.harness.deleteSession(session.id); + // fetchSessions swallows refetch errors, so drop the row locally first — + // a failed refetch must not resurrect it in the remounted list. + this.state.sessions = this.state.sessions.filter((row) => row.id !== session.id); + const requestToken = ++this.sessionPickerScopeRequestToken; + await this.fetchSessions(this.state.sessionsScope); + if (requestToken !== this.sessionPickerScopeRequestToken) return; + if (this.state.activeDialog !== 'session-picker') return; + this.remountSessionPicker(); + this.showStatus('Session deleted.'); + } catch (error) { + this.showError(`Failed to delete session ${session.id}: ${formatErrorMessage(error)}`); + } + } + + private async deleteCurrentSessionFromPicker(session: SessionRow): Promise { + // The picker stays mounted (locking input) until the replacement session + // is ready — restoring the editor mid-flight would let a prompt race the swap. + try { + // Tear down before deleting so no events from the dying session reach the UI. + await this.closeSession('deleting session'); + await this.harness.deleteSession(session.id); + } catch (error) { + // The engine aborts a failed delete and keeps the session: reattach, + // falling back to a fresh session if it is gone. showError runs after + // the switch because switchToSession clears the transcript. + const message = `Failed to delete session ${session.id}: ${formatErrorMessage(error)}`; + try { + const resumed = await this.harness.resumeSession({ + id: session.id, + replayTurnLimit: REPLAY_FETCH_TURN_LIMIT, + }); + await this.switchToSession(resumed, `Resumed session (${resumed.id}).`); + } catch { + // Reattach failed and the session is already unloaded: detach before + // the fallback create so a failed create leaves no ghost UI behind. + this.setAppState({ sessionId: '' }); + this.clearTranscriptAndRedraw(); + await this.createNewSession(); + } + this.showError(message); + this.hideSessionPicker(); + return; + } + // The session is gone whether or not replacement creation succeeds: detach + // first so a failed create leaves no ghost (stale id + transcript) behind. + this.setAppState({ sessionId: '' }); + this.clearTranscriptAndRedraw(); + await this.createNewSession(); + this.hideSessionPicker(); + } + openUndoSelector(): void { void slashCommands.handleUndoCommand(this, ''); } @@ -4011,19 +4061,19 @@ export class KimiTUI { onSearchDrain: () => { void this.drainSessionsForSearch(); }, - onSelect: (session: SessionRow) => { - void this.handleSessionPickerSelect(session, options.applyStartupModes === true).catch( + onSelect: (session: SessionRow) => + this.handleSessionPickerSelect(session, options.applyStartupModes === true).catch( (error) => { this.showError(`Failed to apply startup flags: ${formatErrorMessage(error)}`); }, - ); - }, + ), onCancel: options.onCancel, onCtrlC: options.onCtrlC, onCtrlD: options.onCtrlD, onToggleScope: (selectedSessionId: string) => { void this.toggleSessionPickerScope(selectedSessionId); }, + onDeleteRequest: (session: SessionRow) => this.deleteSessionFromPicker(session), }); this.sessionPickerComponent = picker; this.mountEditorReplacement(picker); @@ -4033,6 +4083,9 @@ export class KimiTUI { session: SessionRow, applyStartupModes: boolean, ): Promise { + // Invalidate any pending scope-toggle remount: it would replace the picker + // and drop the selection lock. + this.sessionPickerScopeRequestToken += 1; if (resolve(session.work_dir) !== resolve(this.state.appState.workDir)) { await this.showResumeOtherWorkDirHint(session); if (applyStartupModes) await this.stop(0); diff --git a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts index 222adfa6a59..a4af77adaaf 100644 --- a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts @@ -869,4 +869,299 @@ describe('SessionPickerComponent', () => { expect(renderPlain(component)).toContain('· searching all…'); }); + + describe('session deletion', () => { + const CTRL_X = '\u0018'; + + function deferred(): { + promise: Promise; + resolve: () => void; + reject: (error: unknown) => void; + } { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + } + + async function flushMicrotasks(): Promise { + await new Promise((r) => { + setTimeout(r, 0); + }); + } + + const alpha = { id: 'ses_alpha', title: 'Alpha session', work_dir: '/tmp/p', updated_at: 2 }; + const beta = { id: 'ses_beta', title: 'Beta session', work_dir: '/tmp/p', updated_at: 1 }; + + it('arms an inline delete confirmation on Ctrl+X for the selected row', () => { + const onDeleteRequest = vi.fn(async () => {}); + const component = new SessionPickerComponent({ + sessions: [alpha, beta], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onDeleteRequest, + }); + + component.handleInput(CTRL_X); + + expect(renderPlain(component)).toContain('Delete session "Alpha session"? [y/N]'); + expect(onDeleteRequest).not.toHaveBeenCalled(); + }); + + it('does nothing on Ctrl+X without a delete handler or a selected row', () => { + const noHandler = new SessionPickerComponent({ + sessions: [alpha], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + noHandler.handleInput(CTRL_X); + expect(renderPlain(noHandler)).not.toContain('Delete session'); + + const noRows = new SessionPickerComponent({ + sessions: [], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onDeleteRequest: vi.fn(async () => {}), + }); + noRows.handleInput(CTRL_X); + expect(renderPlain(noRows)).not.toContain('Delete session'); + }); + + it('confirms on y, shows a deleting state, and clears it after success', async () => { + const { promise, resolve } = deferred(); + const onDeleteRequest = vi.fn(() => promise); + const component = new SessionPickerComponent({ + sessions: [alpha, beta], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onDeleteRequest, + }); + + component.handleInput(CTRL_X); + component.handleInput('y'); + + expect(onDeleteRequest).toHaveBeenCalledOnce(); + expect(onDeleteRequest).toHaveBeenCalledWith(alpha); + expect(renderPlain(component)).toContain('Deleting session "Alpha session"…'); + + resolve(); + await flushMicrotasks(); + + const output = renderPlain(component); + expect(output).not.toContain('Deleting session'); + expect(output).not.toContain('Delete session'); + }); + + it('cancels on n and on Esc without calling onDeleteRequest or onCancel', () => { + const onDeleteRequest = vi.fn(async () => {}); + const onCancel = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [alpha, beta], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel, + onDeleteRequest, + }); + + component.handleInput(CTRL_X); + component.handleInput('n'); + expect(renderPlain(component)).not.toContain('Delete session'); + + component.handleInput(CTRL_X); + component.handleInput(ESC); + expect(renderPlain(component)).not.toContain('Delete session'); + + expect(onDeleteRequest).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it('ignores all other keys while the confirmation is armed', () => { + const onDeleteRequest = vi.fn(async () => {}); + const onSelect = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [alpha, beta], + loading: false, + currentSessionId: '', + onSelect, + onCancel: vi.fn(), + onDeleteRequest, + }); + + component.handleInput(CTRL_X); + component.handleInput('\r'); + component.handleInput('\u001B[B'); + component.handleInput('x'); + component.handleInput(CTRL_X); + + expect(renderPlain(component)).toContain('Delete session "Alpha session"? [y/N]'); + expect(onDeleteRequest).not.toHaveBeenCalled(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('ignores keys while a delete is in flight', async () => { + const { promise, resolve } = deferred(); + const onDeleteRequest = vi.fn(() => promise); + const component = new SessionPickerComponent({ + sessions: [alpha, beta], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onDeleteRequest, + }); + + component.handleInput(CTRL_X); + component.handleInput('y'); + component.handleInput('y'); + component.handleInput(CTRL_X); + component.handleInput('\r'); + component.handleInput(ESC); + + expect(onDeleteRequest).toHaveBeenCalledOnce(); + + resolve(); + await flushMicrotasks(); + }); + + it('returns to the list when the delete fails', async () => { + const { promise, reject } = deferred(); + const onDeleteRequest = vi.fn(() => promise); + const component = new SessionPickerComponent({ + sessions: [alpha, beta], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onDeleteRequest, + }); + + component.handleInput(CTRL_X); + component.handleInput('y'); + reject(new Error('boom')); + await flushMicrotasks(); + + const output = renderPlain(component); + expect(output).not.toContain('Deleting session'); + expect(output).not.toContain('Delete session'); + expect(onDeleteRequest).toHaveBeenCalledOnce(); + }); + + it('adds Ctrl+X delete to the hint when deletion is available', () => { + const component = new SessionPickerComponent({ + sessions: [alpha], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onDeleteRequest: vi.fn(async () => {}), + }); + + expect(renderPlain(component)).toContain('Ctrl+X delete'); + }); + + it('ignores input while a selection is in flight', async () => { + const { promise, resolve } = deferred(); + const onSelect = vi.fn(() => promise); + const onDeleteRequest = vi.fn(async () => {}); + const component = new SessionPickerComponent({ + sessions: [alpha, beta], + loading: false, + currentSessionId: '', + onSelect, + onCancel: vi.fn(), + onDeleteRequest, + }); + + component.handleInput('\r'); + expect(onSelect).toHaveBeenCalledOnce(); + + component.handleInput(CTRL_X); + expect(renderPlain(component)).not.toContain('Delete session'); + component.handleInput('y'); + component.handleInput('\r'); + expect(onSelect).toHaveBeenCalledOnce(); + expect(onDeleteRequest).not.toHaveBeenCalled(); + + resolve(); + await flushMicrotasks(); + + component.handleInput(CTRL_X); + expect(renderPlain(component)).toContain('Delete session "Alpha session"? [y/N]'); + }); + + it('unlocks input when the selection fails', async () => { + const { promise, reject } = deferred(); + const onSelect = vi.fn(() => promise); + const component = new SessionPickerComponent({ + sessions: [alpha, beta], + loading: false, + currentSessionId: '', + onSelect, + onCancel: vi.fn(), + onDeleteRequest: vi.fn(async () => {}), + }); + + component.handleInput('\r'); + expect(onSelect).toHaveBeenCalledOnce(); + + reject(new Error('boom')); + await flushMicrotasks(); + + component.handleInput(CTRL_X); + expect(renderPlain(component)).toContain('Delete session "Alpha session"? [y/N]'); + }); + + it('keeps every line within the terminal width with a delete confirmation armed', () => { + const component = new SessionPickerComponent({ + sessions: [alpha, beta], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onDeleteRequest: vi.fn(async () => {}), + }); + component.handleInput(CTRL_X); + + for (const width of [10, 20, 24, 40]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + + it('keeps the [y/N] confirmation keys visible when the title is truncated', () => { + const longTitled = { + id: 'ses_long', + title: 'A very long session title that cannot fit a narrow terminal', + work_dir: '/tmp/p', + updated_at: 2, + }; + const component = new SessionPickerComponent({ + sessions: [longTitled], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onDeleteRequest: vi.fn(async () => {}), + }); + + component.handleInput(CTRL_X); + + for (const width of [40, 24, 20, 12, 8]) { + expect(renderPlain(component, width)).toContain('? [y/N]'); + } + }); + }); }); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index b3e25b3c491..567220d54ae 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -1184,6 +1184,466 @@ describe('KimiTUI startup', () => { expect(output).not.toContain('Search: cwd'); }); + it('deletes a session from the picker and refreshes the list', async () => { + const sesA = { id: 'ses-a', title: 'Session A', workDir: '/tmp/proj-a', updatedAt: Date.now() }; + const sesB = { + id: 'ses-b', + title: 'Session B', + workDir: '/tmp/proj-a', + updatedAt: Date.now() - 1000, + }; + let deleted = false; + const listSessions = vi.fn(async () => (deleted ? [sesB] : [sesA, sesB])); + const deleteSession = vi.fn(async () => { + deleted = true; + }); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions, deleteSession }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + picker.handleInput('\u0018'); + expect(picker.render(160).join('\n')).toContain('Delete session "Session A"? [y/N]'); + picker.handleInput('y'); + + await vi.waitFor(() => { + expect(deleteSession).toHaveBeenCalledWith('ses-a'); + }); + await vi.waitFor(() => { + const remounted = driver.state.editorContainer.children[0] as { + render(width: number): string[]; + }; + expect(remounted.render(160).join('\n')).not.toContain('Session A'); + }); + expect(driver.state.activeDialog).toBe('session-picker'); + }); + + it('deleting the current session closes it, deletes it, and starts a new session', async () => { + const session = makeSession({ id: 'ses-current' }); + const sesCurrent = { + id: 'ses-current', + title: 'Current session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + let resolveDelete!: () => void; + const deleteSession = vi.fn( + () => + new Promise((resolve) => { + resolveDelete = resolve; + }), + ); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [sesCurrent]), + deleteSession, + }); + const driver = makeDriver(harness, makeStartupInput({ model: 'k2' })); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { createNewSession(): Promise }).createNewSession(); + expect(driver.state.appState.sessionId).toBe('ses-current'); + // Contentless current sessions are filtered out of picker rows; fake content so the row exists. + vi.spyOn(driver as unknown as { hasSessionContent(): boolean }, 'hasSessionContent') + .mockReturnValue(true); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0018'); + picker.handleInput('y'); + + // The picker (and its input lock) stays mounted until the replacement + // session is ready; the editor must not accept input mid-flight. + await vi.waitFor(() => { + expect(deleteSession).toHaveBeenCalledWith('ses-current'); + }); + expect(driver.state.activeDialog).toBe('session-picker'); + resolveDelete(); + + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledTimes(2); + }); + expect(session.close).toHaveBeenCalled(); + expect(driver.state.activeDialog).toBeNull(); + }); + + it('reattaches to the current session when deleting it fails', async () => { + const session = makeSession({ id: 'ses-current' }); + const sesCurrent = { + id: 'ses-current', + title: 'Current session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const deleteSession = vi.fn(async () => { + throw new Error('boom'); + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [sesCurrent]), + deleteSession, + }); + const driver = makeDriver(harness, makeStartupInput({ model: 'k2' })); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { createNewSession(): Promise }).createNewSession(); + vi.spyOn(driver as unknown as { hasSessionContent(): boolean }, 'hasSessionContent') + .mockReturnValue(true); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0018'); + picker.handleInput('y'); + + await vi.waitFor(() => { + expect(harness.resumeSession).toHaveBeenCalledWith({ + id: 'ses-current', + replayTurnLimit: REPLAY_FETCH_TURN_LIMIT, + }); + }); + await vi.waitFor(() => { + expect(driver.state.appState.sessionId).toBe('ses-current'); + }); + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).toContain('Failed to delete session ses-current'); + expect(harness.createSession).toHaveBeenCalledTimes(1); + }); + + it('reattaches when closing the current session fails during deletion', async () => { + const session = makeSession({ + id: 'ses-current', + close: vi.fn(async () => { + throw new Error('close boom'); + }), + }); + const sesCurrent = { + id: 'ses-current', + title: 'Current session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const deleteSession = vi.fn(async () => {}); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [sesCurrent]), + deleteSession, + }); + const driver = makeDriver(harness, makeStartupInput({ model: 'k2' })); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { createNewSession(): Promise }).createNewSession(); + vi.spyOn(driver as unknown as { hasSessionContent(): boolean }, 'hasSessionContent') + .mockReturnValue(true); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0018'); + picker.handleInput('y'); + + await vi.waitFor(() => { + expect(harness.resumeSession).toHaveBeenCalledWith({ + id: 'ses-current', + replayTurnLimit: REPLAY_FETCH_TURN_LIMIT, + }); + }); + expect(deleteSession).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect(driver.state.appState.sessionId).toBe('ses-current'); + }); + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).toContain('Failed to delete session ses-current'); + }); + + it('drops the deleted row locally when the post-delete list refresh fails', async () => { + const sesA = { id: 'ses-a', title: 'Session A', workDir: '/tmp/proj-a', updatedAt: Date.now() }; + const sesB = { + id: 'ses-b', + title: 'Session B', + workDir: '/tmp/proj-a', + updatedAt: Date.now() - 1000, + }; + let refreshCalls = 0; + const listSessions = vi.fn(async () => { + refreshCalls += 1; + if (refreshCalls > 1) throw new Error('refresh boom'); + return [sesA, sesB]; + }); + const deleteSession = vi.fn(async () => {}); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions, deleteSession }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0018'); + picker.handleInput('y'); + + await vi.waitFor(() => { + expect(deleteSession).toHaveBeenCalledWith('ses-a'); + }); + await vi.waitFor(() => { + const remounted = driver.state.editorContainer.children[0] as { + render(width: number): string[]; + }; + const output = remounted.render(160).join('\n'); + expect(output).not.toContain('Session A'); + expect(output).toContain('Session B'); + }); + expect(driver.state.activeDialog).toBe('session-picker'); + }); + + it('keeps the picker open and surfaces an error when deletion fails', async () => { + const sesA = { id: 'ses-a', title: 'Session A', workDir: '/tmp/proj-a', updatedAt: Date.now() }; + const deleteSession = vi.fn(async () => { + throw new Error('boom'); + }); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { + listSessions: vi.fn(async () => [sesA]), + deleteSession, + }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0018'); + picker.handleInput('y'); + + await vi.waitFor(() => { + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).toContain('Failed to delete session ses-a'); + }); + expect(driver.state.activeDialog).toBe('session-picker'); + }); + + it('does not arm deletion while a picker selection is in flight', async () => { + const picked = makeSession({ id: 'ses-2' }); + let resolveResume!: (session: unknown) => void; + const resumeSession = vi.fn( + () => + new Promise((resolve) => { + resolveResume = resolve; + }), + ); + const deleteSession = vi.fn(async () => {}); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { + resumeSession, + deleteSession, + listSessions: vi.fn(async () => [ + { id: 'ses-2', title: 'Other session', workDir: '/tmp/proj-a', updatedAt: Date.now() }, + ]), + }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + picker.handleInput('\r'); + await vi.waitFor(() => { + expect(resumeSession).toHaveBeenCalled(); + }); + + picker.handleInput('\u0018'); + expect(picker.render(160).join('\n')).not.toContain('Delete session'); + expect(deleteSession).not.toHaveBeenCalled(); + + resolveResume(picked); + await vi.waitFor(() => { + expect(driver.state.activeDialog).toBeNull(); + }); + }); + + it('does not remount the picker while a deletion is in flight and a scope toggle is pending', async () => { + const sesA = { id: 'ses-a', title: 'Session A', workDir: '/tmp/proj-a', updatedAt: Date.now() }; + const sesB = { + id: 'ses-b', + title: 'Session B', + workDir: '/tmp/proj-a', + updatedAt: Date.now() - 1000, + }; + let resolveAllSessions: ((value: unknown[]) => void) | undefined; + let resolveDelete: (() => void) | undefined; + let allFetchPending = true; + const listSessions = vi.fn((input: { workDir?: string } = {}) => { + if (input.workDir === '/tmp/proj-a') return Promise.resolve([sesA, sesB]); + if (allFetchPending) { + allFetchPending = false; + return new Promise((resolve) => { + resolveAllSessions = resolve; + }); + } + return Promise.resolve([sesA, sesB]); + }); + const deleteSession = vi.fn( + () => + new Promise((resolve) => { + resolveDelete = resolve; + }), + ); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions, deleteSession }); + const driver = makeDriver(harness, makeStartupInput()); + const mountSessionPicker = vi.spyOn( + driver as unknown as { mountSessionPicker(options: unknown): void }, + 'mountSessionPicker', + ); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + expect(mountSessionPicker).toHaveBeenCalledTimes(1); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0001'); + picker.handleInput('\u0018'); + picker.handleInput('y'); + await vi.waitFor(() => { + expect(deleteSession).toHaveBeenCalledWith('ses-a'); + }); + + resolveAllSessions?.([sesA, sesB]); + await new Promise((resolve) => setImmediate(resolve)); + + expect(mountSessionPicker).toHaveBeenCalledTimes(1); + expect(driver.state.editorContainer.children[0]).toBe(picker); + + resolveDelete?.(); + await vi.waitFor(() => { + expect(mountSessionPicker).toHaveBeenCalledTimes(2); + }); + }); + + it('does not remount the picker while a selection is in flight and a scope toggle is pending', async () => { + const picked = makeSession({ id: 'ses-2' }); + const ses2 = { id: 'ses-2', title: 'Other session', workDir: '/tmp/proj-a', updatedAt: Date.now() }; + let resolveAllSessions: ((value: unknown[]) => void) | undefined; + let resolveResume: ((value: unknown) => void) | undefined; + const listSessions = vi.fn((input: { workDir?: string } = {}) => { + if (input.workDir === '/tmp/proj-a') return Promise.resolve([ses2]); + return new Promise((resolve) => { + resolveAllSessions = resolve; + }); + }); + const resumeSession = vi.fn( + () => + new Promise((resolve) => { + resolveResume = resolve; + }), + ); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessions, resumeSession }); + const driver = makeDriver(harness, makeStartupInput()); + const mountSessionPicker = vi.spyOn( + driver as unknown as { mountSessionPicker(options: unknown): void }, + 'mountSessionPicker', + ); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + expect(mountSessionPicker).toHaveBeenCalledTimes(1); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0001'); + picker.handleInput('\r'); + await vi.waitFor(() => { + expect(resumeSession).toHaveBeenCalled(); + }); + + resolveAllSessions?.([ses2]); + await new Promise((resolve) => setImmediate(resolve)); + + expect(mountSessionPicker).toHaveBeenCalledTimes(1); + expect(driver.state.editorContainer.children[0]).toBe(picker); + + resolveResume?.(picked); + await vi.waitFor(() => { + expect(driver.state.activeDialog).toBeNull(); + }); + }); + + it('resets the detached UI when replacement creation fails after deleting the current session', async () => { + const session = makeSession({ id: 'ses-current' }); + const sesCurrent = { + id: 'ses-current', + title: 'Current session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const deleteSession = vi.fn(async () => {}); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [sesCurrent]), + deleteSession, + }); + const driver = makeDriver(harness, makeStartupInput({ model: 'k2' })); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { createNewSession(): Promise }).createNewSession(); + expect(driver.state.appState.sessionId).toBe('ses-current'); + // Contentless current sessions are filtered out of picker rows; fake content so the row exists. + vi.spyOn(driver as unknown as { hasSessionContent(): boolean }, 'hasSessionContent') + .mockReturnValue(true); + harness.createSession.mockRejectedValueOnce(new Error('create boom')); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0018'); + picker.handleInput('y'); + + await vi.waitFor(() => { + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).toContain('Failed to start a new session'); + }); + expect(driver.state.appState.sessionId).toBe(''); + expect(driver.state.activeDialog).toBeNull(); + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).not.toContain('Started a new session (ses-current)'); + }); + + it('resets the detached UI when recovery creation also fails after a failed delete', async () => { + const session = makeSession({ id: 'ses-current' }); + const sesCurrent = { + id: 'ses-current', + title: 'Current session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const deleteSession = vi.fn(async () => { + throw new Error('delete boom'); + }); + const resumeSession = vi.fn(async () => { + throw new Error('resume boom'); + }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [sesCurrent]), + deleteSession, + resumeSession, + }); + const driver = makeDriver(harness, makeStartupInput({ model: 'k2' })); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { createNewSession(): Promise }).createNewSession(); + expect(driver.state.appState.sessionId).toBe('ses-current'); + // Contentless current sessions are filtered out of picker rows; fake content so the row exists. + vi.spyOn(driver as unknown as { hasSessionContent(): boolean }, 'hasSessionContent') + .mockReturnValue(true); + harness.createSession.mockRejectedValueOnce(new Error('create boom')); + + await (driver as unknown as { showSessionPicker(): Promise }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u0018'); + picker.handleInput('y'); + + await vi.waitFor(() => { + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).toContain('Failed to delete session ses-current'); + }); + expect(driver.state.appState.sessionId).toBe(''); + expect(driver.state.activeDialog).toBeNull(); + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).not.toContain('Started a new session (ses-current)'); + }); + it('does not resume a session from a different cwd and shows a cd hint', async () => { const currentWorkDirSession = { id: 'ses-cwd',