Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 112 additions & 39 deletions extensions/sessions/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Adapted from pi-agent-extensions (MIT); see THIRD_PARTY_NOTICES.md.
import path from "node:path";
import type {
ExtensionAPI,
ExtensionCommandContext,
Expand Down Expand Up @@ -41,6 +42,7 @@ import {
buildSessionLabel,
buildSessionPreview,
buildSessionSearchEntries,
deleteSessionFile,
filterSessionEntries,
formatRelativeTime,
getSessionPaneLayout,
Expand Down Expand Up @@ -599,6 +601,31 @@ async function showSessionPicker(
let focus: "list" | "preview" = "list";
let showAllWorkspaces = false;
let isLoading = false;
let confirmingDeletePath: string | null = null;

const handleDelete = async (sessionPath: string) => {
const result = await deleteSessionFile(sessionPath);
if (result.ok) {
sorted = sorted.filter((s) => s.path !== sessionPath);
sessionByPath.delete(sessionPath);
statsUniverseVersion++;
entries = buildSessionSearchEntries(sorted);
rebuild();
schedulePreviewLoad();
ctx.ui.notify(
result.method === "trash"
? "Session moved to trash"
: "Session deleted",
"info",
);
} else {
ctx.ui.notify(
`Failed to delete session: ${result.error ?? "unknown error"}`,
"error",
);
}
tui.requestRender();
};

const cancelPreviewLoad = () => {
previewSeq++;
Expand Down Expand Up @@ -818,21 +845,20 @@ async function showSessionPicker(
selectList.onSelectionChange = (item) => setSelectedPath(item.value);
container.addChild(selectList);
}
container.addChild(
new Text(
hintLine(
theme,
[
["↑↓", "navigate"],
["enter", "open"],
["esc", "cancel"],
],
Math.max(1, width - 2),
),
1,
0,
),
);
const singlePaneHints =
confirmingDeletePath !== null
? theme.fg("error", "Delete session? Enter confirm · Esc cancel")
: hintLine(
theme,
[
["↑↓", "navigate"],
["enter", "open"],
["ctrl+d", "delete"],
["esc", "cancel"],
],
Math.max(1, width - 2),
);
container.addChild(new Text(singlePaneHints, 1, 0));
container.addChild(
new DynamicBorder((text: string) => theme.fg("accent", text)),
);
Expand Down Expand Up @@ -925,31 +951,30 @@ async function showSessionPicker(
previewScrollOffset,
renderedPreview.maxScroll,
);
// Hints live on their own line under the frame instead of being packed
// into the bottom border, where they had to compete with the border for
// the same row and lost the keys in a wall of dim text.
const hints = hintLine(
theme,
const hintItems = [
renderedPreview.maxScroll > 0
? ([
"",
`${previewScrollOffset + 1}-${Math.min(previewScrollOffset + contentHeight, renderedPreview.totalLines)}/${renderedPreview.totalLines}`,
] as const)
: undefined,
renderedPreview.maxScroll > 0
? (["pgup/pgdn", "scroll"] as const)
: undefined,
["t", toolsExpanded ? "compact" : "tools"] as const,
["h", thinkingVisible ? "hide thinking" : "thinking"] as const,
[
renderedPreview.maxScroll > 0
? ([
"",
`${previewScrollOffset + 1}-${Math.min(previewScrollOffset + contentHeight, renderedPreview.totalLines)}/${renderedPreview.totalLines}`,
] as const)
: undefined,
renderedPreview.maxScroll > 0
? (["pgup/pgdn", "scroll"] as const)
: undefined,
["t", toolsExpanded ? "compact" : "tools"],
["h", thinkingVisible ? "hide thinking" : "thinking"],
[
"opt+w/ctrl+t",
showAllWorkspaces ? "current workspace" : "all workspaces",
],
["esc", "close"],
],
width,
);
"opt+w/ctrl+t",
showAllWorkspaces ? "current workspace" : "all workspaces",
] as const,
["ctrl+d", "delete"] as const,
["esc", "close"] as const,
];

const hints =
confirmingDeletePath !== null
? theme.fg("error", "Delete session? Enter confirm · Esc cancel")
: hintLine(theme, hintItems, width);

const lines = [buildTopBorder(layout.listWidth, layout.previewWidth)];
for (let i = 0; i < contentHeight; i++) {
Expand Down Expand Up @@ -981,6 +1006,29 @@ async function showSessionPicker(
},
dispose: disposePicker,
handleInput: (data) => {
if (confirmingDeletePath !== null) {
if (
matchesKey(data, Key.enter) ||
kb.matches(data, "tui.select.confirm")
) {
const target = confirmingDeletePath;
confirmingDeletePath = null;
void handleDelete(target);
return;
}
if (
matchesKey(data, Key.escape) ||
kb.matches(data, "tui.select.cancel")
) {
confirmingDeletePath = null;
tui.requestRender();
return;
}
confirmingDeletePath = null;
tui.requestRender();
return;
}

if (data === "\u0014" || data === "\u001bw") {
showAllWorkspaces = !showAllWorkspaces;
void loadWorkspaceSessions(showAllWorkspaces);
Expand Down Expand Up @@ -1074,6 +1122,31 @@ async function showSessionPicker(
}

if (focus === "list") {
if (
matchesKey(data, Key.ctrl("d")) ||
kb.matches(data, "app.session.delete") ||
data === "\u0004"
) {
const selectedSession = sessionByPath.get(selectedPath);
if (selectedSession) {
const activeSessionPath = ctx.sessionManager.getSessionFile();
if (
activeSessionPath &&
path.resolve(selectedSession.path) ===
path.resolve(activeSessionPath)
) {
ctx.ui.notify(
"Cannot delete the currently active session",
"error",
);
return;
}
confirmingDeletePath = selectedSession.path;
tui.requestRender();
return;
}
}

if (isPrintable(data)) {
filter += data;
rebuild();
Expand Down
64 changes: 64 additions & 0 deletions extensions/sessions/sessions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import { unlink } from "node:fs/promises";
import { sanitizeTerminalText } from "../shared/terminal-text.ts";

export interface SessionInfoLike {
Expand Down Expand Up @@ -422,3 +425,64 @@ export function buildPreviewError(
error: message,
};
}

const TRASH_TIMEOUT_MS = 2_000;

function runTrashAsync(
sessionPath: string,
timeoutMs = TRASH_TIMEOUT_MS,
signal?: AbortSignal,
): Promise<boolean> {
const trashArgs = sessionPath.startsWith("-")
? ["--", sessionPath]
: [sessionPath];
return new Promise((resolve) => {
try {
const child = execFile(
"trash",
trashArgs,
{
timeout: timeoutMs,
signal,
encoding: "utf8",
},
(error) => {
if (!error || !existsSync(sessionPath)) {
resolve(true);
} else {
resolve(false);
}
},
);
child.on("error", () => resolve(false));
} catch {
resolve(false);
}
});
}

export async function deleteSessionFile(
sessionPath: string,
options?: { timeoutMs?: number; signal?: AbortSignal },
): Promise<{ ok: boolean; method: "trash" | "unlink"; error?: string }> {
if (!existsSync(sessionPath)) {
return { ok: false, method: "unlink", error: "File not found" };
}

const trashed = await runTrashAsync(
sessionPath,
options?.timeoutMs,
options?.signal,
);
if (trashed || !existsSync(sessionPath)) {
return { ok: true, method: "trash" };
}

try {
await unlink(sessionPath);
return { ok: true, method: "unlink" };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
return { ok: false, method: "unlink", error };
}
}
40 changes: 39 additions & 1 deletion tests/extensions/sessions/sessions.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import {
buildSessionDescription,
buildSessionLabel,
buildSessionPreview,
deleteSessionFile,
filterSessionEntries,
parseLimit,
selectSessionStatsWindow,
type SessionInfoLike,
selectSessionStatsWindow,
} from "../../../extensions/sessions/sessions.ts";

const session: SessionInfoLike = {
Expand Down Expand Up @@ -144,3 +149,36 @@ test("bounded preview reports omitted messages and content bytes", () => {
]);
assert.match(preview.subtitle, /100 messages/);
});

test("deleteSessionFile removes an existing file", async () => {
const file = path.join(tmpdir(), `openpi-del-test-${Date.now()}.jsonl`);
await writeFile(file, "{}");
assert.equal(existsSync(file), true);

const result = await deleteSessionFile(file);
assert.equal(result.ok, true);
assert.equal(existsSync(file), false);
});

test("deleteSessionFile returns error on non-existent file", async () => {
const file = path.join(tmpdir(), `nonexistent-${Date.now()}.jsonl`);
const result = await deleteSessionFile(file);
assert.equal(result.ok, false);
});

test("deleteSessionFile is asynchronous and bounds execution timeout", async () => {
const file = path.join(tmpdir(), `openpi-del-async-${Date.now()}.jsonl`);
await writeFile(file, "{}");

let timerFired = false;
setTimeout(() => {
timerFired = true;
}, 1);

const result = await deleteSessionFile(file, { timeoutMs: 100 });
assert.equal(result.ok, true);
assert.equal(existsSync(file), false);

await new Promise((resolve) => setTimeout(resolve, 5));
assert.equal(timerFired, true);
});
Loading