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.
@@ -0,0 +1,192 @@
1
+ declare namespace NodeJS {
2
+ interface Process {
3
+ env: Record<string, string | undefined>;
4
+ platform: string;
5
+ stdout: {
6
+ columns?: number;
7
+ rows?: number;
8
+ };
9
+ }
10
+ }
11
+
12
+ declare const process: NodeJS.Process;
13
+
14
+ declare module "node:assert/strict" {
15
+ const assert: {
16
+ equal(actual: unknown, expected: unknown, message?: string): void;
17
+ deepEqual(actual: unknown, expected: unknown, message?: string): void;
18
+ ok(value: unknown, message?: string): void;
19
+ };
20
+
21
+ export default assert;
22
+ }
23
+
24
+ declare module "node:child_process" {
25
+ export function spawnSync(
26
+ command: string,
27
+ args?: readonly string[],
28
+ options?: Record<string, unknown>,
29
+ ): {
30
+ status: number | null;
31
+ stderr?: unknown;
32
+ error?: Error;
33
+ };
34
+ }
35
+
36
+ declare module "node:fs" {
37
+ export function existsSync(path: string): boolean;
38
+ export function mkdirSync(path: string, options?: { recursive?: boolean }): void;
39
+ export function mkdtempSync(prefix: string): string;
40
+ export function readFileSync(path: string, encoding: string): string;
41
+ export function rmSync(path: string, options?: { recursive?: boolean; force?: boolean }): void;
42
+ export function writeFileSync(path: string, data: string, encoding: string): void;
43
+ }
44
+
45
+ declare module "node:fs/promises" {
46
+ export function readFile(path: string, encoding: string): Promise<string>;
47
+ export function unlink(path: string): Promise<void>;
48
+ }
49
+
50
+ declare module "node:os" {
51
+ export function homedir(): string;
52
+ export function tmpdir(): string;
53
+ }
54
+
55
+ declare module "node:path" {
56
+ export function basename(path: string): string;
57
+ export function dirname(path: string): string;
58
+ export function join(...parts: string[]): string;
59
+ }
60
+
61
+ declare module "node:test" {
62
+ const test: (
63
+ name: string,
64
+ fn: (context?: unknown) => void | Promise<void>,
65
+ ) => void;
66
+
67
+ export default test;
68
+ }
69
+
70
+ declare module "node:url" {
71
+ export function fileURLToPath(url: unknown): string;
72
+ }
73
+
74
+ declare module "@mariozechner/pi-tui" {
75
+ export interface Component {
76
+ render(width: number): string[];
77
+ handleInput(data: string): void;
78
+ invalidate?(): void;
79
+ }
80
+
81
+ export function matchesKey(data: string, key: string): boolean;
82
+ export function truncateToWidth(
83
+ text: string,
84
+ width: number,
85
+ ellipsis?: string,
86
+ trimWhitespace?: boolean,
87
+ ): string;
88
+ export function visibleWidth(text: string): number;
89
+ }
90
+
91
+ declare module "@mariozechner/pi-coding-agent" {
92
+ import type { Component } from "@mariozechner/pi-tui";
93
+
94
+ export interface SessionInfo {
95
+ path: string;
96
+ id: string;
97
+ cwd: string;
98
+ name?: string;
99
+ parentSessionPath?: string;
100
+ created: Date;
101
+ modified: Date;
102
+ messageCount: number;
103
+ firstMessage: string;
104
+ allMessagesText: string;
105
+ }
106
+
107
+ export class SessionManager {
108
+ static list(cwd: string, sessionDir: string): Promise<SessionInfo[]>;
109
+ static listAll(): Promise<SessionInfo[]>;
110
+ getSessionId(): string;
111
+ getSessionFile(): string | undefined;
112
+ getCwd(): string;
113
+ getSessionDir(): string;
114
+ getEntries(): unknown[];
115
+ getSessionName(): string | undefined;
116
+ }
117
+
118
+ export interface Theme {
119
+ fg?(color: string, text: string): string;
120
+ bold?(text: string): string;
121
+ }
122
+
123
+ export interface ExtensionUIContext {
124
+ select(title: string, options: string[]): Promise<string | undefined>;
125
+ confirm(title: string, message: string): Promise<boolean>;
126
+ notify(message: string, type?: "info" | "warning" | "error"): void;
127
+ custom<T>(
128
+ factory: (
129
+ tui: { requestRender(): void },
130
+ theme: Theme,
131
+ keybindings: unknown,
132
+ done: (result?: T) => void,
133
+ ) => Component,
134
+ options?: Record<string, unknown>,
135
+ ): Promise<T>;
136
+ }
137
+
138
+ export interface ExtensionContext {
139
+ hasUI: boolean;
140
+ cwd: string;
141
+ sessionManager: SessionManager;
142
+ ui: ExtensionUIContext;
143
+ getSystemPrompt(): string;
144
+ }
145
+
146
+ export interface ExtensionCommandContext extends ExtensionContext {
147
+ newSession(options?: {
148
+ parentSession?: string;
149
+ setup?: (sessionManager: SessionManager) => Promise<void>;
150
+ }): Promise<{ cancelled: boolean }>;
151
+ reload(): Promise<void>;
152
+ }
153
+
154
+ export interface ResourcesDiscoverEvent {
155
+ type: "resources_discover";
156
+ cwd: string;
157
+ reason: "startup" | "reload";
158
+ }
159
+
160
+ export interface SessionStartEvent {
161
+ type: "session_start";
162
+ cwd: string;
163
+ reason: "new" | "resume" | "fork" | "reload";
164
+ }
165
+
166
+ export interface ExtensionAPI {
167
+ on(
168
+ event: "resources_discover",
169
+ handler: (event: ResourcesDiscoverEvent) => void | Promise<void>,
170
+ ): void;
171
+ on(
172
+ event: "session_start",
173
+ handler: (event: SessionStartEvent, ctx: ExtensionContext) => void | Promise<void>,
174
+ ): void;
175
+
176
+ registerCommand(
177
+ name: string,
178
+ definition: {
179
+ description: string;
180
+ getArgumentCompletions?: (
181
+ argumentPrefix: string,
182
+ ) => Array<{ value: string; label: string; description?: string }> | null;
183
+ handler: (args: string, ctx: ExtensionCommandContext) => Promise<void> | void;
184
+ },
185
+ ): void;
186
+
187
+ sendUserMessage(
188
+ content: string,
189
+ options?: { deliverAs?: "steer" | "followUp" },
190
+ ): void;
191
+ }
192
+ }
package/src/types.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { SessionInfo } from "@mariozechner/pi-coding-agent";
2
+
3
+ export type SessionScope = "current" | "all";
4
+
5
+ export type DeleteMethod = "trash" | "unlink";
6
+
7
+ export interface SessionCleanupSession extends SessionInfo {
8
+ responsibleAgentName: string | null;
9
+ }
10
+
11
+ export interface DeleteSessionSuccess {
12
+ ok: true;
13
+ method: DeleteMethod;
14
+ }
15
+
16
+ export interface DeleteSessionFailure {
17
+ ok: false;
18
+ method: "unlink";
19
+ error: string;
20
+ }
21
+
22
+ export type DeleteSessionResult = DeleteSessionSuccess | DeleteSessionFailure;
23
+
24
+ export interface DeleteFailureDetail {
25
+ session: SessionInfo;
26
+ error: string;
27
+ }
28
+
29
+ export interface BatchDeleteResult {
30
+ deleted: SessionInfo[];
31
+ failed: DeleteFailureDetail[];
32
+ methods: Record<DeleteMethod, number>;
33
+ }
34
+
35
+ export interface SessionSelectionResult {
36
+ cancelled: boolean;
37
+ refreshRequested: boolean;
38
+ selectedPaths: Set<string>;
39
+ }
@@ -0,0 +1,476 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import type { IconModePreference } from "../config-store.js";
5
+
6
+ export type PickerIconMode = "nerd" | "fallback";
7
+
8
+ export interface PickerIcons {
9
+ cursor: string;
10
+ checkboxChecked: string;
11
+ checkboxUnchecked: string;
12
+ scrollUp: string;
13
+ scrollDown: string;
14
+ actionMove: string;
15
+ actionPage: string;
16
+ actionToggle: string;
17
+ actionSelectAll: string;
18
+ actionRefresh: string;
19
+ actionDelete: string;
20
+ actionCancel: string;
21
+ }
22
+
23
+ export interface ResolvedPickerIcons {
24
+ mode: PickerIconMode;
25
+ icons: PickerIcons;
26
+ }
27
+
28
+ export interface PickerIconDetectionContext {
29
+ platform: string;
30
+ env: Record<string, string | undefined>;
31
+ pathExists: (path: string) => boolean;
32
+ readTextFile: (path: string) => string | null;
33
+ }
34
+
35
+ const NERD_ICONS: PickerIcons = {
36
+ cursor: "",
37
+ checkboxChecked: "",
38
+ checkboxUnchecked: "",
39
+ scrollUp: "",
40
+ scrollDown: "",
41
+ actionMove: "󰆾",
42
+ actionPage: "󰘖",
43
+ actionToggle: "󰄱",
44
+ actionSelectAll: "󰄬",
45
+ actionRefresh: "󰑐",
46
+ actionDelete: "󰆴",
47
+ actionCancel: "󰜺",
48
+ };
49
+
50
+ const FALLBACK_ICONS: PickerIcons = {
51
+ cursor: "›",
52
+ checkboxChecked: "☑",
53
+ checkboxUnchecked: "☐",
54
+ scrollUp: "↑",
55
+ scrollDown: "↓",
56
+ actionMove: "↕",
57
+ actionPage: "⇵",
58
+ actionToggle: "☑",
59
+ actionSelectAll: "☑",
60
+ actionRefresh: "↻",
61
+ actionDelete: "✖",
62
+ actionCancel: "✖",
63
+ };
64
+
65
+ const WINDOWS_TERMINAL_SETTINGS_CANDIDATES = [
66
+ ["Packages", "Microsoft.WindowsTerminal_8wekyb3d8bbwe", "LocalState", "settings.json"],
67
+ ["Packages", "Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe", "LocalState", "settings.json"],
68
+ ["Packages", "Microsoft.WindowsTerminalDev_8wekyb3d8bbwe", "LocalState", "settings.json"],
69
+ ["Microsoft", "Windows Terminal", "settings.json"],
70
+ ] as const;
71
+
72
+ const KNOWN_NERD_TERM_PROGRAM_HINTS = [
73
+ "wezterm",
74
+ "ghostty",
75
+ "kitty",
76
+ "iterm",
77
+ "warpterminal",
78
+ "warp",
79
+ "hyper",
80
+ "alacritty",
81
+ "konsole",
82
+ ] as const;
83
+
84
+ const KNOWN_NERD_TERM_HINTS = ["xterm-kitty", "wezterm", "ghostty", "alacritty", "konsole"] as const;
85
+
86
+ const FONT_HINT_ENV_KEYS = [
87
+ "PI_SESSION_CLEANUP_FONT_FAMILY",
88
+ "PI_FONT_FAMILY",
89
+ "TERM_PROGRAM_FONT",
90
+ "KITTY_FONT_FAMILY",
91
+ "WEZTERM_FONT",
92
+ "WT_PROFILE_FONT_FACE",
93
+ ] as const;
94
+
95
+ function createDefaultContext(): PickerIconDetectionContext {
96
+ return {
97
+ platform: process.platform,
98
+ env: process.env,
99
+ pathExists: (path) => existsSync(path),
100
+ readTextFile: (path) => {
101
+ try {
102
+ return readFileSync(path, "utf8");
103
+ } catch {
104
+ return null;
105
+ }
106
+ },
107
+ };
108
+ }
109
+
110
+ function parseEnvBoolean(value: string | undefined): boolean | null {
111
+ if (!value) {
112
+ return null;
113
+ }
114
+
115
+ const normalized = value.trim().toLowerCase();
116
+ if (["1", "true", "yes", "on"].includes(normalized)) {
117
+ return true;
118
+ }
119
+
120
+ if (["0", "false", "no", "off"].includes(normalized)) {
121
+ return false;
122
+ }
123
+
124
+ return null;
125
+ }
126
+
127
+ function parseMode(value: string | undefined): IconModePreference | null {
128
+ if (!value) {
129
+ return null;
130
+ }
131
+
132
+ const normalized = value.trim().toLowerCase();
133
+ if (normalized === "auto" || normalized === "nerd" || normalized === "fallback") {
134
+ return normalized;
135
+ }
136
+
137
+ return null;
138
+ }
139
+
140
+ function resolvePreference(
141
+ configPreference: IconModePreference,
142
+ env: Record<string, string | undefined>,
143
+ ): IconModePreference {
144
+ const envMode = parseMode(env.PI_SESSION_CLEANUP_ICON_MODE);
145
+ if (envMode) {
146
+ return envMode;
147
+ }
148
+
149
+ const envBool = parseEnvBoolean(env.PI_SESSION_CLEANUP_NERD_FONT ?? env.PI_NERD_FONT);
150
+ if (envBool !== null) {
151
+ return envBool ? "nerd" : "fallback";
152
+ }
153
+
154
+ return configPreference;
155
+ }
156
+
157
+ function isRecord(value: unknown): value is Record<string, unknown> {
158
+ return typeof value === "object" && value !== null;
159
+ }
160
+
161
+ function normalizeProfileId(value: string): string {
162
+ return value.trim().replace(/^\{/, "").replace(/\}$/, "").toLowerCase();
163
+ }
164
+
165
+ function readWindowsTerminalSettingsJson(
166
+ settingsPath: string,
167
+ context: PickerIconDetectionContext,
168
+ ): Record<string, unknown> | null {
169
+ const raw = context.readTextFile(settingsPath);
170
+ return raw ? parseSettingsJson(raw) : null;
171
+ }
172
+
173
+ function parseSettingsJson(raw: string): Record<string, unknown> | null {
174
+ const withoutBom = raw.replace(/^\uFEFF/, "");
175
+
176
+ try {
177
+ const parsed = JSON.parse(withoutBom);
178
+ return isRecord(parsed) ? parsed : null;
179
+ } catch {
180
+ const withoutComments = stripJsonComments(withoutBom);
181
+ const withoutTrailingCommas = stripTrailingCommas(withoutComments);
182
+
183
+ try {
184
+ const parsed = JSON.parse(withoutTrailingCommas);
185
+ return isRecord(parsed) ? parsed : null;
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+ }
191
+
192
+ function stripJsonComments(value: string): string {
193
+ let result = "";
194
+ let inString = false;
195
+ let escaped = false;
196
+ let inLineComment = false;
197
+ let inBlockComment = false;
198
+
199
+ for (let index = 0; index < value.length; index += 1) {
200
+ const current = value[index];
201
+ const next = value[index + 1];
202
+
203
+ if (inLineComment) {
204
+ if (current === "\n") {
205
+ inLineComment = false;
206
+ result += current;
207
+ }
208
+ continue;
209
+ }
210
+
211
+ if (inBlockComment) {
212
+ if (current === "*" && next === "/") {
213
+ inBlockComment = false;
214
+ index += 1;
215
+ }
216
+ continue;
217
+ }
218
+
219
+ if (inString) {
220
+ result += current;
221
+ if (escaped) {
222
+ escaped = false;
223
+ } else if (current === "\\") {
224
+ escaped = true;
225
+ } else if (current === '"') {
226
+ inString = false;
227
+ }
228
+ continue;
229
+ }
230
+
231
+ if (current === '"') {
232
+ inString = true;
233
+ result += current;
234
+ continue;
235
+ }
236
+
237
+ if (current === "/" && next === "/") {
238
+ inLineComment = true;
239
+ index += 1;
240
+ continue;
241
+ }
242
+
243
+ if (current === "/" && next === "*") {
244
+ inBlockComment = true;
245
+ index += 1;
246
+ continue;
247
+ }
248
+
249
+ result += current;
250
+ }
251
+
252
+ return result;
253
+ }
254
+
255
+ function stripTrailingCommas(value: string): string {
256
+ let result = "";
257
+ let inString = false;
258
+ let escaped = false;
259
+
260
+ for (let index = 0; index < value.length; index += 1) {
261
+ const current = value[index];
262
+
263
+ if (inString) {
264
+ result += current;
265
+ if (escaped) {
266
+ escaped = false;
267
+ } else if (current === "\\") {
268
+ escaped = true;
269
+ } else if (current === '"') {
270
+ inString = false;
271
+ }
272
+ continue;
273
+ }
274
+
275
+ if (current === '"') {
276
+ inString = true;
277
+ result += current;
278
+ continue;
279
+ }
280
+
281
+ if (current !== ",") {
282
+ result += current;
283
+ continue;
284
+ }
285
+
286
+ let lookahead = index + 1;
287
+ while (lookahead < value.length && /\s/.test(value[lookahead] ?? "")) {
288
+ lookahead += 1;
289
+ }
290
+
291
+ const nextNonSpace = value[lookahead];
292
+ if (nextNonSpace === "}" || nextNonSpace === "]") {
293
+ continue;
294
+ }
295
+
296
+ result += current;
297
+ }
298
+
299
+ return result;
300
+ }
301
+
302
+ function getRecord(value: unknown, key: string): Record<string, unknown> | null {
303
+ if (!isRecord(value)) {
304
+ return null;
305
+ }
306
+
307
+ const nested = value[key];
308
+ return isRecord(nested) ? nested : null;
309
+ }
310
+
311
+ function getProfileFontFace(profile: Record<string, unknown> | null): string | null {
312
+ if (!profile) {
313
+ return null;
314
+ }
315
+
316
+ const font = getRecord(profile, "font");
317
+ if (font && typeof font.face === "string" && font.face.trim().length > 0) {
318
+ return font.face;
319
+ }
320
+
321
+ if (typeof profile.fontFace === "string" && profile.fontFace.trim().length > 0) {
322
+ return profile.fontFace;
323
+ }
324
+
325
+ return null;
326
+ }
327
+
328
+ function findProfileById(
329
+ settings: Record<string, unknown>,
330
+ wtProfileId: string | undefined,
331
+ ): Record<string, unknown> | null {
332
+ if (!wtProfileId) {
333
+ return null;
334
+ }
335
+
336
+ const profiles = getRecord(settings, "profiles");
337
+ const list = profiles?.list;
338
+ if (!Array.isArray(list)) {
339
+ return null;
340
+ }
341
+
342
+ const expectedId = normalizeProfileId(wtProfileId);
343
+ if (expectedId.length === 0) {
344
+ return null;
345
+ }
346
+
347
+ for (const item of list) {
348
+ if (!isRecord(item)) {
349
+ continue;
350
+ }
351
+
352
+ const guid = typeof item.guid === "string" ? normalizeProfileId(item.guid) : "";
353
+ if (guid === expectedId) {
354
+ return item;
355
+ }
356
+ }
357
+
358
+ return null;
359
+ }
360
+
361
+ function resolveWindowsTerminalSettingsPath(context: PickerIconDetectionContext): string | null {
362
+ const localAppData = context.env.LOCALAPPDATA;
363
+ if (!localAppData) {
364
+ return null;
365
+ }
366
+
367
+ for (const segments of WINDOWS_TERMINAL_SETTINGS_CANDIDATES) {
368
+ const candidatePath = join(localAppData, ...segments);
369
+ if (context.pathExists(candidatePath)) {
370
+ return candidatePath;
371
+ }
372
+ }
373
+
374
+ return null;
375
+ }
376
+
377
+ function isNerdFontFace(fontFace: string | null): boolean {
378
+ return typeof fontFace === "string" && /nerd/i.test(fontFace);
379
+ }
380
+
381
+ function detectFontHintFromEnv(env: Record<string, string | undefined>): boolean {
382
+ for (const key of FONT_HINT_ENV_KEYS) {
383
+ if (isNerdFontFace(env[key] ?? null)) {
384
+ return true;
385
+ }
386
+ }
387
+
388
+ return false;
389
+ }
390
+
391
+ function detectKnownTerminalHint(env: Record<string, string | undefined>): boolean {
392
+ if (env.GHOSTTY_RESOURCES_DIR || env.WEZTERM_EXECUTABLE || env.WEZTERM_PANE || env.KITTY_WINDOW_ID) {
393
+ return true;
394
+ }
395
+
396
+ const termProgram = (env.TERM_PROGRAM ?? "").trim().toLowerCase();
397
+ if (KNOWN_NERD_TERM_PROGRAM_HINTS.some((hint) => termProgram.includes(hint))) {
398
+ return true;
399
+ }
400
+
401
+ const term = (env.TERM ?? "").trim().toLowerCase();
402
+ return KNOWN_NERD_TERM_HINTS.some((hint) => term.includes(hint));
403
+ }
404
+
405
+ function detectWindowsTerminalNerdFont(context: PickerIconDetectionContext): boolean {
406
+ if (!context.env.WT_SESSION) {
407
+ return false;
408
+ }
409
+
410
+ const settingsPath = resolveWindowsTerminalSettingsPath(context);
411
+ if (!settingsPath) {
412
+ return false;
413
+ }
414
+
415
+ const settings = readWindowsTerminalSettingsJson(settingsPath, context);
416
+ if (!settings) {
417
+ return false;
418
+ }
419
+
420
+ const activeProfile = findProfileById(settings, context.env.WT_PROFILE_ID);
421
+ const activeProfileFont = getProfileFontFace(activeProfile);
422
+ if (activeProfileFont !== null) {
423
+ return isNerdFontFace(activeProfileFont);
424
+ }
425
+
426
+ const profiles = getRecord(settings, "profiles");
427
+ const profileDefaultsFont = getProfileFontFace(getRecord(profiles, "defaults"));
428
+ if (profileDefaultsFont !== null) {
429
+ return isNerdFontFace(profileDefaultsFont);
430
+ }
431
+
432
+ const rootDefaultsFont = getProfileFontFace(getRecord(settings, "defaults"));
433
+ if (rootDefaultsFont !== null) {
434
+ return isNerdFontFace(rootDefaultsFont);
435
+ }
436
+
437
+ return false;
438
+ }
439
+
440
+ function resolveAutoMode(context: PickerIconDetectionContext): PickerIconMode {
441
+ if (context.platform === "win32" && detectWindowsTerminalNerdFont(context)) {
442
+ return "nerd";
443
+ }
444
+
445
+ if (detectFontHintFromEnv(context.env) || detectKnownTerminalHint(context.env)) {
446
+ return "nerd";
447
+ }
448
+
449
+ return "fallback";
450
+ }
451
+
452
+ function iconsForMode(mode: PickerIconMode): PickerIcons {
453
+ return mode === "nerd" ? NERD_ICONS : FALLBACK_ICONS;
454
+ }
455
+
456
+ export function resolvePickerIconsForContext(
457
+ configPreference: IconModePreference,
458
+ context: PickerIconDetectionContext,
459
+ ): ResolvedPickerIcons {
460
+ const preference = resolvePreference(configPreference, context.env);
461
+ const mode =
462
+ preference === "auto"
463
+ ? resolveAutoMode(context)
464
+ : preference === "nerd"
465
+ ? "nerd"
466
+ : "fallback";
467
+
468
+ return {
469
+ mode,
470
+ icons: iconsForMode(mode),
471
+ };
472
+ }
473
+
474
+ export function resolvePickerIcons(configPreference: IconModePreference): ResolvedPickerIcons {
475
+ return resolvePickerIconsForContext(configPreference, createDefaultContext());
476
+ }
@@ -0,0 +1,15 @@
1
+ import type { PickerIcons } from "./icons.js";
2
+
3
+ export interface LegendContent {
4
+ lines: string[];
5
+ }
6
+
7
+ export function buildLegendContent(_icons: PickerIcons, _maxWidth: number): LegendContent {
8
+ return {
9
+ lines: [
10
+ "NAV: [↑/↓/j/k] Move [PgUp/PgDn] Page",
11
+ "SEL: [Space] Toggle [a] Select All",
12
+ "ACT: [Enter] Delete [r] Refresh [Esc/q] Cancel",
13
+ ],
14
+ };
15
+ }