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

@ -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<typeof setTimeout> | 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:<pattern> 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:<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 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<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
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<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
*/
@ -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();
}
}

View file

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