feat(coding-agent): add session path toggle and deletion to /resume

This commit is contained in:
warren 2026-01-17 20:29:16 -05:00 committed by Mario Zechner
parent d43930c818
commit 26fe048314
6 changed files with 520 additions and 51 deletions

View file

@ -5,6 +5,7 @@
### Added ### 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)) - 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 ### Changed

View file

@ -541,6 +541,10 @@ pi --session /path/to/file.jsonl # Use specific session file
pi --session a8ec1c2a # Resume by session ID (partial UUID) 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/<project>/` (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. **Resuming by session ID:** The `--session` flag accepts a session UUID (or prefix). Session IDs are visible in filenames under `~/.pi/agent/sessions/<project>/` (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 ### Context Compaction

View file

@ -10,6 +10,12 @@ Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with
Where `<path>` is the working directory with `/` replaced by `-`. Where `<path>` 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 ## Session Version
Sessions have a version field in the header: Sessions have a version field in the header:

View file

@ -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 * as os from "node:os";
import { import {
type Component, type Component,
@ -45,12 +48,18 @@ function formatSessionDate(date: Date): string {
class SessionSelectorHeader implements Component { class SessionSelectorHeader implements Component {
private scope: SessionScope; private scope: SessionScope;
private sortMode: SortMode; private sortMode: SortMode;
private requestRender: () => void;
private loading = false; private loading = false;
private loadProgress: { loaded: number; total: number } | null = null; 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<typeof setTimeout> | null = null;
constructor(scope: SessionScope, sortMode: SortMode) { constructor(scope: SessionScope, sortMode: SortMode, requestRender: () => void) {
this.scope = scope; this.scope = scope;
this.sortMode = sortMode; this.sortMode = sortMode;
this.requestRender = requestRender;
} }
setScope(scope: SessionScope): void { setScope(scope: SessionScope): void {
@ -63,15 +72,40 @@ class SessionSelectorHeader implements Component {
setLoading(loading: boolean): void { setLoading(loading: boolean): void {
this.loading = loading; this.loading = loading;
if (!loading) { // Progress is scoped to the current load; clear whenever the loading state is set
this.loadProgress = null; this.loadProgress = null;
}
} }
setProgress(loaded: number, total: number): void { setProgress(loaded: number, total: number): void {
this.loadProgress = { loaded, total }; 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 {} invalidate(): void {}
render(width: number): string[] { render(width: number): string[] {
@ -85,21 +119,37 @@ class SessionSelectorHeader implements Component {
if (this.loading) { if (this.loading) {
const progressText = this.loadProgress ? `${this.loadProgress.loaded}/${this.loadProgress.total}` : "..."; const progressText = this.loadProgress ? `${this.loadProgress.loaded}/${this.loadProgress.total}` : "...";
scopeText = `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", `Loading ${progressText}`)}`; 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 { } else {
scopeText = scopeText = `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", "◉ All")}`;
this.scope === "current"
? `${theme.fg("accent", "◉ Current Folder")}${theme.fg("muted", " | ○ All")}`
: `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", "◉ All")}`;
} }
const rightText = truncateToWidth(`${scopeText} ${sortText}`, width, ""); const rightText = truncateToWidth(`${scopeText} ${sortText}`, width, "");
const availableLeft = Math.max(0, width - visibleWidth(rightText) - 1); const availableLeft = Math.max(0, width - visibleWidth(rightText) - 1);
const left = truncateToWidth(leftText, availableLeft, ""); const left = truncateToWidth(leftText, availableLeft, "");
const spacing = Math.max(0, width - visibleWidth(left) - visibleWidth(rightText)); const spacing = Math.max(0, width - visibleWidth(left) - visibleWidth(rightText));
const hintText = 'Tab: scope · Ctrl+R: sort · re:<pattern> for regex · "phrase" for exact phrase';
const truncatedHint = truncateToWidth(hintText, width, "…"); // Build hint lines - changes based on state (all branches truncate to width)
const hint = theme.fg("muted", truncatedHint); let hintLine1: string;
return [`${left}${" ".repeat(spacing)}${rightText}`, hint]; 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:<pattern> 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 searchInput: Input;
private showCwd = false; private showCwd = false;
private sortMode: SortMode = "relevance"; private sortMode: SortMode = "relevance";
private showPath = false;
private confirmingDeletePath: string | null = null;
private currentSessionFilePath?: string;
public onSelect?: (sessionPath: string) => void; public onSelect?: (sessionPath: string) => void;
public onCancel?: () => void; public onCancel?: () => void;
public onExit: () => void = () => {}; public onExit: () => void = () => {};
public onToggleScope?: () => void; public onToggleScope?: () => void;
public onToggleSort?: () => 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<void>;
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 // Focusable implementation - propagate to searchInput for IME cursor positioning
private _focused = false; private _focused = false;
@ -130,12 +187,13 @@ class SessionList implements Component, Focusable {
this.searchInput.focused = value; this.searchInput.focused = value;
} }
constructor(sessions: SessionInfo[], showCwd: boolean, sortMode: SortMode) { constructor(sessions: SessionInfo[], showCwd: boolean, sortMode: SortMode, currentSessionFilePath?: string) {
this.allSessions = sessions; this.allSessions = sessions;
this.filteredSessions = sessions; this.filteredSessions = sessions;
this.searchInput = new Input(); this.searchInput = new Input();
this.showCwd = showCwd; this.showCwd = showCwd;
this.sortMode = sortMode; this.sortMode = sortMode;
this.currentSessionFilePath = currentSessionFilePath;
// Handle Enter in search input - select current item // Handle Enter in search input - select current item
this.searchInput.onSubmit = () => { 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)); 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 {} invalidate(): void {}
render(width: number): string[] { render(width: number): string[] {
@ -196,10 +272,11 @@ class SessionList implements Component, Focusable {
); );
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredSessions.length); 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++) { for (let i = startIndex; i < endIndex; i++) {
const session = this.filteredSessions[i]; const session = this.filteredSessions[i];
const isSelected = i === this.selectedIndex; const isSelected = i === this.selectedIndex;
const isConfirmingDelete = session.path === this.confirmingDeletePath;
// Use session name if set, otherwise first message // Use session name if set, otherwise first message
const hasName = !!session.name; const hasName = !!session.name;
@ -211,10 +288,13 @@ class SessionList implements Component, Focusable {
const cursor = isSelected ? theme.fg("accent", " ") : " "; const cursor = isSelected ? theme.fg("accent", " ") : " ";
const maxMsgWidth = width - 2; // Account for cursor (2 visible chars) const maxMsgWidth = width - 2; // Account for cursor (2 visible chars)
const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth, "..."); const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth, "...");
let styledMsg = truncatedMsg; let messageColor: "error" | "warning" | null = null;
if (hasName) { if (isConfirmingDelete) {
styledMsg = theme.fg("warning", truncatedMsg); messageColor = "error";
} else if (hasName) {
messageColor = "warning";
} }
let styledMsg = messageColor ? theme.fg(messageColor, truncatedMsg) : truncatedMsg;
if (isSelected) { if (isSelected) {
styledMsg = theme.bold(styledMsg); styledMsg = theme.bold(styledMsg);
} }
@ -228,10 +308,20 @@ class SessionList implements Component, Focusable {
metadataParts.push(shortenPath(session.cwd)); metadataParts.push(shortenPath(session.cwd));
} }
const metadata = ` ${metadataParts.join(" · ")}`; 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(messageLine);
lines.push(metadataLine); 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 lines.push(""); // Blank line between sessions
} }
@ -247,6 +337,24 @@ class SessionList implements Component, Focusable {
handleInput(keyData: string): void { handleInput(keyData: string): void {
const kb = getEditorKeybindings(); 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 (kb.matches(keyData, "tab")) {
if (this.onToggleScope) { if (this.onToggleScope) {
this.onToggleScope(); this.onToggleScope();
@ -259,6 +367,32 @@ class SessionList implements Component, Focusable {
return; 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 // Up arrow
if (kb.matches(keyData, "selectUp")) { if (kb.matches(keyData, "selectUp")) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.selectedIndex = Math.max(0, this.selectedIndex - 1);
@ -298,6 +432,46 @@ class SessionList implements Component, Focusable {
type SessionsLoader = (onProgress?: SessionListProgress) => Promise<SessionInfo[]>; type SessionsLoader = (onProgress?: SessionListProgress) => Promise<SessionInfo[]>;
/**
* 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 * Component that renders a session selector
*/ */
@ -312,6 +486,9 @@ export class SessionSelectorComponent extends Container implements Focusable {
private allSessionsLoader: SessionsLoader; private allSessionsLoader: SessionsLoader;
private onCancel: () => void; private onCancel: () => void;
private requestRender: () => void; private requestRender: () => void;
private currentLoading = false;
private allLoading = false;
private allLoadSeq = 0;
// Focusable implementation - propagate to sessionList for IME cursor positioning // Focusable implementation - propagate to sessionList for IME cursor positioning
private _focused = false; private _focused = false;
@ -330,13 +507,14 @@ export class SessionSelectorComponent extends Container implements Focusable {
onCancel: () => void, onCancel: () => void,
onExit: () => void, onExit: () => void,
requestRender: () => void, requestRender: () => void,
currentSessionFilePath?: string,
) { ) {
super(); super();
this.currentSessionsLoader = currentSessionsLoader; this.currentSessionsLoader = currentSessionsLoader;
this.allSessionsLoader = allSessionsLoader; this.allSessionsLoader = allSessionsLoader;
this.onCancel = onCancel; this.onCancel = onCancel;
this.requestRender = requestRender; this.requestRender = requestRender;
this.header = new SessionSelectorHeader(this.scope, this.sortMode); this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.requestRender);
// Add header // Add header
this.addChild(new Spacer(1)); this.addChild(new Spacer(1));
@ -346,13 +524,65 @@ export class SessionSelectorComponent extends Container implements Focusable {
this.addChild(new Spacer(1)); this.addChild(new Spacer(1));
// Create session list (starts empty, will be populated after load) // Create session list (starts empty, will be populated after load)
this.sessionList = new SessionList([], false, this.sortMode); this.sessionList = new SessionList([], false, this.sortMode, currentSessionFilePath);
this.sessionList.onSelect = onSelect;
this.sessionList.onCancel = onCancel; // Ensure header status timeouts are cleared when leaving the selector
this.sessionList.onExit = onExit; 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.onToggleScope = () => this.toggleScope();
this.sessionList.onToggleSort = () => this.toggleSortMode(); 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); this.addChild(this.sessionList);
// Add bottom border // Add bottom border
@ -364,17 +594,37 @@ export class SessionSelectorComponent extends Container implements Focusable {
} }
private loadCurrentSessions(): void { private loadCurrentSessions(): void {
this.currentLoading = true;
this.header.setScope("current");
this.header.setLoading(true); this.header.setLoading(true);
this.requestRender(); this.requestRender();
this.currentSessionsLoader((loaded, total) => { this.currentSessionsLoader((loaded, total) => {
if (this.scope !== "current") return;
this.header.setProgress(loaded, total); this.header.setProgress(loaded, total);
this.requestRender(); this.requestRender();
}).then((sessions) => { })
this.currentSessions = sessions; .then((sessions) => {
this.header.setLoading(false); this.currentSessions = sessions;
this.sessionList.setSessions(sessions, false); this.currentLoading = false;
this.requestRender();
}); 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 { private toggleSortMode(): void {
@ -386,37 +636,64 @@ export class SessionSelectorComponent extends Container implements Focusable {
private toggleScope(): void { private toggleScope(): void {
if (this.scope === "current") { if (this.scope === "current") {
// Switching to "all" - load if not already loaded this.scope = "all";
if (this.allSessions === null) { this.header.setScope(this.scope);
this.header.setLoading(true);
this.header.setScope("all"); if (this.allSessions !== null) {
this.sessionList.setSessions([], true); // Clear list while loading this.header.setLoading(false);
this.sessionList.setSessions(this.allSessions, true);
this.requestRender(); this.requestRender();
// Load asynchronously with progress updates return;
this.allSessionsLoader((loaded, total) => { }
this.header.setProgress(loaded, total);
this.requestRender(); this.header.setLoading(true);
}).then((sessions) => { 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.allSessions = sessions;
this.allLoading = false;
if (seq !== this.allLoadSeq) return;
if (this.scope !== "all") return;
this.header.setLoading(false); this.header.setLoading(false);
this.scope = "all"; this.sessionList.setSessions(sessions, true);
this.sessionList.setSessions(this.allSessions, true);
this.requestRender(); 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(); 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 { } else {
// Switching back to "current"
this.scope = "current"; this.scope = "current";
this.sessionList.setSessions(this.currentSessions ?? [], false);
this.header.setScope(this.scope); this.header.setScope(this.scope);
this.header.setLoading(this.currentLoading);
this.sessionList.setSessions(this.currentSessions ?? [], false);
this.requestRender();
} }
} }

View file

@ -3035,6 +3035,7 @@ export class InteractiveMode {
void this.shutdown(); void this.shutdown();
}, },
() => this.ui.requestRender(), () => this.ui.requestRender(),
this.sessionManager.getSessionFile(),
); );
return { component: selector, focus: selector.getSessionList() }; return { component: selector, focus: selector.getSessionList() };
}); });

View file

@ -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<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (err: unknown) => void;
};
function createDeferred<T>(): Deferred<T> {
let resolve: (value: T) => void = () => {};
let reject: (err: unknown) => void = () => {};
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function flushPromises(): Promise<void> {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
function makeSession(overrides: Partial<SessionInfo> & { 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<string | null> = [];
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<string | null> = [];
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<string | null> = [];
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<SessionInfo[]>();
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<SessionInfo[]>();
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();
});
});