pi-session-cleanup 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/config/config.example.json +4 -0
- package/index.ts +3 -0
- package/package.json +66 -0
- package/src/config-store.ts +81 -0
- package/src/constants.ts +3 -0
- package/src/index.ts +27 -0
- package/src/session-agent.ts +78 -0
- package/src/session-cleanup-command.ts +268 -0
- package/src/session-delete.ts +50 -0
- package/src/session-format.ts +98 -0
- package/src/session-nix-command.ts +84 -0
- package/src/session-selection.ts +167 -0
- package/src/session-sort.ts +137 -0
- package/src/session-source.ts +32 -0
- package/src/tui/session-cleanup-picker.ts +592 -0
- package/src/types-shims.d.ts +192 -0
- package/src/types.ts +39 -0
- package/src/ui/icons.ts +476 -0
- package/src/ui/legend.ts +15 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionCommandContext,
|
|
3
|
+
SessionInfo,
|
|
4
|
+
} from "@mariozechner/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
SESSION_CLEANUP_COMMAND,
|
|
8
|
+
} from "./constants.js";
|
|
9
|
+
import { getSessionTitle } from "./session-format.js";
|
|
10
|
+
import { deleteSessionFile } from "./session-delete.js";
|
|
11
|
+
import { loadSessions } from "./session-source.js";
|
|
12
|
+
import { selectSessionsForCleanup } from "./session-selection.js";
|
|
13
|
+
import type {
|
|
14
|
+
BatchDeleteResult,
|
|
15
|
+
SessionCleanupSession,
|
|
16
|
+
SessionScope,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
|
|
19
|
+
const ARG_COMPLETIONS = [
|
|
20
|
+
{
|
|
21
|
+
value: "current",
|
|
22
|
+
label: "current",
|
|
23
|
+
description: "List sessions from the current working directory only",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
value: "all",
|
|
27
|
+
label: "all",
|
|
28
|
+
description: "List sessions across every working directory",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
value: "help",
|
|
32
|
+
label: "help",
|
|
33
|
+
description: "Show usage",
|
|
34
|
+
},
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
interface ParsedArgs {
|
|
38
|
+
help: boolean;
|
|
39
|
+
scope: SessionScope;
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function usage(): string {
|
|
44
|
+
return `Usage: /${SESSION_CLEANUP_COMMAND} [current|all]`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseArgs(args: string): ParsedArgs {
|
|
48
|
+
const normalized = args.trim().toLowerCase();
|
|
49
|
+
if (!normalized) {
|
|
50
|
+
return { help: false, scope: "current" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (normalized === "help") {
|
|
54
|
+
return { help: true, scope: "current" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (normalized === "current" || normalized === "all") {
|
|
58
|
+
return { help: false, scope: normalized };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
help: false,
|
|
63
|
+
scope: "current",
|
|
64
|
+
error: `Unknown argument: ${args.trim()}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildSelectionSummary(sessions: readonly SessionInfo[]): string {
|
|
69
|
+
const preview = sessions
|
|
70
|
+
.slice(0, 6)
|
|
71
|
+
.map((session) => `- ${getSessionTitle(session)} (${session.id.slice(0, 8)})`)
|
|
72
|
+
.join("\n");
|
|
73
|
+
const hiddenCount = Math.max(0, sessions.length - 6);
|
|
74
|
+
|
|
75
|
+
if (hiddenCount > 0) {
|
|
76
|
+
return `${preview}\n- …and ${hiddenCount} more`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return preview;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildConfirmationMessage(sessions: readonly SessionInfo[]): string {
|
|
83
|
+
const noun = sessions.length === 1 ? "session" : "sessions";
|
|
84
|
+
return `Delete ${sessions.length} selected ${noun}?\n\n${buildSelectionSummary(sessions)}\n\nThis action removes session files. Pi will try trash first, then permanent delete if trash is unavailable.`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function summarizeFailures(
|
|
88
|
+
failures: readonly { session: SessionInfo; error: string }[],
|
|
89
|
+
): string {
|
|
90
|
+
return failures
|
|
91
|
+
.slice(0, 4)
|
|
92
|
+
.map(
|
|
93
|
+
(failure) =>
|
|
94
|
+
`- ${failure.session.id.slice(0, 8)} (${getSessionTitle(failure.session)}): ${failure.error}`,
|
|
95
|
+
)
|
|
96
|
+
.join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function deleteSelectedSessions(
|
|
100
|
+
selectedSessions: readonly SessionInfo[],
|
|
101
|
+
currentSessionFile: string | undefined,
|
|
102
|
+
): Promise<BatchDeleteResult> {
|
|
103
|
+
const result: BatchDeleteResult = {
|
|
104
|
+
deleted: [],
|
|
105
|
+
failed: [],
|
|
106
|
+
methods: {
|
|
107
|
+
trash: 0,
|
|
108
|
+
unlink: 0,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
for (const session of selectedSessions) {
|
|
113
|
+
if (currentSessionFile && session.path === currentSessionFile) {
|
|
114
|
+
result.failed.push({
|
|
115
|
+
session,
|
|
116
|
+
error: "Refused to delete the currently active session.",
|
|
117
|
+
});
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const deleteResult = await deleteSessionFile(session.path);
|
|
123
|
+
if (deleteResult.ok) {
|
|
124
|
+
result.deleted.push(session);
|
|
125
|
+
result.methods[deleteResult.method] += 1;
|
|
126
|
+
} else {
|
|
127
|
+
result.failed.push({
|
|
128
|
+
session,
|
|
129
|
+
error: deleteResult.error,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
134
|
+
result.failed.push({
|
|
135
|
+
session,
|
|
136
|
+
error: message,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function notifyDeleteOutcome(ctx: ExtensionCommandContext, result: BatchDeleteResult): void {
|
|
145
|
+
const deletedCount = result.deleted.length;
|
|
146
|
+
const failedCount = result.failed.length;
|
|
147
|
+
|
|
148
|
+
if (deletedCount === 0 && failedCount > 0) {
|
|
149
|
+
ctx.ui.notify(
|
|
150
|
+
`No sessions were deleted.\n${summarizeFailures(result.failed)}`,
|
|
151
|
+
"error",
|
|
152
|
+
);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (failedCount === 0) {
|
|
157
|
+
ctx.ui.notify(
|
|
158
|
+
`Deleted ${deletedCount} session(s) (trash: ${result.methods.trash}, permanent: ${result.methods.unlink}).`,
|
|
159
|
+
"info",
|
|
160
|
+
);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
ctx.ui.notify(
|
|
165
|
+
`Deleted ${deletedCount} session(s), but ${failedCount} failed.\n${summarizeFailures(result.failed)}`,
|
|
166
|
+
"warning",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function getSessionCleanupArgumentCompletions(
|
|
171
|
+
argumentPrefix: string,
|
|
172
|
+
): Array<{ value: string; label: string; description?: string }> | null {
|
|
173
|
+
const normalizedPrefix = argumentPrefix.trim().toLowerCase();
|
|
174
|
+
if (!normalizedPrefix) {
|
|
175
|
+
return [...ARG_COMPLETIONS];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const matched = ARG_COMPLETIONS.filter((item) =>
|
|
179
|
+
item.value.startsWith(normalizedPrefix),
|
|
180
|
+
);
|
|
181
|
+
if (matched.length === 0) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return matched.map((item) => ({ ...item }));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function handleSessionCleanupCommand(
|
|
189
|
+
args: string,
|
|
190
|
+
ctx: ExtensionCommandContext,
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
const parsed = parseArgs(args);
|
|
193
|
+
if (parsed.help) {
|
|
194
|
+
ctx.ui.notify(usage(), "info");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (parsed.error) {
|
|
199
|
+
ctx.ui.notify(`${parsed.error}\n${usage()}`, "warning");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!ctx.hasUI) {
|
|
204
|
+
ctx.ui.notify(`/${SESSION_CLEANUP_COMMAND} requires interactive TUI mode.`, "warning");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
209
|
+
|
|
210
|
+
while (true) {
|
|
211
|
+
let sessions: SessionCleanupSession[];
|
|
212
|
+
try {
|
|
213
|
+
sessions = await loadSessions(ctx, parsed.scope);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
216
|
+
ctx.ui.notify(`Failed to load sessions: ${message}`, "error");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const candidates = sessions.filter(
|
|
221
|
+
(session) => session.path !== currentSessionFile,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
if (candidates.length === 0) {
|
|
225
|
+
ctx.ui.notify(
|
|
226
|
+
"No deletable sessions found for this scope (current active session is excluded).",
|
|
227
|
+
"info",
|
|
228
|
+
);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const selection = await selectSessionsForCleanup(ctx, candidates);
|
|
233
|
+
if (selection.cancelled) {
|
|
234
|
+
ctx.ui.notify("Session cleanup cancelled.", "info");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (selection.refreshRequested) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const selectedSessions = candidates.filter((session) =>
|
|
243
|
+
selection.selectedPaths.has(session.path),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
if (selectedSessions.length === 0) {
|
|
247
|
+
ctx.ui.notify("No sessions selected. Select one or more sessions first.", "warning");
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const confirmed = await ctx.ui.confirm(
|
|
252
|
+
"Delete selected sessions",
|
|
253
|
+
buildConfirmationMessage(selectedSessions),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
if (!confirmed) {
|
|
257
|
+
ctx.ui.notify("Delete cancelled.", "info");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const deleteResult = await deleteSelectedSessions(
|
|
262
|
+
selectedSessions,
|
|
263
|
+
currentSessionFile,
|
|
264
|
+
);
|
|
265
|
+
notifyDeleteOutcome(ctx, deleteResult);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { unlink } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
import type { DeleteSessionResult } from "./types.js";
|
|
6
|
+
|
|
7
|
+
function buildTrashErrorHint(result: ReturnType<typeof spawnSync>): string | null {
|
|
8
|
+
const details: string[] = [];
|
|
9
|
+
|
|
10
|
+
if (result.error) {
|
|
11
|
+
details.push(result.error.message);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const stderr = result.stderr?.toString().trim();
|
|
15
|
+
if (stderr) {
|
|
16
|
+
details.push(stderr.split("\n")[0] ?? stderr);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (details.length === 0) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return `trash: ${details.join(" · ").slice(0, 200)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Mirrors Pi's built-in behavior: try moving to trash first, then fallback to unlink.
|
|
28
|
+
*/
|
|
29
|
+
export async function deleteSessionFile(sessionPath: string): Promise<DeleteSessionResult> {
|
|
30
|
+
const trashArgs = sessionPath.startsWith("-") ? ["--", sessionPath] : [sessionPath];
|
|
31
|
+
const trashResult = spawnSync("trash", trashArgs, { encoding: "utf-8" });
|
|
32
|
+
|
|
33
|
+
if (trashResult.status === 0 || !existsSync(sessionPath)) {
|
|
34
|
+
return { ok: true, method: "trash" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await unlink(sessionPath);
|
|
39
|
+
return { ok: true, method: "unlink" };
|
|
40
|
+
} catch (error) {
|
|
41
|
+
const unlinkError = error instanceof Error ? error.message : String(error);
|
|
42
|
+
const trashHint = buildTrashErrorHint(trashResult);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
method: "unlink",
|
|
47
|
+
error: trashHint ? `${unlinkError} (${trashHint})` : unlinkError,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
|
|
3
|
+
import type { SessionInfo } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
const LABEL_MAX_TEXT = 56;
|
|
6
|
+
|
|
7
|
+
type SessionDisplayInfo = SessionInfo & {
|
|
8
|
+
responsibleAgentName?: string | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function compactWhitespace(value: string): string {
|
|
12
|
+
return value.replace(/\s+/g, " ").trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function clipText(value: string, max = LABEL_MAX_TEXT): string {
|
|
16
|
+
if (value.length <= max) {
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return `${value.slice(0, Math.max(1, max - 1))}…`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function shortenPath(path: string): string {
|
|
24
|
+
const home = homedir();
|
|
25
|
+
if (path.startsWith(home)) {
|
|
26
|
+
return `~${path.slice(home.length)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return path;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatSessionAge(date: Date, now = Date.now()): string {
|
|
33
|
+
const ageMs = Math.max(0, now - date.getTime());
|
|
34
|
+
const minutes = Math.floor(ageMs / 60000);
|
|
35
|
+
|
|
36
|
+
if (minutes < 1) {
|
|
37
|
+
return "now";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (minutes < 60) {
|
|
41
|
+
return `${minutes}m`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const hours = Math.floor(minutes / 60);
|
|
45
|
+
if (hours < 24) {
|
|
46
|
+
return `${hours}h`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const days = Math.floor(hours / 24);
|
|
50
|
+
if (days < 7) {
|
|
51
|
+
return `${days}d`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (days < 30) {
|
|
55
|
+
return `${Math.floor(days / 7)}w`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (days < 365) {
|
|
59
|
+
return `${Math.floor(days / 30)}mo`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return `${Math.floor(days / 365)}y`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getSessionTitle(session: SessionInfo): string {
|
|
66
|
+
const preferred = compactWhitespace(session.name ?? "");
|
|
67
|
+
if (preferred) {
|
|
68
|
+
return clipText(preferred);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const preview = compactWhitespace(session.firstMessage ?? "");
|
|
72
|
+
if (preview) {
|
|
73
|
+
return clipText(preview);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return "(no preview)";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getResponsibleAgentDisplayName(session: SessionDisplayInfo): string {
|
|
80
|
+
const normalizedAgentName = compactWhitespace(session.responsibleAgentName ?? "");
|
|
81
|
+
return normalizedAgentName || "unknown";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function buildSessionSelectionLabel(
|
|
85
|
+
session: SessionDisplayInfo,
|
|
86
|
+
index: number,
|
|
87
|
+
selected: boolean,
|
|
88
|
+
): string {
|
|
89
|
+
const marker = selected ? "[x]" : "[ ]";
|
|
90
|
+
const title = getSessionTitle(session);
|
|
91
|
+
const agent = `@${getResponsibleAgentDisplayName(session)}`;
|
|
92
|
+
const age = formatSessionAge(session.modified);
|
|
93
|
+
const shortId = session.id.slice(0, 8);
|
|
94
|
+
const cwd = shortenPath(session.cwd || "(unknown cwd)");
|
|
95
|
+
const position = String(index + 1).padStart(3, "0");
|
|
96
|
+
|
|
97
|
+
return `${position} ${marker} ${title} · ${agent} · ${age} · ${shortId} · ${cwd}`;
|
|
98
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import { SESSION_NIX_COMMAND } from "./constants.js";
|
|
6
|
+
import { deleteSessionFile } from "./session-delete.js";
|
|
7
|
+
|
|
8
|
+
function usage(): string {
|
|
9
|
+
return `Usage: /${SESSION_NIX_COMMAND}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getSessionLabel(sessionPath: string): string {
|
|
13
|
+
const fileName = basename(sessionPath).trim();
|
|
14
|
+
return fileName.length > 0 ? fileName : sessionPath;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function buildConfirmationMessage(previousSessionFile: string | undefined): string {
|
|
18
|
+
if (!previousSessionFile) {
|
|
19
|
+
return "This will start a new session. The current session is not persisted yet, so there is no session file to delete.";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return [
|
|
23
|
+
"This will start a new session and permanently remove the current session from your session history.",
|
|
24
|
+
"",
|
|
25
|
+
`Current session: ${getSessionLabel(previousSessionFile)}`,
|
|
26
|
+
"",
|
|
27
|
+
"Pi will try moving it to trash first, then permanently delete it if trash is unavailable.",
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function handleSessionNixCommand(
|
|
32
|
+
args: string,
|
|
33
|
+
ctx: ExtensionCommandContext,
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
const normalizedArgs = args.trim();
|
|
36
|
+
if (normalizedArgs.toLowerCase() === "help") {
|
|
37
|
+
ctx.ui.notify(usage(), "info");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (normalizedArgs.length > 0) {
|
|
42
|
+
ctx.ui.notify(`/${SESSION_NIX_COMMAND} does not accept arguments.\n${usage()}`, "warning");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!ctx.hasUI) {
|
|
47
|
+
ctx.ui.notify(`/${SESSION_NIX_COMMAND} requires interactive TUI mode.`, "warning");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const previousSessionFile = ctx.sessionManager.getSessionFile();
|
|
52
|
+
const confirmed = await ctx.ui.confirm(
|
|
53
|
+
"Start fresh and delete current session",
|
|
54
|
+
buildConfirmationMessage(previousSessionFile),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (!confirmed) {
|
|
58
|
+
ctx.ui.notify(`/${SESSION_NIX_COMMAND} cancelled.`, "info");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const newSessionResult = await ctx.newSession();
|
|
64
|
+
if (newSessionResult.cancelled) {
|
|
65
|
+
ctx.ui.notify("New session cancelled.", "info");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
70
|
+
ctx.ui.notify(`Failed to start a new session: ${message}`, "error");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// After successful ctx.newSession(), ctx is stale. Do NOT call ctx.ui.notify() below.
|
|
75
|
+
if (!previousSessionFile) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await deleteSessionFile(previousSessionFile);
|
|
81
|
+
} catch {
|
|
82
|
+
// Deletion failure is non-critical; the session was already replaced.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { buildSessionSelectionLabel } from "./session-format.js";
|
|
4
|
+
import { showSessionCleanupPicker } from "./tui/session-cleanup-picker.js";
|
|
5
|
+
import type { SessionCleanupSession, SessionSelectionResult } from "./types.js";
|
|
6
|
+
|
|
7
|
+
interface ConfirmAction {
|
|
8
|
+
kind: "confirm";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ToggleAllAction {
|
|
12
|
+
kind: "toggle-all";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface RefreshAction {
|
|
16
|
+
kind: "refresh";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CancelAction {
|
|
20
|
+
kind: "cancel";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ToggleSessionAction {
|
|
24
|
+
kind: "toggle-session";
|
|
25
|
+
sessionPath: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type SelectionAction =
|
|
29
|
+
| ConfirmAction
|
|
30
|
+
| ToggleAllAction
|
|
31
|
+
| RefreshAction
|
|
32
|
+
| CancelAction
|
|
33
|
+
| ToggleSessionAction;
|
|
34
|
+
|
|
35
|
+
function buildMenu(
|
|
36
|
+
sessions: readonly SessionCleanupSession[],
|
|
37
|
+
selectedPaths: ReadonlySet<string>,
|
|
38
|
+
): {
|
|
39
|
+
labels: string[];
|
|
40
|
+
actionsByLabel: Map<string, SelectionAction>;
|
|
41
|
+
} {
|
|
42
|
+
const labels: string[] = [];
|
|
43
|
+
const actionsByLabel = new Map<string, SelectionAction>();
|
|
44
|
+
|
|
45
|
+
const confirmLabel = `✅ Delete selected (${selectedPaths.size})`;
|
|
46
|
+
labels.push(confirmLabel);
|
|
47
|
+
actionsByLabel.set(confirmLabel, { kind: "confirm" });
|
|
48
|
+
|
|
49
|
+
const toggleAllLabel =
|
|
50
|
+
selectedPaths.size === sessions.length
|
|
51
|
+
? "☐ Clear all selections"
|
|
52
|
+
: "☑ Select all sessions";
|
|
53
|
+
labels.push(toggleAllLabel);
|
|
54
|
+
actionsByLabel.set(toggleAllLabel, { kind: "toggle-all" });
|
|
55
|
+
|
|
56
|
+
const refreshLabel = "↻ Refresh session list";
|
|
57
|
+
labels.push(refreshLabel);
|
|
58
|
+
actionsByLabel.set(refreshLabel, { kind: "refresh" });
|
|
59
|
+
|
|
60
|
+
const cancelLabel = "✖ Cancel";
|
|
61
|
+
labels.push(cancelLabel);
|
|
62
|
+
actionsByLabel.set(cancelLabel, { kind: "cancel" });
|
|
63
|
+
|
|
64
|
+
sessions.forEach((session, index) => {
|
|
65
|
+
const label = buildSessionSelectionLabel(
|
|
66
|
+
session,
|
|
67
|
+
index,
|
|
68
|
+
selectedPaths.has(session.path),
|
|
69
|
+
);
|
|
70
|
+
labels.push(label);
|
|
71
|
+
actionsByLabel.set(label, {
|
|
72
|
+
kind: "toggle-session",
|
|
73
|
+
sessionPath: session.path,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return { labels, actionsByLabel };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function selectSessionsWithLegacyMenu(
|
|
81
|
+
ctx: ExtensionCommandContext,
|
|
82
|
+
sessions: readonly SessionCleanupSession[],
|
|
83
|
+
): Promise<SessionSelectionResult> {
|
|
84
|
+
const selectedPaths = new Set<string>();
|
|
85
|
+
|
|
86
|
+
while (true) {
|
|
87
|
+
const menu = buildMenu(sessions, selectedPaths);
|
|
88
|
+
const selectedLabel = await ctx.ui.select(
|
|
89
|
+
"Select sessions to delete (toggle multiple, then confirm)",
|
|
90
|
+
menu.labels,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (!selectedLabel) {
|
|
94
|
+
return {
|
|
95
|
+
cancelled: true,
|
|
96
|
+
refreshRequested: false,
|
|
97
|
+
selectedPaths,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const action = menu.actionsByLabel.get(selectedLabel);
|
|
102
|
+
if (!action) {
|
|
103
|
+
ctx.ui.notify("Unknown selection action. Please try again.", "warning");
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
switch (action.kind) {
|
|
108
|
+
case "confirm": {
|
|
109
|
+
return {
|
|
110
|
+
cancelled: false,
|
|
111
|
+
refreshRequested: false,
|
|
112
|
+
selectedPaths,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
case "toggle-all": {
|
|
116
|
+
if (selectedPaths.size === sessions.length) {
|
|
117
|
+
selectedPaths.clear();
|
|
118
|
+
} else {
|
|
119
|
+
sessions.forEach((session) => selectedPaths.add(session.path));
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case "refresh": {
|
|
124
|
+
return {
|
|
125
|
+
cancelled: false,
|
|
126
|
+
refreshRequested: true,
|
|
127
|
+
selectedPaths,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
case "cancel": {
|
|
131
|
+
return {
|
|
132
|
+
cancelled: true,
|
|
133
|
+
refreshRequested: false,
|
|
134
|
+
selectedPaths,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
case "toggle-session": {
|
|
138
|
+
if (selectedPaths.has(action.sessionPath)) {
|
|
139
|
+
selectedPaths.delete(action.sessionPath);
|
|
140
|
+
} else {
|
|
141
|
+
selectedPaths.add(action.sessionPath);
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
default: {
|
|
146
|
+
const unreachable: never = action;
|
|
147
|
+
throw new Error(`Unhandled selection action: ${String(unreachable)}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function selectSessionsForCleanup(
|
|
154
|
+
ctx: ExtensionCommandContext,
|
|
155
|
+
sessions: readonly SessionCleanupSession[],
|
|
156
|
+
): Promise<SessionSelectionResult> {
|
|
157
|
+
try {
|
|
158
|
+
return await showSessionCleanupPicker(ctx, sessions);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
161
|
+
ctx.ui.notify(
|
|
162
|
+
`Interactive picker failed (${message}). Falling back to basic selector.`,
|
|
163
|
+
"warning",
|
|
164
|
+
);
|
|
165
|
+
return selectSessionsWithLegacyMenu(ctx, sessions);
|
|
166
|
+
}
|
|
167
|
+
}
|