pi-session-cleanup 1.0.0 → 1.1.1
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 +13 -0
- package/README.md +20 -2
- package/package.json +66 -66
- package/src/agent-target.ts +361 -0
- package/src/index.ts +12 -3
- package/src/session-agent.ts +103 -78
- package/src/session-cleanup-command.ts +268 -268
- package/src/session-delete.ts +165 -11
- package/src/session-entry.ts +23 -0
- package/src/session-format.ts +98 -98
- package/src/session-nix-command.ts +353 -84
- package/src/session-quit-shutdown.ts +40 -0
- package/src/session-selection.ts +167 -167
- package/src/session-sort.ts +137 -137
- package/src/session-source.ts +32 -32
- package/src/tui/agent-target-picker.ts +306 -0
- package/src/tui/session-cleanup-picker.ts +592 -592
- package/src/types-shims.d.ts +41 -9
- package/src/types.ts +1 -1
|
@@ -1,592 +1,592 @@
|
|
|
1
|
-
import type { ExtensionCommandContext } from "@
|
|
2
|
-
import { matchesKey, truncateToWidth, visibleWidth, type Component } from "@
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
formatSessionAge,
|
|
6
|
-
getResponsibleAgentDisplayName,
|
|
7
|
-
getSessionTitle,
|
|
8
|
-
shortenPath,
|
|
9
|
-
} from "../session-format.js";
|
|
10
|
-
import { loadSessionCleanupConfig } from "../config-store.js";
|
|
11
|
-
import type { SessionSelectionResult, SessionCleanupSession } from "../types.js";
|
|
12
|
-
import { resolvePickerIcons, type PickerIcons } from "../ui/icons.js";
|
|
13
|
-
import { buildLegendContent } from "../ui/legend.js";
|
|
14
|
-
|
|
15
|
-
interface ThemeLike {
|
|
16
|
-
fg?: unknown;
|
|
17
|
-
bold?: unknown;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface PickerResultHandler {
|
|
21
|
-
(result: SessionSelectionResult): void;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface OverlayOptions {
|
|
25
|
-
anchor: "center";
|
|
26
|
-
width: number;
|
|
27
|
-
maxHeight: number;
|
|
28
|
-
margin: number;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface ColumnLayout {
|
|
32
|
-
description: number;
|
|
33
|
-
agent: number;
|
|
34
|
-
age: number;
|
|
35
|
-
id: number;
|
|
36
|
-
path: number;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
type CellAlignment = "start" | "end";
|
|
40
|
-
|
|
41
|
-
const TITLE_TEXT = "SESSION CLEANUP : BATCH DELETE";
|
|
42
|
-
const ROW_PREFIX_WIDTH = 6;
|
|
43
|
-
const AGE_COLUMN_WIDTH = 5;
|
|
44
|
-
const ID_COLUMN_WIDTH = 8;
|
|
45
|
-
const COLUMN_GAP = " ";
|
|
46
|
-
|
|
47
|
-
function clamp(value: number, min: number, max: number): number {
|
|
48
|
-
return Math.max(min, Math.min(max, value));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function fitLine(text: string, width: number): string {
|
|
52
|
-
return truncateToWidth(text, Math.max(1, width), "…", true);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function formatTheme(theme: ThemeLike, color: string, text: string): string {
|
|
56
|
-
try {
|
|
57
|
-
if (typeof theme.fg === "function") {
|
|
58
|
-
const format = theme.fg as (resolvedColor: string, value: string) => string;
|
|
59
|
-
return format(color, text);
|
|
60
|
-
}
|
|
61
|
-
} catch {
|
|
62
|
-
// Fall through to plain text rendering.
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return text;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function formatBold(theme: ThemeLike, text: string): string {
|
|
69
|
-
try {
|
|
70
|
-
if (typeof theme.bold === "function") {
|
|
71
|
-
const format = theme.bold as (value: string) => string;
|
|
72
|
-
return format(text);
|
|
73
|
-
}
|
|
74
|
-
} catch {
|
|
75
|
-
// Fall through to plain text rendering.
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return text;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function frameTop(width: number): string {
|
|
82
|
-
return `╭${"─".repeat(width)}╮`;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function frameDivider(width: number): string {
|
|
86
|
-
return `├${"─".repeat(width)}┤`;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function frameBottom(width: number): string {
|
|
90
|
-
return `╰${"─".repeat(width)}╯`;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function frameLine(content: string, width: number): string {
|
|
94
|
-
const clipped = fitLine(content, width);
|
|
95
|
-
const padded = clipped.padEnd(width, " ");
|
|
96
|
-
return `│${padded}│`;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function resolveOverlayOptions(): OverlayOptions {
|
|
100
|
-
const terminalWidth =
|
|
101
|
-
typeof process.stdout.columns === "number" && Number.isFinite(process.stdout.columns)
|
|
102
|
-
? process.stdout.columns
|
|
103
|
-
: 120;
|
|
104
|
-
const terminalHeight =
|
|
105
|
-
typeof process.stdout.rows === "number" && Number.isFinite(process.stdout.rows)
|
|
106
|
-
? process.stdout.rows
|
|
107
|
-
: 36;
|
|
108
|
-
|
|
109
|
-
const margin = 1;
|
|
110
|
-
const availableWidth = Math.max(24, terminalWidth - margin * 2);
|
|
111
|
-
const preferredWidth =
|
|
112
|
-
terminalWidth >= 160 ? 118 : terminalWidth >= 140 ? 110 : terminalWidth >= 120 ? 100 : 92;
|
|
113
|
-
const width = Math.max(24, Math.min(preferredWidth, availableWidth));
|
|
114
|
-
|
|
115
|
-
const availableHeight = Math.max(12, terminalHeight - margin * 2);
|
|
116
|
-
const preferredHeight = Math.max(12, Math.floor(terminalHeight * 0.86));
|
|
117
|
-
const maxHeight = Math.min(preferredHeight, availableHeight);
|
|
118
|
-
|
|
119
|
-
return {
|
|
120
|
-
anchor: "center",
|
|
121
|
-
width,
|
|
122
|
-
maxHeight,
|
|
123
|
-
margin,
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function alignCell(value: string, width: number, alignment: CellAlignment = "start"): string {
|
|
128
|
-
const clipped = fitLine(value, width);
|
|
129
|
-
const padding = Math.max(0, width - visibleWidth(clipped));
|
|
130
|
-
return alignment === "end"
|
|
131
|
-
? `${" ".repeat(padding)}${clipped}`
|
|
132
|
-
: `${clipped}${" ".repeat(padding)}`;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function resolveColumnLayout(contentWidth: number): ColumnLayout {
|
|
136
|
-
const gapTotal = COLUMN_GAP.length * 4;
|
|
137
|
-
const availableColumns = Math.max(5, contentWidth - ROW_PREFIX_WIDTH - gapTotal);
|
|
138
|
-
const fixedColumns = AGE_COLUMN_WIDTH + ID_COLUMN_WIDTH;
|
|
139
|
-
const flexibleColumns = Math.max(3, availableColumns - fixedColumns);
|
|
140
|
-
|
|
141
|
-
let description = 12;
|
|
142
|
-
let agent = 8;
|
|
143
|
-
let path = 12;
|
|
144
|
-
|
|
145
|
-
const minimumFlexibleColumns = description + agent + path;
|
|
146
|
-
|
|
147
|
-
if (flexibleColumns >= minimumFlexibleColumns) {
|
|
148
|
-
const extra = flexibleColumns - minimumFlexibleColumns;
|
|
149
|
-
description += Math.floor(extra * 0.45);
|
|
150
|
-
path += Math.floor(extra * 0.4);
|
|
151
|
-
agent += Math.min(6, extra - (description - 12) - (path - 12));
|
|
152
|
-
path += flexibleColumns - (description + agent + path);
|
|
153
|
-
} else {
|
|
154
|
-
agent = Math.max(4, Math.floor(flexibleColumns * 0.18));
|
|
155
|
-
description = Math.max(6, Math.floor(flexibleColumns * 0.42));
|
|
156
|
-
path = Math.max(1, flexibleColumns - agent - description);
|
|
157
|
-
|
|
158
|
-
if (path < 6 && description > 6) {
|
|
159
|
-
const shift = Math.min(6 - path, description - 6);
|
|
160
|
-
description -= shift;
|
|
161
|
-
path += shift;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (path < 6 && agent > 4) {
|
|
165
|
-
const shift = Math.min(6 - path, agent - 4);
|
|
166
|
-
agent -= shift;
|
|
167
|
-
path += shift;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
path = Math.max(1, flexibleColumns - agent - description);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return {
|
|
174
|
-
description,
|
|
175
|
-
agent,
|
|
176
|
-
age: AGE_COLUMN_WIDTH,
|
|
177
|
-
id: ID_COLUMN_WIDTH,
|
|
178
|
-
path,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function buildStatsLine(
|
|
183
|
-
contentWidth: number,
|
|
184
|
-
totalSessions: number,
|
|
185
|
-
selectedCount: number,
|
|
186
|
-
start: number,
|
|
187
|
-
end: number,
|
|
188
|
-
): string {
|
|
189
|
-
const segmentGap = " ";
|
|
190
|
-
const totalGapWidth = segmentGap.length * 2;
|
|
191
|
-
const baseSegmentWidth = Math.max(1, Math.floor((contentWidth - totalGapWidth) / 3));
|
|
192
|
-
const remainingWidth = Math.max(0, contentWidth - totalGapWidth - baseSegmentWidth * 3);
|
|
193
|
-
const segmentWidths = [
|
|
194
|
-
baseSegmentWidth + remainingWidth,
|
|
195
|
-
baseSegmentWidth,
|
|
196
|
-
baseSegmentWidth,
|
|
197
|
-
] as const;
|
|
198
|
-
const visibleRange = totalSessions === 0 ? "0-0/0" : `${start + 1}-${end}/${totalSessions}`;
|
|
199
|
-
const segments = [
|
|
200
|
-
alignCell(`TOTAL: ${totalSessions}`, segmentWidths[0]),
|
|
201
|
-
alignCell(`SELECTED: ${selectedCount}`, segmentWidths[1]),
|
|
202
|
-
alignCell(`VISIBLE: ${visibleRange}`, segmentWidths[2]),
|
|
203
|
-
];
|
|
204
|
-
|
|
205
|
-
return segments.join(segmentGap);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function buildColumnLine(
|
|
209
|
-
layout: ColumnLayout,
|
|
210
|
-
values: {
|
|
211
|
-
description: string;
|
|
212
|
-
agent: string;
|
|
213
|
-
age: string;
|
|
214
|
-
id: string;
|
|
215
|
-
path: string;
|
|
216
|
-
},
|
|
217
|
-
): string {
|
|
218
|
-
return [
|
|
219
|
-
alignCell(values.description, layout.description),
|
|
220
|
-
alignCell(values.agent, layout.agent),
|
|
221
|
-
alignCell(values.age, layout.age, "end"),
|
|
222
|
-
alignCell(values.id, layout.id),
|
|
223
|
-
alignCell(values.path, layout.path),
|
|
224
|
-
].join(COLUMN_GAP);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function buildColumnHeaderLine(layout: ColumnLayout): string {
|
|
228
|
-
return `${" ".repeat(ROW_PREFIX_WIDTH)}${buildColumnLine(layout, {
|
|
229
|
-
description: "TASK DESCRIPTION",
|
|
230
|
-
agent: "AGENT",
|
|
231
|
-
age: "AGE",
|
|
232
|
-
id: "ID",
|
|
233
|
-
path: "PATH",
|
|
234
|
-
})}`;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function buildSessionRow(
|
|
238
|
-
session: SessionCleanupSession,
|
|
239
|
-
selected: boolean,
|
|
240
|
-
focused: boolean,
|
|
241
|
-
layout: ColumnLayout,
|
|
242
|
-
): string {
|
|
243
|
-
const prefix = `${focused ? ">" : " "} ${selected ? "[x]" : "[ ]"} `;
|
|
244
|
-
return `${prefix}${buildColumnLine(layout, {
|
|
245
|
-
description: getSessionTitle(session),
|
|
246
|
-
agent: `@${getResponsibleAgentDisplayName(session)}`,
|
|
247
|
-
age: formatSessionAge(session.modified),
|
|
248
|
-
id: session.id.slice(0, 8),
|
|
249
|
-
path: shortenPath(session.cwd || "(unknown cwd)"),
|
|
250
|
-
})}`;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
class SessionCleanupPicker implements Component {
|
|
254
|
-
private cursorIndex = 0;
|
|
255
|
-
|
|
256
|
-
private scrollOffset = 0;
|
|
257
|
-
|
|
258
|
-
private inlineMessage: string | null = null;
|
|
259
|
-
|
|
260
|
-
private lastViewportSize = 10;
|
|
261
|
-
|
|
262
|
-
private readonly icons: PickerIcons;
|
|
263
|
-
|
|
264
|
-
constructor(
|
|
265
|
-
private readonly sessions: readonly SessionCleanupSession[],
|
|
266
|
-
private readonly selectedPaths: Set<string>,
|
|
267
|
-
private readonly theme: ThemeLike,
|
|
268
|
-
initialIcons: PickerIcons,
|
|
269
|
-
private readonly maxRenderRows: number,
|
|
270
|
-
private readonly onFinish: PickerResultHandler,
|
|
271
|
-
private readonly requestRender: () => void,
|
|
272
|
-
) {
|
|
273
|
-
this.icons = initialIcons;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
invalidate(): void {
|
|
277
|
-
// Rendering is state driven.
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
render(width: number): string[] {
|
|
281
|
-
const safeWidth = Math.max(24, Math.floor(width));
|
|
282
|
-
const frameInnerWidth = Math.max(22, safeWidth - 2);
|
|
283
|
-
const maxRows = this.resolveMaxRenderRows();
|
|
284
|
-
const legend = buildLegendContent(this.icons, frameInnerWidth);
|
|
285
|
-
const viewportSize = this.resolveViewportSize(maxRows, legend.lines.length);
|
|
286
|
-
const columns = resolveColumnLayout(frameInnerWidth);
|
|
287
|
-
|
|
288
|
-
this.lastViewportSize = viewportSize;
|
|
289
|
-
this.ensureCursorVisible(viewportSize);
|
|
290
|
-
|
|
291
|
-
const start = this.scrollOffset;
|
|
292
|
-
const end = Math.min(this.sessions.length, start + viewportSize);
|
|
293
|
-
|
|
294
|
-
const lines: string[] = [];
|
|
295
|
-
lines.push(frameTop(frameInnerWidth));
|
|
296
|
-
lines.push(
|
|
297
|
-
formatTheme(
|
|
298
|
-
this.theme,
|
|
299
|
-
"accent",
|
|
300
|
-
formatBold(this.theme, frameLine(` ${TITLE_TEXT}`, frameInnerWidth)),
|
|
301
|
-
),
|
|
302
|
-
);
|
|
303
|
-
lines.push(
|
|
304
|
-
formatTheme(
|
|
305
|
-
this.theme,
|
|
306
|
-
"dim",
|
|
307
|
-
frameLine(
|
|
308
|
-
buildStatsLine(
|
|
309
|
-
frameInnerWidth,
|
|
310
|
-
this.sessions.length,
|
|
311
|
-
this.selectedPaths.size,
|
|
312
|
-
start,
|
|
313
|
-
end,
|
|
314
|
-
),
|
|
315
|
-
frameInnerWidth,
|
|
316
|
-
),
|
|
317
|
-
),
|
|
318
|
-
);
|
|
319
|
-
lines.push(frameDivider(frameInnerWidth));
|
|
320
|
-
lines.push(
|
|
321
|
-
formatTheme(
|
|
322
|
-
this.theme,
|
|
323
|
-
"accent",
|
|
324
|
-
formatBold(this.theme, frameLine(buildColumnHeaderLine(columns), frameInnerWidth)),
|
|
325
|
-
),
|
|
326
|
-
);
|
|
327
|
-
lines.push(frameDivider(frameInnerWidth));
|
|
328
|
-
|
|
329
|
-
if (this.sessions.length === 0) {
|
|
330
|
-
lines.push(
|
|
331
|
-
formatTheme(
|
|
332
|
-
this.theme,
|
|
333
|
-
"dim",
|
|
334
|
-
frameLine(
|
|
335
|
-
`${" ".repeat(ROW_PREFIX_WIDTH)}${fitLine(
|
|
336
|
-
"No sessions found for this scope.",
|
|
337
|
-
frameInnerWidth - ROW_PREFIX_WIDTH,
|
|
338
|
-
)}`,
|
|
339
|
-
frameInnerWidth,
|
|
340
|
-
),
|
|
341
|
-
),
|
|
342
|
-
);
|
|
343
|
-
} else {
|
|
344
|
-
for (let index = start; index < end; index += 1) {
|
|
345
|
-
const session = this.sessions[index];
|
|
346
|
-
const rowLine = frameLine(
|
|
347
|
-
buildSessionRow(
|
|
348
|
-
session,
|
|
349
|
-
this.selectedPaths.has(session.path),
|
|
350
|
-
index === this.cursorIndex,
|
|
351
|
-
columns,
|
|
352
|
-
),
|
|
353
|
-
frameInnerWidth,
|
|
354
|
-
);
|
|
355
|
-
|
|
356
|
-
lines.push(
|
|
357
|
-
index === this.cursorIndex
|
|
358
|
-
? formatTheme(this.theme, "accent", formatBold(this.theme, rowLine))
|
|
359
|
-
: rowLine,
|
|
360
|
-
);
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (this.inlineMessage) {
|
|
365
|
-
lines.push(frameDivider(frameInnerWidth));
|
|
366
|
-
lines.push(
|
|
367
|
-
formatTheme(
|
|
368
|
-
this.theme,
|
|
369
|
-
"warning",
|
|
370
|
-
frameLine(` ${this.inlineMessage}`, frameInnerWidth),
|
|
371
|
-
),
|
|
372
|
-
);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
lines.push(frameDivider(frameInnerWidth));
|
|
376
|
-
for (const legendLine of legend.lines) {
|
|
377
|
-
lines.push(formatTheme(this.theme, "dim", frameLine(` ${legendLine}`, frameInnerWidth)));
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
lines.push(frameBottom(frameInnerWidth));
|
|
381
|
-
return lines;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
handleInput(data: string): void {
|
|
385
|
-
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || matchesKey(data, "q")) {
|
|
386
|
-
this.finish({
|
|
387
|
-
cancelled: true,
|
|
388
|
-
refreshRequested: false,
|
|
389
|
-
selectedPaths: new Set(this.selectedPaths),
|
|
390
|
-
});
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
395
|
-
this.moveCursor(-1);
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
400
|
-
this.moveCursor(1);
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
if (matchesKey(data, "pageUp")) {
|
|
405
|
-
this.moveCursor(-Math.max(1, this.lastViewportSize - 1));
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
if (matchesKey(data, "pageDown")) {
|
|
410
|
-
this.moveCursor(Math.max(1, this.lastViewportSize - 1));
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
if (matchesKey(data, "home")) {
|
|
415
|
-
this.cursorIndex = 0;
|
|
416
|
-
this.inlineMessage = null;
|
|
417
|
-
this.ensureCursorVisible(this.lastViewportSize);
|
|
418
|
-
this.requestRender();
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
if (matchesKey(data, "end")) {
|
|
423
|
-
this.cursorIndex = Math.max(0, this.sessions.length - 1);
|
|
424
|
-
this.inlineMessage = null;
|
|
425
|
-
this.ensureCursorVisible(this.lastViewportSize);
|
|
426
|
-
this.requestRender();
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
if (matchesKey(data, "space")) {
|
|
431
|
-
this.toggleCurrent();
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
if (matchesKey(data, "a")) {
|
|
436
|
-
this.toggleAll();
|
|
437
|
-
return;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
if (matchesKey(data, "r")) {
|
|
441
|
-
this.finish({
|
|
442
|
-
cancelled: false,
|
|
443
|
-
refreshRequested: true,
|
|
444
|
-
selectedPaths: new Set(this.selectedPaths),
|
|
445
|
-
});
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
if (matchesKey(data, "return")) {
|
|
450
|
-
if (this.selectedPaths.size === 0) {
|
|
451
|
-
this.inlineMessage = "No sessions selected. Toggle at least one session first.";
|
|
452
|
-
this.requestRender();
|
|
453
|
-
return;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
this.finish({
|
|
457
|
-
cancelled: false,
|
|
458
|
-
refreshRequested: false,
|
|
459
|
-
selectedPaths: new Set(this.selectedPaths),
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
private finish(result: SessionSelectionResult): void {
|
|
465
|
-
this.onFinish(result);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
private moveCursor(delta: number): void {
|
|
469
|
-
if (this.sessions.length === 0) {
|
|
470
|
-
this.cursorIndex = 0;
|
|
471
|
-
this.scrollOffset = 0;
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
this.cursorIndex = clamp(this.cursorIndex + delta, 0, this.sessions.length - 1);
|
|
476
|
-
this.inlineMessage = null;
|
|
477
|
-
this.ensureCursorVisible(this.lastViewportSize);
|
|
478
|
-
this.requestRender();
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
private toggleCurrent(): void {
|
|
482
|
-
const session = this.sessions[this.cursorIndex];
|
|
483
|
-
if (!session) {
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
if (this.selectedPaths.has(session.path)) {
|
|
488
|
-
this.selectedPaths.delete(session.path);
|
|
489
|
-
} else {
|
|
490
|
-
this.selectedPaths.add(session.path);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
this.inlineMessage = null;
|
|
494
|
-
this.requestRender();
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
private toggleAll(): void {
|
|
498
|
-
if (this.selectedPaths.size === this.sessions.length) {
|
|
499
|
-
this.selectedPaths.clear();
|
|
500
|
-
} else {
|
|
501
|
-
for (const session of this.sessions) {
|
|
502
|
-
this.selectedPaths.add(session.path);
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
this.inlineMessage = null;
|
|
507
|
-
this.requestRender();
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
private resolveMaxRenderRows(): number {
|
|
511
|
-
const terminalRows =
|
|
512
|
-
typeof process.stdout.rows === "number" &&
|
|
513
|
-
Number.isFinite(process.stdout.rows) &&
|
|
514
|
-
process.stdout.rows > 0
|
|
515
|
-
? Math.floor(process.stdout.rows)
|
|
516
|
-
: this.maxRenderRows;
|
|
517
|
-
|
|
518
|
-
return Math.max(12, Math.min(this.maxRenderRows, terminalRows));
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
private resolveViewportSize(maxRows: number, legendLineCount: number): number {
|
|
522
|
-
const inlineMessageRows = this.inlineMessage ? 2 : 0;
|
|
523
|
-
const reservedRows = 8 + legendLineCount + inlineMessageRows;
|
|
524
|
-
return Math.max(1, maxRows - reservedRows);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
private ensureCursorVisible(viewportSize: number): void {
|
|
528
|
-
if (this.sessions.length === 0) {
|
|
529
|
-
this.cursorIndex = 0;
|
|
530
|
-
this.scrollOffset = 0;
|
|
531
|
-
return;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
this.cursorIndex = clamp(this.cursorIndex, 0, this.sessions.length - 1);
|
|
535
|
-
|
|
536
|
-
if (this.cursorIndex < this.scrollOffset) {
|
|
537
|
-
this.scrollOffset = this.cursorIndex;
|
|
538
|
-
} else if (this.cursorIndex >= this.scrollOffset + viewportSize) {
|
|
539
|
-
this.scrollOffset = this.cursorIndex - viewportSize + 1;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
const maxOffset = Math.max(0, this.sessions.length - viewportSize);
|
|
543
|
-
this.scrollOffset = clamp(this.scrollOffset, 0, maxOffset);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
export async function showSessionCleanupPicker(
|
|
548
|
-
ctx: ExtensionCommandContext,
|
|
549
|
-
sessions: readonly SessionCleanupSession[],
|
|
550
|
-
): Promise<SessionSelectionResult> {
|
|
551
|
-
const selectedPaths = new Set<string>();
|
|
552
|
-
const overlayOptions = resolveOverlayOptions();
|
|
553
|
-
const config = loadSessionCleanupConfig();
|
|
554
|
-
const resolvedIcons = resolvePickerIcons(config.iconMode);
|
|
555
|
-
|
|
556
|
-
let finalResult: SessionSelectionResult | null = null;
|
|
557
|
-
|
|
558
|
-
await ctx.ui.custom<void>(
|
|
559
|
-
(tui, theme, _keybindings, done) => {
|
|
560
|
-
const picker = new SessionCleanupPicker(
|
|
561
|
-
sessions,
|
|
562
|
-
selectedPaths,
|
|
563
|
-
theme,
|
|
564
|
-
resolvedIcons.icons,
|
|
565
|
-
overlayOptions.maxHeight,
|
|
566
|
-
(result) => {
|
|
567
|
-
finalResult = result;
|
|
568
|
-
done();
|
|
569
|
-
},
|
|
570
|
-
() => {
|
|
571
|
-
tui.requestRender();
|
|
572
|
-
},
|
|
573
|
-
);
|
|
574
|
-
|
|
575
|
-
return picker;
|
|
576
|
-
},
|
|
577
|
-
{
|
|
578
|
-
overlay: true,
|
|
579
|
-
overlayOptions,
|
|
580
|
-
},
|
|
581
|
-
);
|
|
582
|
-
|
|
583
|
-
if (finalResult) {
|
|
584
|
-
return finalResult;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
return {
|
|
588
|
-
cancelled: true,
|
|
589
|
-
refreshRequested: false,
|
|
590
|
-
selectedPaths,
|
|
591
|
-
};
|
|
592
|
-
}
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { matchesKey, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
formatSessionAge,
|
|
6
|
+
getResponsibleAgentDisplayName,
|
|
7
|
+
getSessionTitle,
|
|
8
|
+
shortenPath,
|
|
9
|
+
} from "../session-format.js";
|
|
10
|
+
import { loadSessionCleanupConfig } from "../config-store.js";
|
|
11
|
+
import type { SessionSelectionResult, SessionCleanupSession } from "../types.js";
|
|
12
|
+
import { resolvePickerIcons, type PickerIcons } from "../ui/icons.js";
|
|
13
|
+
import { buildLegendContent } from "../ui/legend.js";
|
|
14
|
+
|
|
15
|
+
interface ThemeLike {
|
|
16
|
+
fg?: unknown;
|
|
17
|
+
bold?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PickerResultHandler {
|
|
21
|
+
(result: SessionSelectionResult): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface OverlayOptions {
|
|
25
|
+
anchor: "center";
|
|
26
|
+
width: number;
|
|
27
|
+
maxHeight: number;
|
|
28
|
+
margin: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ColumnLayout {
|
|
32
|
+
description: number;
|
|
33
|
+
agent: number;
|
|
34
|
+
age: number;
|
|
35
|
+
id: number;
|
|
36
|
+
path: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type CellAlignment = "start" | "end";
|
|
40
|
+
|
|
41
|
+
const TITLE_TEXT = "SESSION CLEANUP : BATCH DELETE";
|
|
42
|
+
const ROW_PREFIX_WIDTH = 6;
|
|
43
|
+
const AGE_COLUMN_WIDTH = 5;
|
|
44
|
+
const ID_COLUMN_WIDTH = 8;
|
|
45
|
+
const COLUMN_GAP = " ";
|
|
46
|
+
|
|
47
|
+
function clamp(value: number, min: number, max: number): number {
|
|
48
|
+
return Math.max(min, Math.min(max, value));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fitLine(text: string, width: number): string {
|
|
52
|
+
return truncateToWidth(text, Math.max(1, width), "…", true);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatTheme(theme: ThemeLike, color: string, text: string): string {
|
|
56
|
+
try {
|
|
57
|
+
if (typeof theme.fg === "function") {
|
|
58
|
+
const format = theme.fg as (resolvedColor: string, value: string) => string;
|
|
59
|
+
return format(color, text);
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// Fall through to plain text rendering.
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function formatBold(theme: ThemeLike, text: string): string {
|
|
69
|
+
try {
|
|
70
|
+
if (typeof theme.bold === "function") {
|
|
71
|
+
const format = theme.bold as (value: string) => string;
|
|
72
|
+
return format(text);
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Fall through to plain text rendering.
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return text;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function frameTop(width: number): string {
|
|
82
|
+
return `╭${"─".repeat(width)}╮`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function frameDivider(width: number): string {
|
|
86
|
+
return `├${"─".repeat(width)}┤`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function frameBottom(width: number): string {
|
|
90
|
+
return `╰${"─".repeat(width)}╯`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function frameLine(content: string, width: number): string {
|
|
94
|
+
const clipped = fitLine(content, width);
|
|
95
|
+
const padded = clipped.padEnd(width, " ");
|
|
96
|
+
return `│${padded}│`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resolveOverlayOptions(): OverlayOptions {
|
|
100
|
+
const terminalWidth =
|
|
101
|
+
typeof process.stdout.columns === "number" && Number.isFinite(process.stdout.columns)
|
|
102
|
+
? process.stdout.columns
|
|
103
|
+
: 120;
|
|
104
|
+
const terminalHeight =
|
|
105
|
+
typeof process.stdout.rows === "number" && Number.isFinite(process.stdout.rows)
|
|
106
|
+
? process.stdout.rows
|
|
107
|
+
: 36;
|
|
108
|
+
|
|
109
|
+
const margin = 1;
|
|
110
|
+
const availableWidth = Math.max(24, terminalWidth - margin * 2);
|
|
111
|
+
const preferredWidth =
|
|
112
|
+
terminalWidth >= 160 ? 118 : terminalWidth >= 140 ? 110 : terminalWidth >= 120 ? 100 : 92;
|
|
113
|
+
const width = Math.max(24, Math.min(preferredWidth, availableWidth));
|
|
114
|
+
|
|
115
|
+
const availableHeight = Math.max(12, terminalHeight - margin * 2);
|
|
116
|
+
const preferredHeight = Math.max(12, Math.floor(terminalHeight * 0.86));
|
|
117
|
+
const maxHeight = Math.min(preferredHeight, availableHeight);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
anchor: "center",
|
|
121
|
+
width,
|
|
122
|
+
maxHeight,
|
|
123
|
+
margin,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function alignCell(value: string, width: number, alignment: CellAlignment = "start"): string {
|
|
128
|
+
const clipped = fitLine(value, width);
|
|
129
|
+
const padding = Math.max(0, width - visibleWidth(clipped));
|
|
130
|
+
return alignment === "end"
|
|
131
|
+
? `${" ".repeat(padding)}${clipped}`
|
|
132
|
+
: `${clipped}${" ".repeat(padding)}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolveColumnLayout(contentWidth: number): ColumnLayout {
|
|
136
|
+
const gapTotal = COLUMN_GAP.length * 4;
|
|
137
|
+
const availableColumns = Math.max(5, contentWidth - ROW_PREFIX_WIDTH - gapTotal);
|
|
138
|
+
const fixedColumns = AGE_COLUMN_WIDTH + ID_COLUMN_WIDTH;
|
|
139
|
+
const flexibleColumns = Math.max(3, availableColumns - fixedColumns);
|
|
140
|
+
|
|
141
|
+
let description = 12;
|
|
142
|
+
let agent = 8;
|
|
143
|
+
let path = 12;
|
|
144
|
+
|
|
145
|
+
const minimumFlexibleColumns = description + agent + path;
|
|
146
|
+
|
|
147
|
+
if (flexibleColumns >= minimumFlexibleColumns) {
|
|
148
|
+
const extra = flexibleColumns - minimumFlexibleColumns;
|
|
149
|
+
description += Math.floor(extra * 0.45);
|
|
150
|
+
path += Math.floor(extra * 0.4);
|
|
151
|
+
agent += Math.min(6, extra - (description - 12) - (path - 12));
|
|
152
|
+
path += flexibleColumns - (description + agent + path);
|
|
153
|
+
} else {
|
|
154
|
+
agent = Math.max(4, Math.floor(flexibleColumns * 0.18));
|
|
155
|
+
description = Math.max(6, Math.floor(flexibleColumns * 0.42));
|
|
156
|
+
path = Math.max(1, flexibleColumns - agent - description);
|
|
157
|
+
|
|
158
|
+
if (path < 6 && description > 6) {
|
|
159
|
+
const shift = Math.min(6 - path, description - 6);
|
|
160
|
+
description -= shift;
|
|
161
|
+
path += shift;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (path < 6 && agent > 4) {
|
|
165
|
+
const shift = Math.min(6 - path, agent - 4);
|
|
166
|
+
agent -= shift;
|
|
167
|
+
path += shift;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
path = Math.max(1, flexibleColumns - agent - description);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
description,
|
|
175
|
+
agent,
|
|
176
|
+
age: AGE_COLUMN_WIDTH,
|
|
177
|
+
id: ID_COLUMN_WIDTH,
|
|
178
|
+
path,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function buildStatsLine(
|
|
183
|
+
contentWidth: number,
|
|
184
|
+
totalSessions: number,
|
|
185
|
+
selectedCount: number,
|
|
186
|
+
start: number,
|
|
187
|
+
end: number,
|
|
188
|
+
): string {
|
|
189
|
+
const segmentGap = " ";
|
|
190
|
+
const totalGapWidth = segmentGap.length * 2;
|
|
191
|
+
const baseSegmentWidth = Math.max(1, Math.floor((contentWidth - totalGapWidth) / 3));
|
|
192
|
+
const remainingWidth = Math.max(0, contentWidth - totalGapWidth - baseSegmentWidth * 3);
|
|
193
|
+
const segmentWidths = [
|
|
194
|
+
baseSegmentWidth + remainingWidth,
|
|
195
|
+
baseSegmentWidth,
|
|
196
|
+
baseSegmentWidth,
|
|
197
|
+
] as const;
|
|
198
|
+
const visibleRange = totalSessions === 0 ? "0-0/0" : `${start + 1}-${end}/${totalSessions}`;
|
|
199
|
+
const segments = [
|
|
200
|
+
alignCell(`TOTAL: ${totalSessions}`, segmentWidths[0]),
|
|
201
|
+
alignCell(`SELECTED: ${selectedCount}`, segmentWidths[1]),
|
|
202
|
+
alignCell(`VISIBLE: ${visibleRange}`, segmentWidths[2]),
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
return segments.join(segmentGap);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function buildColumnLine(
|
|
209
|
+
layout: ColumnLayout,
|
|
210
|
+
values: {
|
|
211
|
+
description: string;
|
|
212
|
+
agent: string;
|
|
213
|
+
age: string;
|
|
214
|
+
id: string;
|
|
215
|
+
path: string;
|
|
216
|
+
},
|
|
217
|
+
): string {
|
|
218
|
+
return [
|
|
219
|
+
alignCell(values.description, layout.description),
|
|
220
|
+
alignCell(values.agent, layout.agent),
|
|
221
|
+
alignCell(values.age, layout.age, "end"),
|
|
222
|
+
alignCell(values.id, layout.id),
|
|
223
|
+
alignCell(values.path, layout.path),
|
|
224
|
+
].join(COLUMN_GAP);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function buildColumnHeaderLine(layout: ColumnLayout): string {
|
|
228
|
+
return `${" ".repeat(ROW_PREFIX_WIDTH)}${buildColumnLine(layout, {
|
|
229
|
+
description: "TASK DESCRIPTION",
|
|
230
|
+
agent: "AGENT",
|
|
231
|
+
age: "AGE",
|
|
232
|
+
id: "ID",
|
|
233
|
+
path: "PATH",
|
|
234
|
+
})}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function buildSessionRow(
|
|
238
|
+
session: SessionCleanupSession,
|
|
239
|
+
selected: boolean,
|
|
240
|
+
focused: boolean,
|
|
241
|
+
layout: ColumnLayout,
|
|
242
|
+
): string {
|
|
243
|
+
const prefix = `${focused ? ">" : " "} ${selected ? "[x]" : "[ ]"} `;
|
|
244
|
+
return `${prefix}${buildColumnLine(layout, {
|
|
245
|
+
description: getSessionTitle(session),
|
|
246
|
+
agent: `@${getResponsibleAgentDisplayName(session)}`,
|
|
247
|
+
age: formatSessionAge(session.modified),
|
|
248
|
+
id: session.id.slice(0, 8),
|
|
249
|
+
path: shortenPath(session.cwd || "(unknown cwd)"),
|
|
250
|
+
})}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
class SessionCleanupPicker implements Component {
|
|
254
|
+
private cursorIndex = 0;
|
|
255
|
+
|
|
256
|
+
private scrollOffset = 0;
|
|
257
|
+
|
|
258
|
+
private inlineMessage: string | null = null;
|
|
259
|
+
|
|
260
|
+
private lastViewportSize = 10;
|
|
261
|
+
|
|
262
|
+
private readonly icons: PickerIcons;
|
|
263
|
+
|
|
264
|
+
constructor(
|
|
265
|
+
private readonly sessions: readonly SessionCleanupSession[],
|
|
266
|
+
private readonly selectedPaths: Set<string>,
|
|
267
|
+
private readonly theme: ThemeLike,
|
|
268
|
+
initialIcons: PickerIcons,
|
|
269
|
+
private readonly maxRenderRows: number,
|
|
270
|
+
private readonly onFinish: PickerResultHandler,
|
|
271
|
+
private readonly requestRender: () => void,
|
|
272
|
+
) {
|
|
273
|
+
this.icons = initialIcons;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
invalidate(): void {
|
|
277
|
+
// Rendering is state driven.
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
render(width: number): string[] {
|
|
281
|
+
const safeWidth = Math.max(24, Math.floor(width));
|
|
282
|
+
const frameInnerWidth = Math.max(22, safeWidth - 2);
|
|
283
|
+
const maxRows = this.resolveMaxRenderRows();
|
|
284
|
+
const legend = buildLegendContent(this.icons, frameInnerWidth);
|
|
285
|
+
const viewportSize = this.resolveViewportSize(maxRows, legend.lines.length);
|
|
286
|
+
const columns = resolveColumnLayout(frameInnerWidth);
|
|
287
|
+
|
|
288
|
+
this.lastViewportSize = viewportSize;
|
|
289
|
+
this.ensureCursorVisible(viewportSize);
|
|
290
|
+
|
|
291
|
+
const start = this.scrollOffset;
|
|
292
|
+
const end = Math.min(this.sessions.length, start + viewportSize);
|
|
293
|
+
|
|
294
|
+
const lines: string[] = [];
|
|
295
|
+
lines.push(frameTop(frameInnerWidth));
|
|
296
|
+
lines.push(
|
|
297
|
+
formatTheme(
|
|
298
|
+
this.theme,
|
|
299
|
+
"accent",
|
|
300
|
+
formatBold(this.theme, frameLine(` ${TITLE_TEXT}`, frameInnerWidth)),
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
lines.push(
|
|
304
|
+
formatTheme(
|
|
305
|
+
this.theme,
|
|
306
|
+
"dim",
|
|
307
|
+
frameLine(
|
|
308
|
+
buildStatsLine(
|
|
309
|
+
frameInnerWidth,
|
|
310
|
+
this.sessions.length,
|
|
311
|
+
this.selectedPaths.size,
|
|
312
|
+
start,
|
|
313
|
+
end,
|
|
314
|
+
),
|
|
315
|
+
frameInnerWidth,
|
|
316
|
+
),
|
|
317
|
+
),
|
|
318
|
+
);
|
|
319
|
+
lines.push(frameDivider(frameInnerWidth));
|
|
320
|
+
lines.push(
|
|
321
|
+
formatTheme(
|
|
322
|
+
this.theme,
|
|
323
|
+
"accent",
|
|
324
|
+
formatBold(this.theme, frameLine(buildColumnHeaderLine(columns), frameInnerWidth)),
|
|
325
|
+
),
|
|
326
|
+
);
|
|
327
|
+
lines.push(frameDivider(frameInnerWidth));
|
|
328
|
+
|
|
329
|
+
if (this.sessions.length === 0) {
|
|
330
|
+
lines.push(
|
|
331
|
+
formatTheme(
|
|
332
|
+
this.theme,
|
|
333
|
+
"dim",
|
|
334
|
+
frameLine(
|
|
335
|
+
`${" ".repeat(ROW_PREFIX_WIDTH)}${fitLine(
|
|
336
|
+
"No sessions found for this scope.",
|
|
337
|
+
frameInnerWidth - ROW_PREFIX_WIDTH,
|
|
338
|
+
)}`,
|
|
339
|
+
frameInnerWidth,
|
|
340
|
+
),
|
|
341
|
+
),
|
|
342
|
+
);
|
|
343
|
+
} else {
|
|
344
|
+
for (let index = start; index < end; index += 1) {
|
|
345
|
+
const session = this.sessions[index];
|
|
346
|
+
const rowLine = frameLine(
|
|
347
|
+
buildSessionRow(
|
|
348
|
+
session,
|
|
349
|
+
this.selectedPaths.has(session.path),
|
|
350
|
+
index === this.cursorIndex,
|
|
351
|
+
columns,
|
|
352
|
+
),
|
|
353
|
+
frameInnerWidth,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
lines.push(
|
|
357
|
+
index === this.cursorIndex
|
|
358
|
+
? formatTheme(this.theme, "accent", formatBold(this.theme, rowLine))
|
|
359
|
+
: rowLine,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (this.inlineMessage) {
|
|
365
|
+
lines.push(frameDivider(frameInnerWidth));
|
|
366
|
+
lines.push(
|
|
367
|
+
formatTheme(
|
|
368
|
+
this.theme,
|
|
369
|
+
"warning",
|
|
370
|
+
frameLine(` ${this.inlineMessage}`, frameInnerWidth),
|
|
371
|
+
),
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
lines.push(frameDivider(frameInnerWidth));
|
|
376
|
+
for (const legendLine of legend.lines) {
|
|
377
|
+
lines.push(formatTheme(this.theme, "dim", frameLine(` ${legendLine}`, frameInnerWidth)));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
lines.push(frameBottom(frameInnerWidth));
|
|
381
|
+
return lines;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
handleInput(data: string): void {
|
|
385
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || matchesKey(data, "q")) {
|
|
386
|
+
this.finish({
|
|
387
|
+
cancelled: true,
|
|
388
|
+
refreshRequested: false,
|
|
389
|
+
selectedPaths: new Set(this.selectedPaths),
|
|
390
|
+
});
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
395
|
+
this.moveCursor(-1);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
400
|
+
this.moveCursor(1);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (matchesKey(data, "pageUp")) {
|
|
405
|
+
this.moveCursor(-Math.max(1, this.lastViewportSize - 1));
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (matchesKey(data, "pageDown")) {
|
|
410
|
+
this.moveCursor(Math.max(1, this.lastViewportSize - 1));
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (matchesKey(data, "home")) {
|
|
415
|
+
this.cursorIndex = 0;
|
|
416
|
+
this.inlineMessage = null;
|
|
417
|
+
this.ensureCursorVisible(this.lastViewportSize);
|
|
418
|
+
this.requestRender();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (matchesKey(data, "end")) {
|
|
423
|
+
this.cursorIndex = Math.max(0, this.sessions.length - 1);
|
|
424
|
+
this.inlineMessage = null;
|
|
425
|
+
this.ensureCursorVisible(this.lastViewportSize);
|
|
426
|
+
this.requestRender();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (matchesKey(data, "space")) {
|
|
431
|
+
this.toggleCurrent();
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (matchesKey(data, "a")) {
|
|
436
|
+
this.toggleAll();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (matchesKey(data, "r")) {
|
|
441
|
+
this.finish({
|
|
442
|
+
cancelled: false,
|
|
443
|
+
refreshRequested: true,
|
|
444
|
+
selectedPaths: new Set(this.selectedPaths),
|
|
445
|
+
});
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (matchesKey(data, "return")) {
|
|
450
|
+
if (this.selectedPaths.size === 0) {
|
|
451
|
+
this.inlineMessage = "No sessions selected. Toggle at least one session first.";
|
|
452
|
+
this.requestRender();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
this.finish({
|
|
457
|
+
cancelled: false,
|
|
458
|
+
refreshRequested: false,
|
|
459
|
+
selectedPaths: new Set(this.selectedPaths),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private finish(result: SessionSelectionResult): void {
|
|
465
|
+
this.onFinish(result);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private moveCursor(delta: number): void {
|
|
469
|
+
if (this.sessions.length === 0) {
|
|
470
|
+
this.cursorIndex = 0;
|
|
471
|
+
this.scrollOffset = 0;
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
this.cursorIndex = clamp(this.cursorIndex + delta, 0, this.sessions.length - 1);
|
|
476
|
+
this.inlineMessage = null;
|
|
477
|
+
this.ensureCursorVisible(this.lastViewportSize);
|
|
478
|
+
this.requestRender();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private toggleCurrent(): void {
|
|
482
|
+
const session = this.sessions[this.cursorIndex];
|
|
483
|
+
if (!session) {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (this.selectedPaths.has(session.path)) {
|
|
488
|
+
this.selectedPaths.delete(session.path);
|
|
489
|
+
} else {
|
|
490
|
+
this.selectedPaths.add(session.path);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
this.inlineMessage = null;
|
|
494
|
+
this.requestRender();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private toggleAll(): void {
|
|
498
|
+
if (this.selectedPaths.size === this.sessions.length) {
|
|
499
|
+
this.selectedPaths.clear();
|
|
500
|
+
} else {
|
|
501
|
+
for (const session of this.sessions) {
|
|
502
|
+
this.selectedPaths.add(session.path);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
this.inlineMessage = null;
|
|
507
|
+
this.requestRender();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private resolveMaxRenderRows(): number {
|
|
511
|
+
const terminalRows =
|
|
512
|
+
typeof process.stdout.rows === "number" &&
|
|
513
|
+
Number.isFinite(process.stdout.rows) &&
|
|
514
|
+
process.stdout.rows > 0
|
|
515
|
+
? Math.floor(process.stdout.rows)
|
|
516
|
+
: this.maxRenderRows;
|
|
517
|
+
|
|
518
|
+
return Math.max(12, Math.min(this.maxRenderRows, terminalRows));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private resolveViewportSize(maxRows: number, legendLineCount: number): number {
|
|
522
|
+
const inlineMessageRows = this.inlineMessage ? 2 : 0;
|
|
523
|
+
const reservedRows = 8 + legendLineCount + inlineMessageRows;
|
|
524
|
+
return Math.max(1, maxRows - reservedRows);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private ensureCursorVisible(viewportSize: number): void {
|
|
528
|
+
if (this.sessions.length === 0) {
|
|
529
|
+
this.cursorIndex = 0;
|
|
530
|
+
this.scrollOffset = 0;
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
this.cursorIndex = clamp(this.cursorIndex, 0, this.sessions.length - 1);
|
|
535
|
+
|
|
536
|
+
if (this.cursorIndex < this.scrollOffset) {
|
|
537
|
+
this.scrollOffset = this.cursorIndex;
|
|
538
|
+
} else if (this.cursorIndex >= this.scrollOffset + viewportSize) {
|
|
539
|
+
this.scrollOffset = this.cursorIndex - viewportSize + 1;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const maxOffset = Math.max(0, this.sessions.length - viewportSize);
|
|
543
|
+
this.scrollOffset = clamp(this.scrollOffset, 0, maxOffset);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export async function showSessionCleanupPicker(
|
|
548
|
+
ctx: ExtensionCommandContext,
|
|
549
|
+
sessions: readonly SessionCleanupSession[],
|
|
550
|
+
): Promise<SessionSelectionResult> {
|
|
551
|
+
const selectedPaths = new Set<string>();
|
|
552
|
+
const overlayOptions = resolveOverlayOptions();
|
|
553
|
+
const config = loadSessionCleanupConfig();
|
|
554
|
+
const resolvedIcons = resolvePickerIcons(config.iconMode);
|
|
555
|
+
|
|
556
|
+
let finalResult: SessionSelectionResult | null = null;
|
|
557
|
+
|
|
558
|
+
await ctx.ui.custom<void>(
|
|
559
|
+
(tui, theme, _keybindings, done) => {
|
|
560
|
+
const picker = new SessionCleanupPicker(
|
|
561
|
+
sessions,
|
|
562
|
+
selectedPaths,
|
|
563
|
+
theme,
|
|
564
|
+
resolvedIcons.icons,
|
|
565
|
+
overlayOptions.maxHeight,
|
|
566
|
+
(result) => {
|
|
567
|
+
finalResult = result;
|
|
568
|
+
done();
|
|
569
|
+
},
|
|
570
|
+
() => {
|
|
571
|
+
tui.requestRender();
|
|
572
|
+
},
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
return picker;
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
overlay: true,
|
|
579
|
+
overlayOptions,
|
|
580
|
+
},
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
if (finalResult) {
|
|
584
|
+
return finalResult;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
cancelled: true,
|
|
589
|
+
refreshRequested: false,
|
|
590
|
+
selectedPaths,
|
|
591
|
+
};
|
|
592
|
+
}
|