diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index df7e085d..a34924c1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added `strictResponsesPairing` compat option for custom OpenAI Responses models on Azure ([#768](https://github.com/badlogic/pi-mono/pull/768) by [@nicobako](https://github.com/nicobako)) +- Session selector (`/resume`) now supports path display toggle (`Ctrl+P`) and session deletion (`Ctrl+D`) with inline confirmation ([#816](https://github.com/badlogic/pi-mono/pull/816) by [@w-winter](https://github.com/w-winter)) ### Changed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 6e4b89e4..2994cf50 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -541,6 +541,10 @@ pi --session /path/to/file.jsonl # Use specific session file pi --session a8ec1c2a # Resume by session ID (partial UUID) ``` +In the `/resume` picker: +- `Ctrl+P` toggles display of the session `.jsonl` file path +- `Ctrl+D` deletes the selected session (inline confirmation; uses `trash` if available and cannot delete the active session) + **Resuming by session ID:** The `--session` flag accepts a session UUID (or prefix). Session IDs are visible in filenames under `~/.pi/agent/sessions//` (e.g., `2025-12-13T17-47-46-817Z_a8ec1c2a-5a5f-4699-88cb-03e7d3cb9292.jsonl`). The UUID is the part after the underscore. You can also search by session ID in the `pi -r` picker. ### Context Compaction diff --git a/packages/coding-agent/docs/session.md b/packages/coding-agent/docs/session.md index 281fec48..10d0a268 100644 --- a/packages/coding-agent/docs/session.md +++ b/packages/coding-agent/docs/session.md @@ -10,6 +10,12 @@ Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with Where `` is the working directory with `/` replaced by `-`. +## Deleting Sessions + +Sessions can be removed by deleting their `.jsonl` files under `~/.pi/agent/sessions/`. + +Pi also supports deleting sessions interactively from `/resume` (select a session and press `Ctrl+D`, then confirm). When available, pi uses the `trash` CLI to avoid permanent deletion. + ## Session Version Sessions have a version field in the header: diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index 918e3dad..6e67ac49 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -1,3 +1,6 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { unlink } from "node:fs/promises"; import * as os from "node:os"; import { type Component, @@ -45,12 +48,18 @@ function formatSessionDate(date: Date): string { class SessionSelectorHeader implements Component { private scope: SessionScope; private sortMode: SortMode; + private requestRender: () => void; private loading = false; private loadProgress: { loaded: number; total: number } | null = null; + private showPath = false; + private confirmingDeletePath: string | null = null; + private statusMessage: { type: "info" | "error"; message: string } | null = null; + private statusTimeout: ReturnType | null = null; - constructor(scope: SessionScope, sortMode: SortMode) { + constructor(scope: SessionScope, sortMode: SortMode, requestRender: () => void) { this.scope = scope; this.sortMode = sortMode; + this.requestRender = requestRender; } setScope(scope: SessionScope): void { @@ -63,15 +72,40 @@ class SessionSelectorHeader implements Component { setLoading(loading: boolean): void { this.loading = loading; - if (!loading) { - this.loadProgress = null; - } + // Progress is scoped to the current load; clear whenever the loading state is set + this.loadProgress = null; } setProgress(loaded: number, total: number): void { this.loadProgress = { loaded, total }; } + setShowPath(showPath: boolean): void { + this.showPath = showPath; + } + + setConfirmingDeletePath(path: string | null): void { + this.confirmingDeletePath = path; + } + + private clearStatusTimeout(): void { + if (!this.statusTimeout) return; + clearTimeout(this.statusTimeout); + this.statusTimeout = null; + } + + setStatusMessage(msg: { type: "info" | "error"; message: string } | null, autoHideMs?: number): void { + this.clearStatusTimeout(); + this.statusMessage = msg; + if (!msg || !autoHideMs) return; + + this.statusTimeout = setTimeout(() => { + this.statusMessage = null; + this.statusTimeout = null; + this.requestRender(); + }, autoHideMs); + } + invalidate(): void {} render(width: number): string[] { @@ -85,21 +119,37 @@ class SessionSelectorHeader implements Component { if (this.loading) { const progressText = this.loadProgress ? `${this.loadProgress.loaded}/${this.loadProgress.total}` : "..."; scopeText = `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", `Loading ${progressText}`)}`; + } else if (this.scope === "current") { + scopeText = `${theme.fg("accent", "◉ Current Folder")}${theme.fg("muted", " | ○ All")}`; } else { - scopeText = - this.scope === "current" - ? `${theme.fg("accent", "◉ Current Folder")}${theme.fg("muted", " | ○ All")}` - : `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", "◉ All")}`; + scopeText = `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", "◉ All")}`; } const rightText = truncateToWidth(`${scopeText} ${sortText}`, width, ""); const availableLeft = Math.max(0, width - visibleWidth(rightText) - 1); const left = truncateToWidth(leftText, availableLeft, ""); const spacing = Math.max(0, width - visibleWidth(left) - visibleWidth(rightText)); - const hintText = 'Tab: scope · Ctrl+R: sort · re: for regex · "phrase" for exact phrase'; - const truncatedHint = truncateToWidth(hintText, width, "…"); - const hint = theme.fg("muted", truncatedHint); - return [`${left}${" ".repeat(spacing)}${rightText}`, hint]; + + // Build hint lines - changes based on state (all branches truncate to width) + let hintLine1: string; + let hintLine2: string; + if (this.confirmingDeletePath !== null) { + const confirmHint = "Delete session? [Enter] confirm · [Esc/Ctrl+C] cancel"; + hintLine1 = theme.fg("error", truncateToWidth(confirmHint, width, "…")); + hintLine2 = ""; + } else if (this.statusMessage) { + const color = this.statusMessage.type === "error" ? "error" : "accent"; + hintLine1 = theme.fg(color, truncateToWidth(this.statusMessage.message, width, "…")); + hintLine2 = ""; + } else { + const pathState = this.showPath ? "(on)" : "(off)"; + const hint1 = `Tab: scope · re: for regex · "phrase" for exact phrase`; + const hint2 = `Ctrl+R: sort · Ctrl+D: delete · Ctrl+P: path ${pathState}`; + hintLine1 = theme.fg("muted", truncateToWidth(hint1, width, "…")); + hintLine2 = theme.fg("muted", truncateToWidth(hint2, width, "…")); + } + + return [`${left}${" ".repeat(spacing)}${rightText}`, hintLine1, hintLine2]; } } @@ -113,12 +163,19 @@ class SessionList implements Component, Focusable { private searchInput: Input; private showCwd = false; private sortMode: SortMode = "relevance"; + private showPath = false; + private confirmingDeletePath: string | null = null; + private currentSessionFilePath?: string; public onSelect?: (sessionPath: string) => void; public onCancel?: () => void; public onExit: () => void = () => {}; public onToggleScope?: () => void; public onToggleSort?: () => void; - private maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank) + public onTogglePath?: (showPath: boolean) => void; + public onDeleteConfirmationChange?: (path: string | null) => void; + public onDeleteSession?: (sessionPath: string) => Promise; + public onError?: (message: string) => void; + private maxVisible: number = 5; // Max sessions visible (each session: message + metadata + optional path + blank) // Focusable implementation - propagate to searchInput for IME cursor positioning private _focused = false; @@ -130,12 +187,13 @@ class SessionList implements Component, Focusable { this.searchInput.focused = value; } - constructor(sessions: SessionInfo[], showCwd: boolean, sortMode: SortMode) { + constructor(sessions: SessionInfo[], showCwd: boolean, sortMode: SortMode, currentSessionFilePath?: string) { this.allSessions = sessions; this.filteredSessions = sessions; this.searchInput = new Input(); this.showCwd = showCwd; this.sortMode = sortMode; + this.currentSessionFilePath = currentSessionFilePath; // Handle Enter in search input - select current item this.searchInput.onSubmit = () => { @@ -164,6 +222,24 @@ class SessionList implements Component, Focusable { this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1)); } + private setConfirmingDeletePath(path: string | null): void { + this.confirmingDeletePath = path; + this.onDeleteConfirmationChange?.(path); + } + + private startDeleteConfirmationForSelectedSession(): void { + const selected = this.filteredSessions[this.selectedIndex]; + if (!selected) return; + + // Prevent deleting current session + if (this.currentSessionFilePath && selected.path === this.currentSessionFilePath) { + this.onError?.("Cannot delete the currently active session"); + return; + } + + this.setConfirmingDeletePath(selected.path); + } + invalidate(): void {} render(width: number): string[] { @@ -196,10 +272,11 @@ class SessionList implements Component, Focusable { ); const endIndex = Math.min(startIndex + this.maxVisible, this.filteredSessions.length); - // Render visible sessions (2 lines per session + blank line) + // Render visible sessions (message + metadata + optional path + blank line) for (let i = startIndex; i < endIndex; i++) { const session = this.filteredSessions[i]; const isSelected = i === this.selectedIndex; + const isConfirmingDelete = session.path === this.confirmingDeletePath; // Use session name if set, otherwise first message const hasName = !!session.name; @@ -211,10 +288,13 @@ class SessionList implements Component, Focusable { const cursor = isSelected ? theme.fg("accent", "› ") : " "; const maxMsgWidth = width - 2; // Account for cursor (2 visible chars) const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth, "..."); - let styledMsg = truncatedMsg; - if (hasName) { - styledMsg = theme.fg("warning", truncatedMsg); + let messageColor: "error" | "warning" | null = null; + if (isConfirmingDelete) { + messageColor = "error"; + } else if (hasName) { + messageColor = "warning"; } + let styledMsg = messageColor ? theme.fg(messageColor, truncatedMsg) : truncatedMsg; if (isSelected) { styledMsg = theme.bold(styledMsg); } @@ -228,10 +308,20 @@ class SessionList implements Component, Focusable { metadataParts.push(shortenPath(session.cwd)); } const metadata = ` ${metadataParts.join(" · ")}`; - const metadataLine = theme.fg("dim", truncateToWidth(metadata, width, "")); + const truncatedMetadata = truncateToWidth(metadata, width, ""); + const metadataLine = theme.fg(isConfirmingDelete ? "error" : "dim", truncatedMetadata); lines.push(messageLine); lines.push(metadataLine); + + // Optional third line: file path (when showPath is enabled) + if (this.showPath) { + const pathText = ` ${shortenPath(session.path)}`; + const truncatedPath = truncateToWidth(pathText, width, "…"); + const pathLine = theme.fg(isConfirmingDelete ? "error" : "muted", truncatedPath); + lines.push(pathLine); + } + lines.push(""); // Blank line between sessions } @@ -247,6 +337,24 @@ class SessionList implements Component, Focusable { handleInput(keyData: string): void { const kb = getEditorKeybindings(); + + // Handle delete confirmation state first - intercept all keys + if (this.confirmingDeletePath !== null) { + if (kb.matches(keyData, "selectConfirm")) { + const pathToDelete = this.confirmingDeletePath; + this.setConfirmingDeletePath(null); + void this.onDeleteSession?.(pathToDelete); + return; + } + // Allow both Escape and Ctrl+C to cancel (consistent with pi UX) + if (kb.matches(keyData, "selectCancel") || matchesKey(keyData, "ctrl+c")) { + this.setConfirmingDeletePath(null); + return; + } + // Ignore all other keys while confirming + return; + } + if (kb.matches(keyData, "tab")) { if (this.onToggleScope) { this.onToggleScope(); @@ -259,6 +367,32 @@ class SessionList implements Component, Focusable { return; } + // Ctrl+P: toggle path display + if (matchesKey(keyData, "ctrl+p")) { + this.showPath = !this.showPath; + this.onTogglePath?.(this.showPath); + return; + } + + // Ctrl+D: initiate delete confirmation (useful on terminals that don't distinguish Ctrl+Backspace from Backspace) + if (matchesKey(keyData, "ctrl+d")) { + this.startDeleteConfirmationForSelectedSession(); + return; + } + + // Ctrl+Backspace: non-invasive convenience alias for delete + // Only triggers deletion when the query is empty; otherwise it is forwarded to the input + if (matchesKey(keyData, "ctrl+backspace")) { + if (this.searchInput.getValue().length > 0) { + this.searchInput.handleInput(keyData); + this.filterSessions(this.searchInput.getValue()); + return; + } + + this.startDeleteConfirmationForSelectedSession(); + return; + } + // Up arrow if (kb.matches(keyData, "selectUp")) { this.selectedIndex = Math.max(0, this.selectedIndex - 1); @@ -298,6 +432,46 @@ class SessionList implements Component, Focusable { type SessionsLoader = (onProgress?: SessionListProgress) => Promise; +/** + * Delete a session file, trying the `trash` CLI first, then falling back to unlink + */ +async function deleteSessionFile( + sessionPath: string, +): Promise<{ ok: boolean; method: "trash" | "unlink"; error?: string }> { + // Try `trash` first (if installed) + const trashArgs = sessionPath.startsWith("-") ? ["--", sessionPath] : [sessionPath]; + const trashResult = spawnSync("trash", trashArgs, { encoding: "utf-8" }); + + const getTrashErrorHint = (): string | null => { + const parts: string[] = []; + if (trashResult.error) { + parts.push(trashResult.error.message); + } + const stderr = trashResult.stderr?.trim(); + if (stderr) { + parts.push(stderr.split("\n")[0] ?? stderr); + } + if (parts.length === 0) return null; + return `trash: ${parts.join(" · ").slice(0, 200)}`; + }; + + // If trash reports success, or the file is gone afterwards, treat it as successful + if (trashResult.status === 0 || !existsSync(sessionPath)) { + return { ok: true, method: "trash" }; + } + + // Fallback to permanent deletion + try { + await unlink(sessionPath); + return { ok: true, method: "unlink" }; + } catch (err) { + const unlinkError = err instanceof Error ? err.message : String(err); + const trashErrorHint = getTrashErrorHint(); + const error = trashErrorHint ? `${unlinkError} (${trashErrorHint})` : unlinkError; + return { ok: false, method: "unlink", error }; + } +} + /** * Component that renders a session selector */ @@ -312,6 +486,9 @@ export class SessionSelectorComponent extends Container implements Focusable { private allSessionsLoader: SessionsLoader; private onCancel: () => void; private requestRender: () => void; + private currentLoading = false; + private allLoading = false; + private allLoadSeq = 0; // Focusable implementation - propagate to sessionList for IME cursor positioning private _focused = false; @@ -330,13 +507,14 @@ export class SessionSelectorComponent extends Container implements Focusable { onCancel: () => void, onExit: () => void, requestRender: () => void, + currentSessionFilePath?: string, ) { super(); this.currentSessionsLoader = currentSessionsLoader; this.allSessionsLoader = allSessionsLoader; this.onCancel = onCancel; this.requestRender = requestRender; - this.header = new SessionSelectorHeader(this.scope, this.sortMode); + this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.requestRender); // Add header this.addChild(new Spacer(1)); @@ -346,13 +524,65 @@ export class SessionSelectorComponent extends Container implements Focusable { this.addChild(new Spacer(1)); // Create session list (starts empty, will be populated after load) - this.sessionList = new SessionList([], false, this.sortMode); - this.sessionList.onSelect = onSelect; - this.sessionList.onCancel = onCancel; - this.sessionList.onExit = onExit; + this.sessionList = new SessionList([], false, this.sortMode, currentSessionFilePath); + + // Ensure header status timeouts are cleared when leaving the selector + const clearStatusMessage = () => this.header.setStatusMessage(null); + this.sessionList.onSelect = (sessionPath) => { + clearStatusMessage(); + onSelect(sessionPath); + }; + this.sessionList.onCancel = () => { + clearStatusMessage(); + onCancel(); + }; + this.sessionList.onExit = () => { + clearStatusMessage(); + onExit(); + }; this.sessionList.onToggleScope = () => this.toggleScope(); this.sessionList.onToggleSort = () => this.toggleSortMode(); + // Sync list events to header + this.sessionList.onTogglePath = (showPath) => { + this.header.setShowPath(showPath); + this.requestRender(); + }; + this.sessionList.onDeleteConfirmationChange = (path) => { + this.header.setConfirmingDeletePath(path); + this.requestRender(); + }; + this.sessionList.onError = (msg) => { + this.header.setStatusMessage({ type: "error", message: msg }, 3000); + this.requestRender(); + }; + + // Handle session deletion + this.sessionList.onDeleteSession = async (sessionPath: string) => { + const result = await deleteSessionFile(sessionPath); + + if (result.ok) { + if (this.currentSessions) { + this.currentSessions = this.currentSessions.filter((s) => s.path !== sessionPath); + } + if (this.allSessions) { + this.allSessions = this.allSessions.filter((s) => s.path !== sessionPath); + } + + const sessions = this.scope === "all" ? (this.allSessions ?? []) : (this.currentSessions ?? []); + const showCwd = this.scope === "all"; + this.sessionList.setSessions(sessions, showCwd); + + const msg = result.method === "trash" ? "Session moved to trash" : "Session deleted"; + this.header.setStatusMessage({ type: "info", message: msg }, 2000); + } else { + const errorMessage = result.error ?? "Unknown error"; + this.header.setStatusMessage({ type: "error", message: `Failed to delete: ${errorMessage}` }, 3000); + } + + this.requestRender(); + }; + this.addChild(this.sessionList); // Add bottom border @@ -364,17 +594,37 @@ export class SessionSelectorComponent extends Container implements Focusable { } private loadCurrentSessions(): void { + this.currentLoading = true; + this.header.setScope("current"); this.header.setLoading(true); this.requestRender(); + this.currentSessionsLoader((loaded, total) => { + if (this.scope !== "current") return; this.header.setProgress(loaded, total); this.requestRender(); - }).then((sessions) => { - this.currentSessions = sessions; - this.header.setLoading(false); - this.sessionList.setSessions(sessions, false); - this.requestRender(); - }); + }) + .then((sessions) => { + this.currentSessions = sessions; + this.currentLoading = false; + + if (this.scope !== "current") return; + + this.header.setLoading(false); + this.sessionList.setSessions(sessions, false); + this.requestRender(); + }) + .catch((error: unknown) => { + this.currentLoading = false; + const message = error instanceof Error ? error.message : String(error); + + if (this.scope !== "current") return; + + this.header.setLoading(false); + this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000); + this.sessionList.setSessions([], false); + this.requestRender(); + }); } private toggleSortMode(): void { @@ -386,37 +636,64 @@ export class SessionSelectorComponent extends Container implements Focusable { private toggleScope(): void { if (this.scope === "current") { - // Switching to "all" - load if not already loaded - if (this.allSessions === null) { - this.header.setLoading(true); - this.header.setScope("all"); - this.sessionList.setSessions([], true); // Clear list while loading + this.scope = "all"; + this.header.setScope(this.scope); + + if (this.allSessions !== null) { + this.header.setLoading(false); + this.sessionList.setSessions(this.allSessions, true); this.requestRender(); - // Load asynchronously with progress updates - this.allSessionsLoader((loaded, total) => { - this.header.setProgress(loaded, total); - this.requestRender(); - }).then((sessions) => { + return; + } + + this.header.setLoading(true); + this.sessionList.setSessions([], true); + this.requestRender(); + + if (this.allLoading) return; + + this.allLoading = true; + const seq = ++this.allLoadSeq; + + this.allSessionsLoader((loaded, total) => { + if (seq !== this.allLoadSeq) return; + if (this.scope !== "all") return; + this.header.setProgress(loaded, total); + this.requestRender(); + }) + .then((sessions) => { this.allSessions = sessions; + this.allLoading = false; + + if (seq !== this.allLoadSeq) return; + if (this.scope !== "all") return; + this.header.setLoading(false); - this.scope = "all"; - this.sessionList.setSessions(this.allSessions, true); + this.sessionList.setSessions(sessions, true); this.requestRender(); - // If no sessions in All scope either, cancel - if (this.allSessions.length === 0 && (this.currentSessions?.length ?? 0) === 0) { + + if (sessions.length === 0 && (this.currentSessions?.length ?? 0) === 0) { this.onCancel(); } + }) + .catch((error: unknown) => { + this.allLoading = false; + const message = error instanceof Error ? error.message : String(error); + + if (seq !== this.allLoadSeq) return; + if (this.scope !== "all") return; + + this.header.setLoading(false); + this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000); + this.sessionList.setSessions([], true); + this.requestRender(); }); - } else { - this.scope = "all"; - this.sessionList.setSessions(this.allSessions, true); - this.header.setScope(this.scope); - } } else { - // Switching back to "current" this.scope = "current"; - this.sessionList.setSessions(this.currentSessions ?? [], false); this.header.setScope(this.scope); + this.header.setLoading(this.currentLoading); + this.sessionList.setSessions(this.currentSessions ?? [], false); + this.requestRender(); } } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 8cacc614..5ceb152e 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3035,6 +3035,7 @@ export class InteractiveMode { void this.shutdown(); }, () => this.ui.requestRender(), + this.sessionManager.getSessionFile(), ); return { component: selector, focus: selector.getSessionList() }; }); diff --git a/packages/coding-agent/test/session-selector-path-delete.test.ts b/packages/coding-agent/test/session-selector-path-delete.test.ts new file mode 100644 index 00000000..8623dd3f --- /dev/null +++ b/packages/coding-agent/test/session-selector-path-delete.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import type { SessionInfo } from "../src/core/session-manager.js"; +import { SessionSelectorComponent } from "../src/modes/interactive/components/session-selector.js"; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (err: unknown) => void; +}; + +function createDeferred(): Deferred { + let resolve: (value: T) => void = () => {}; + let reject: (err: unknown) => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function flushPromises(): Promise { + await new Promise((resolve) => { + setImmediate(resolve); + }); +} + +function makeSession(overrides: Partial & { id: string }): SessionInfo { + return { + path: overrides.path ?? `/tmp/${overrides.id}.jsonl`, + id: overrides.id, + cwd: overrides.cwd ?? "", + name: overrides.name, + created: overrides.created ?? new Date(0), + modified: overrides.modified ?? new Date(0), + messageCount: overrides.messageCount ?? 1, + firstMessage: overrides.firstMessage ?? "hello", + allMessagesText: overrides.allMessagesText ?? "hello", + }; +} + +const CTRL_D = "\x04"; +const CTRL_BACKSPACE = "\x1b[127;5u"; + +describe("session selector path/delete interactions", () => { + it("does not treat Ctrl+Backspace as delete when search query is non-empty", async () => { + const sessions = [makeSession({ id: "a" }), makeSession({ id: "b" })]; + + const selector = new SessionSelectorComponent( + async () => sessions, + async () => [], + () => {}, + () => {}, + () => {}, + () => {}, + ); + await flushPromises(); + + const list = selector.getSessionList(); + const confirmationChanges: Array = []; + list.onDeleteConfirmationChange = (path) => confirmationChanges.push(path); + + list.handleInput("a"); + list.handleInput(CTRL_BACKSPACE); + + expect(confirmationChanges).toEqual([]); + }); + + it("enters confirmation mode on Ctrl+D even with a non-empty search query", async () => { + const sessions = [makeSession({ id: "a" }), makeSession({ id: "b" })]; + + const selector = new SessionSelectorComponent( + async () => sessions, + async () => [], + () => {}, + () => {}, + () => {}, + () => {}, + ); + await flushPromises(); + + const list = selector.getSessionList(); + const confirmationChanges: Array = []; + list.onDeleteConfirmationChange = (path) => confirmationChanges.push(path); + + list.handleInput("a"); + list.handleInput(CTRL_D); + + expect(confirmationChanges).toEqual([sessions[0]!.path]); + }); + + it("enters confirmation mode on Ctrl+Backspace when search query is empty", async () => { + const sessions = [makeSession({ id: "a" }), makeSession({ id: "b" })]; + + const selector = new SessionSelectorComponent( + async () => sessions, + async () => [], + () => {}, + () => {}, + () => {}, + () => {}, + ); + await flushPromises(); + + const list = selector.getSessionList(); + const confirmationChanges: Array = []; + list.onDeleteConfirmationChange = (path) => confirmationChanges.push(path); + + let deletedPath: string | null = null; + list.onDeleteSession = async (sessionPath) => { + deletedPath = sessionPath; + }; + + list.handleInput(CTRL_BACKSPACE); + expect(confirmationChanges).toEqual([sessions[0]!.path]); + + list.handleInput("\r"); + expect(confirmationChanges).toEqual([sessions[0]!.path, null]); + expect(deletedPath).toBe(sessions[0]!.path); + }); + + it("does not switch scope back to All when All load resolves after toggling back to Current", async () => { + const currentSessions = [makeSession({ id: "current" })]; + const allDeferred = createDeferred(); + let allLoadCalls = 0; + + const selector = new SessionSelectorComponent( + async () => currentSessions, + async () => { + allLoadCalls++; + return allDeferred.promise; + }, + () => {}, + () => {}, + () => {}, + () => {}, + ); + await flushPromises(); + + const list = selector.getSessionList(); + list.handleInput("\t"); // current -> all (starts async load) + list.handleInput("\t"); // all -> current + + allDeferred.resolve([makeSession({ id: "all" })]); + await flushPromises(); + + expect(allLoadCalls).toBe(1); + const output = selector.render(120).join("\n"); + expect(output).toContain("Resume Session (Current Folder)"); + expect(output).not.toContain("Resume Session (All)"); + }); + + it("does not start redundant All loads when toggling scopes while All is already loading", async () => { + const currentSessions = [makeSession({ id: "current" })]; + const allDeferred = createDeferred(); + let allLoadCalls = 0; + + const selector = new SessionSelectorComponent( + async () => currentSessions, + async () => { + allLoadCalls++; + return allDeferred.promise; + }, + () => {}, + () => {}, + () => {}, + () => {}, + ); + await flushPromises(); + + const list = selector.getSessionList(); + list.handleInput("\t"); // current -> all (starts async load) + list.handleInput("\t"); // all -> current + list.handleInput("\t"); // current -> all again while load pending + + expect(allLoadCalls).toBe(1); + + allDeferred.resolve([makeSession({ id: "all" })]); + await flushPromises(); + }); +});