-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat: delete sessions from the session picker #3670
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<void>; | ||
| 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<SessionRow>; | ||
| 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<void>; | ||
| 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<void>; | ||
| }) { | ||
| 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<void>; | ||
|
|
||
| /** 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); | ||
|
Comment on lines
+317
to
+320
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Whenever deletion confirmation is shown, this styles the entire prompt with AGENTS.md reference: apps/kimi-code/AGENTS.md:L5-L5 Useful? React with 👍 / 👎. |
||
| return truncateToWidth(styled, width, ELLIPSIS); | ||
|
Comment on lines
+317
to
+321
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a generated session title is long or the terminal is narrow, truncating the entire confirmation string removes the trailing AGENTS.md reference: apps/kimi-code/AGENTS.md:L5-L5 Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| 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; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3928,23 +3928,7 @@ export class KimiTUI { | |
| }): Promise<void> { | ||
| 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<void> { | ||
|
|
@@ -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<void> { | ||
| // 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(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a Ctrl+A scope fetch is already running when the user presses Ctrl+X and confirms, this path leaves its request token valid until after the non-current deletion, and the current-session path does not invalidate it until the picker closes. The scope request can therefore finish and call Useful? React with 👍 / 👎. |
||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When deletion succeeds but this refresh fails, Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a user deletes a row before all backend pages have been loaded and this refresh fails, AGENTS.md reference: apps/kimi-code/AGENTS.md:L5-L5 Useful? React with 👍 / 👎. |
||
| 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<void> { | ||
| // 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(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If closing or deleting the current session fails, and resuming it also fails, Useful? React with 👍 / 👎.
Comment on lines
+4018
to
+4020
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When deletion or close fails, Useful? React with 👍 / 👎. |
||
| } | ||
| 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(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When deleting the current session succeeds but Useful? React with 👍 / 👎. |
||
| 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<void> { | ||
| // 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); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
Enterhas already started the asynchronousonSelectflow, the picker remains interactive, so pressing Ctrl+X and confirming can run deletion concurrently withresumeSession/switchToSession. For the current row, the selection flow can callhideSessionPicker()while deletion is still replacing the session; for another row, deletion can close the newly resumedSessionwhileswitchToSession()is initializing it, leaving the TUI attached to a closed/deleted session. Fresh evidence beyond the earlier input-lock finding is thathandleInputpermits this delete path after an in-flight selection; lock the picker when either lifecycle action starts or serialize both operations.Useful? React with 👍 / 👎.