pi-session-cleanup 1.0.0 → 1.1.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 +7 -0
- package/README.md +20 -2
- package/package.json +66 -66
- package/src/agent-target.ts +361 -0
- package/src/index.ts +11 -2
- package/src/session-agent.ts +25 -0
- package/src/session-entry.ts +23 -0
- package/src/session-nix-command.ts +270 -25
- package/src/session-quit-shutdown.ts +40 -0
- package/src/tui/agent-target-picker.ts +306 -0
- package/src/types-shims.d.ts +18 -0
|
@@ -3,10 +3,52 @@ import { basename } from "node:path";
|
|
|
3
3
|
import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
4
4
|
|
|
5
5
|
import { SESSION_NIX_COMMAND } from "./constants.js";
|
|
6
|
+
import {
|
|
7
|
+
resolveTargetAgentForSessionNix,
|
|
8
|
+
type SelectableAgent,
|
|
9
|
+
} from "./agent-target.js";
|
|
10
|
+
import { extractPersistedActiveAgentNameFromEntries } from "./session-agent.js";
|
|
6
11
|
import { deleteSessionFile } from "./session-delete.js";
|
|
12
|
+
import { appendActiveAgentSessionEntry } from "./session-entry.js";
|
|
13
|
+
import {
|
|
14
|
+
clearScheduledSessionDeletionForQuit,
|
|
15
|
+
scheduleSessionDeletionForQuit,
|
|
16
|
+
} from "./session-quit-shutdown.js";
|
|
17
|
+
|
|
18
|
+
const ARG_COMPLETIONS = [
|
|
19
|
+
{
|
|
20
|
+
value: "quit",
|
|
21
|
+
label: "quit",
|
|
22
|
+
description: "Delete the current session and quit Pi immediately",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
value: "agent",
|
|
26
|
+
label: "agent",
|
|
27
|
+
description: "Start a fresh session with a selected target agent",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
value: "help",
|
|
31
|
+
label: "help",
|
|
32
|
+
description: "Show usage",
|
|
33
|
+
},
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
type NixMode = "fresh" | "quit" | "agent";
|
|
37
|
+
|
|
38
|
+
interface ParsedArgs {
|
|
39
|
+
help: boolean;
|
|
40
|
+
mode: NixMode;
|
|
41
|
+
targetAgentName?: string;
|
|
42
|
+
error?: string;
|
|
43
|
+
}
|
|
7
44
|
|
|
8
45
|
function usage(): string {
|
|
9
|
-
return
|
|
46
|
+
return [
|
|
47
|
+
`Usage: /${SESSION_NIX_COMMAND}`,
|
|
48
|
+
` /${SESSION_NIX_COMMAND} quit`,
|
|
49
|
+
` /${SESSION_NIX_COMMAND} agent [name]`,
|
|
50
|
+
` /${SESSION_NIX_COMMAND} help`,
|
|
51
|
+
].join("\n");
|
|
10
52
|
}
|
|
11
53
|
|
|
12
54
|
function getSessionLabel(sessionPath: string): string {
|
|
@@ -14,7 +56,48 @@ function getSessionLabel(sessionPath: string): string {
|
|
|
14
56
|
return fileName.length > 0 ? fileName : sessionPath;
|
|
15
57
|
}
|
|
16
58
|
|
|
17
|
-
function
|
|
59
|
+
function parseArgs(args: string): ParsedArgs {
|
|
60
|
+
const trimmed = args.trim();
|
|
61
|
+
if (!trimmed) {
|
|
62
|
+
return { help: false, mode: "fresh" };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const parts = trimmed.split(/\s+/);
|
|
66
|
+
const command = parts[0]?.toLowerCase();
|
|
67
|
+
if (!command) {
|
|
68
|
+
return { help: false, mode: "fresh" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (command === "help") {
|
|
72
|
+
return { help: true, mode: "fresh" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (command === "quit" || command === "exit") {
|
|
76
|
+
return parts.length === 1
|
|
77
|
+
? { help: false, mode: "quit" }
|
|
78
|
+
: {
|
|
79
|
+
help: false,
|
|
80
|
+
mode: "quit",
|
|
81
|
+
error: `/${SESSION_NIX_COMMAND} quit does not accept additional arguments.`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (command === "agent") {
|
|
86
|
+
return {
|
|
87
|
+
help: false,
|
|
88
|
+
mode: "agent",
|
|
89
|
+
targetAgentName: parts.length > 1 ? parts.slice(1).join(" ") : undefined,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
help: false,
|
|
95
|
+
mode: "fresh",
|
|
96
|
+
error: `Unknown argument: ${trimmed}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildFreshConfirmationMessage(previousSessionFile: string | undefined): string {
|
|
18
101
|
if (!previousSessionFile) {
|
|
19
102
|
return "This will start a new session. The current session is not persisted yet, so there is no session file to delete.";
|
|
20
103
|
}
|
|
@@ -28,18 +111,153 @@ function buildConfirmationMessage(previousSessionFile: string | undefined): stri
|
|
|
28
111
|
].join("\n");
|
|
29
112
|
}
|
|
30
113
|
|
|
114
|
+
function buildQuitConfirmationMessage(previousSessionFile: string | undefined): string {
|
|
115
|
+
if (!previousSessionFile) {
|
|
116
|
+
return "This will quit Pi immediately. The current session is not persisted yet, so there is no session file to delete first.";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return [
|
|
120
|
+
"This will permanently remove the current session and then quit Pi immediately.",
|
|
121
|
+
"",
|
|
122
|
+
`Current session: ${getSessionLabel(previousSessionFile)}`,
|
|
123
|
+
"",
|
|
124
|
+
"Pi will try moving it to trash first, then permanently delete it if trash is unavailable.",
|
|
125
|
+
].join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildAgentConfirmationMessage(
|
|
129
|
+
previousSessionFile: string | undefined,
|
|
130
|
+
targetAgent: SelectableAgent,
|
|
131
|
+
): string {
|
|
132
|
+
if (!previousSessionFile) {
|
|
133
|
+
return [
|
|
134
|
+
`This will start a new session with agent '${targetAgent.name}'.`,
|
|
135
|
+
"",
|
|
136
|
+
"The current session is not persisted yet, so there is no session file to delete.",
|
|
137
|
+
].join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return [
|
|
141
|
+
`This will start a new session with agent '${targetAgent.name}' and remove the current session from history.`,
|
|
142
|
+
"",
|
|
143
|
+
`Current session: ${getSessionLabel(previousSessionFile)}`,
|
|
144
|
+
`Target agent: ${targetAgent.name}`,
|
|
145
|
+
"",
|
|
146
|
+
"Pi will try moving the old session to trash first, then permanently delete it if trash is unavailable.",
|
|
147
|
+
].join("\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function getSessionNixArgumentCompletions(
|
|
151
|
+
argumentPrefix: string,
|
|
152
|
+
): Array<{ value: string; label: string; description?: string }> | null {
|
|
153
|
+
const normalizedPrefix = argumentPrefix.trim().toLowerCase();
|
|
154
|
+
if (!normalizedPrefix) {
|
|
155
|
+
return [...ARG_COMPLETIONS];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const matched = ARG_COMPLETIONS.filter((item) => item.value.startsWith(normalizedPrefix));
|
|
159
|
+
if (matched.length === 0) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return matched.map((item) => ({ ...item }));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function deletePreviousSessionAfterSwitch(previousSessionFile: string | undefined): Promise<void> {
|
|
167
|
+
if (!previousSessionFile) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
await deleteSessionFile(previousSessionFile);
|
|
173
|
+
} catch {
|
|
174
|
+
// The new session is already active. Avoid surfacing stale-context errors.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function startFreshSession(
|
|
179
|
+
ctx: ExtensionCommandContext,
|
|
180
|
+
previousSessionFile: string | undefined,
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
try {
|
|
183
|
+
const newSessionResult = await ctx.newSession();
|
|
184
|
+
if (newSessionResult.cancelled) {
|
|
185
|
+
ctx.ui.notify("New session cancelled.", "info");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
} catch (error) {
|
|
189
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
190
|
+
ctx.ui.notify(`Failed to start a new session: ${message}`, "error");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await deletePreviousSessionAfterSwitch(previousSessionFile);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function startAgentTargetSession(
|
|
198
|
+
ctx: ExtensionCommandContext,
|
|
199
|
+
previousSessionFile: string | undefined,
|
|
200
|
+
targetAgent: SelectableAgent,
|
|
201
|
+
): Promise<void> {
|
|
202
|
+
try {
|
|
203
|
+
const newSessionResult = await ctx.newSession({
|
|
204
|
+
setup: async (sessionManager) => {
|
|
205
|
+
appendActiveAgentSessionEntry(sessionManager, targetAgent.name);
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (newSessionResult.cancelled) {
|
|
210
|
+
ctx.ui.notify("New session cancelled.", "info");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
} catch (error) {
|
|
214
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
215
|
+
ctx.ui.notify(`Failed to start a new session for agent '${targetAgent.name}': ${message}`, "error");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await deletePreviousSessionAfterSwitch(previousSessionFile);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function requestGracefulQuit(
|
|
223
|
+
ctx: ExtensionCommandContext,
|
|
224
|
+
previousSessionFile: string | undefined,
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
const shutdown = (ctx as ExtensionCommandContext & {
|
|
227
|
+
shutdown?: () => Promise<void> | void;
|
|
228
|
+
}).shutdown;
|
|
229
|
+
|
|
230
|
+
if (typeof shutdown !== "function") {
|
|
231
|
+
ctx.ui.notify(
|
|
232
|
+
"Graceful shutdown is unavailable in this Pi build. Update Pi to use /nix quit safely.",
|
|
233
|
+
"warning",
|
|
234
|
+
);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
scheduleSessionDeletionForQuit(previousSessionFile);
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
await Promise.resolve(shutdown.call(ctx));
|
|
242
|
+
} catch (error) {
|
|
243
|
+
clearScheduledSessionDeletionForQuit();
|
|
244
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
245
|
+
ctx.ui.notify(`Failed to quit Pi gracefully: ${message}`, "error");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
31
249
|
export async function handleSessionNixCommand(
|
|
32
250
|
args: string,
|
|
33
251
|
ctx: ExtensionCommandContext,
|
|
34
252
|
): Promise<void> {
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
253
|
+
const parsed = parseArgs(args);
|
|
254
|
+
if (parsed.help) {
|
|
37
255
|
ctx.ui.notify(usage(), "info");
|
|
38
256
|
return;
|
|
39
257
|
}
|
|
40
258
|
|
|
41
|
-
if (
|
|
42
|
-
ctx.ui.notify(
|
|
259
|
+
if (parsed.error) {
|
|
260
|
+
ctx.ui.notify(`${parsed.error}\n${usage()}`, "warning");
|
|
43
261
|
return;
|
|
44
262
|
}
|
|
45
263
|
|
|
@@ -49,36 +267,63 @@ export async function handleSessionNixCommand(
|
|
|
49
267
|
}
|
|
50
268
|
|
|
51
269
|
const previousSessionFile = ctx.sessionManager.getSessionFile();
|
|
52
|
-
const confirmed = await ctx.ui.confirm(
|
|
53
|
-
"Start fresh and delete current session",
|
|
54
|
-
buildConfirmationMessage(previousSessionFile),
|
|
55
|
-
);
|
|
56
270
|
|
|
57
|
-
if (
|
|
58
|
-
ctx.ui.
|
|
271
|
+
if (parsed.mode === "fresh") {
|
|
272
|
+
const confirmed = await ctx.ui.confirm(
|
|
273
|
+
"Start fresh and delete current session",
|
|
274
|
+
buildFreshConfirmationMessage(previousSessionFile),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
if (!confirmed) {
|
|
278
|
+
ctx.ui.notify(`/${SESSION_NIX_COMMAND} cancelled.`, "info");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
await startFreshSession(ctx, previousSessionFile);
|
|
59
283
|
return;
|
|
60
284
|
}
|
|
61
285
|
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
286
|
+
if (parsed.mode === "quit") {
|
|
287
|
+
const confirmed = await ctx.ui.confirm(
|
|
288
|
+
"Delete current session and quit Pi",
|
|
289
|
+
buildQuitConfirmationMessage(previousSessionFile),
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
if (!confirmed) {
|
|
293
|
+
ctx.ui.notify(`/${SESSION_NIX_COMMAND} quit cancelled.`, "info");
|
|
66
294
|
return;
|
|
67
295
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
ctx.ui.notify(`Failed to start a new session: ${message}`, "error");
|
|
296
|
+
|
|
297
|
+
await requestGracefulQuit(ctx, previousSessionFile);
|
|
71
298
|
return;
|
|
72
299
|
}
|
|
73
300
|
|
|
74
|
-
|
|
75
|
-
|
|
301
|
+
const currentAgentName =
|
|
302
|
+
extractPersistedActiveAgentNameFromEntries(ctx.sessionManager.getEntries()) ?? null;
|
|
303
|
+
const targetAgent = await resolveTargetAgentForSessionNix(
|
|
304
|
+
ctx,
|
|
305
|
+
parsed.targetAgentName,
|
|
306
|
+
currentAgentName,
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
if (targetAgent === null) {
|
|
310
|
+
ctx.ui.notify(`/${SESSION_NIX_COMMAND} agent cancelled.`, "info");
|
|
76
311
|
return;
|
|
77
312
|
}
|
|
78
313
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
} catch {
|
|
82
|
-
// Deletion failure is non-critical; the session was already replaced.
|
|
314
|
+
if (!targetAgent) {
|
|
315
|
+
return;
|
|
83
316
|
}
|
|
317
|
+
|
|
318
|
+
const confirmed = await ctx.ui.confirm(
|
|
319
|
+
`Start a new '${targetAgent.name}' session`,
|
|
320
|
+
buildAgentConfirmationMessage(previousSessionFile, targetAgent),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
if (!confirmed) {
|
|
324
|
+
ctx.ui.notify(`/${SESSION_NIX_COMMAND} agent cancelled.`, "info");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
await startAgentTargetSession(ctx, previousSessionFile, targetAgent);
|
|
84
329
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { deleteSessionFile } from "./session-delete.js";
|
|
4
|
+
|
|
5
|
+
let pendingSessionFileForQuitDeletion: string | undefined;
|
|
6
|
+
|
|
7
|
+
export function scheduleSessionDeletionForQuit(sessionFile: string | undefined): void {
|
|
8
|
+
pendingSessionFileForQuitDeletion = sessionFile;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function clearScheduledSessionDeletionForQuit(): void {
|
|
12
|
+
pendingSessionFileForQuitDeletion = undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function flushScheduledSessionDeletionForQuit(
|
|
16
|
+
ctx: ExtensionContext,
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
const sessionFile = pendingSessionFileForQuitDeletion;
|
|
19
|
+
pendingSessionFileForQuitDeletion = undefined;
|
|
20
|
+
|
|
21
|
+
if (!sessionFile) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const deleteResult = await deleteSessionFile(sessionFile);
|
|
27
|
+
if (!deleteResult.ok) {
|
|
28
|
+
ctx.ui.notify(
|
|
29
|
+
`Failed to delete the current session during shutdown: ${deleteResult.error}`,
|
|
30
|
+
"warning",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
} catch (error) {
|
|
34
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
35
|
+
ctx.ui.notify(
|
|
36
|
+
`Failed to delete the current session during shutdown: ${message}`,
|
|
37
|
+
"warning",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { matchesKey, truncateToWidth, visibleWidth, type Component } from "@mariozechner/pi-tui";
|
|
3
|
+
|
|
4
|
+
import type { SelectableAgent } from "../agent-target.js";
|
|
5
|
+
|
|
6
|
+
interface ThemeLike {
|
|
7
|
+
fg?: unknown;
|
|
8
|
+
bold?: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface OverlayOptions {
|
|
12
|
+
anchor: "center";
|
|
13
|
+
width: number;
|
|
14
|
+
maxHeight: number;
|
|
15
|
+
margin: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TITLE_TEXT = "SELECT TARGET AGENT";
|
|
19
|
+
|
|
20
|
+
function clamp(value: number, min: number, max: number): number {
|
|
21
|
+
return Math.max(min, Math.min(max, value));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fitLine(text: string, width: number): string {
|
|
25
|
+
return truncateToWidth(text, Math.max(1, width), "…", true);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatTheme(theme: ThemeLike, color: string, text: string): string {
|
|
29
|
+
try {
|
|
30
|
+
if (typeof theme.fg === "function") {
|
|
31
|
+
const format = theme.fg as (resolvedColor: string, value: string) => string;
|
|
32
|
+
return format(color, text);
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// Fall back to plain text.
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatBold(theme: ThemeLike, text: string): string {
|
|
42
|
+
try {
|
|
43
|
+
if (typeof theme.bold === "function") {
|
|
44
|
+
const format = theme.bold as (value: string) => string;
|
|
45
|
+
return format(text);
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// Fall back to plain text.
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return text;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function frameTop(width: number): string {
|
|
55
|
+
return `╭${"─".repeat(width)}╮`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function frameDivider(width: number): string {
|
|
59
|
+
return `├${"─".repeat(width)}┤`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function frameBottom(width: number): string {
|
|
63
|
+
return `╰${"─".repeat(width)}╯`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function frameLine(content: string, width: number): string {
|
|
67
|
+
const clipped = fitLine(content, width);
|
|
68
|
+
const padded = clipped.padEnd(width, " ");
|
|
69
|
+
return `│${padded}│`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolveOverlayOptions(): OverlayOptions {
|
|
73
|
+
const terminalWidth =
|
|
74
|
+
typeof process.stdout.columns === "number" && Number.isFinite(process.stdout.columns)
|
|
75
|
+
? process.stdout.columns
|
|
76
|
+
: 120;
|
|
77
|
+
const terminalHeight =
|
|
78
|
+
typeof process.stdout.rows === "number" && Number.isFinite(process.stdout.rows)
|
|
79
|
+
? process.stdout.rows
|
|
80
|
+
: 36;
|
|
81
|
+
|
|
82
|
+
const margin = 1;
|
|
83
|
+
const availableWidth = Math.max(24, terminalWidth - margin * 2);
|
|
84
|
+
const preferredWidth = terminalWidth >= 140 ? 96 : terminalWidth >= 120 ? 88 : 80;
|
|
85
|
+
const width = Math.max(24, Math.min(preferredWidth, availableWidth));
|
|
86
|
+
|
|
87
|
+
const availableHeight = Math.max(10, terminalHeight - margin * 2);
|
|
88
|
+
const preferredHeight = Math.max(10, Math.floor(terminalHeight * 0.72));
|
|
89
|
+
const maxHeight = Math.min(preferredHeight, availableHeight);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
anchor: "center",
|
|
93
|
+
width,
|
|
94
|
+
maxHeight,
|
|
95
|
+
margin,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatModeBadge(agent: SelectableAgent): string {
|
|
100
|
+
return `[${agent.mode ?? "primary"}]`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildAgentRow(
|
|
104
|
+
agent: SelectableAgent,
|
|
105
|
+
isCurrent: boolean,
|
|
106
|
+
isSelected: boolean,
|
|
107
|
+
width: number,
|
|
108
|
+
): string {
|
|
109
|
+
const prefix = isSelected ? "❯ " : " ";
|
|
110
|
+
const currentMarker = isCurrent ? "●" : "○";
|
|
111
|
+
const leading = `${prefix}${currentMarker} ${agent.name} ${formatModeBadge(agent)} — `;
|
|
112
|
+
const availableDescriptionWidth = Math.max(1, width - visibleWidth(leading));
|
|
113
|
+
return `${leading}${fitLine(agent.description, availableDescriptionWidth)}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
class AgentTargetPicker implements Component {
|
|
117
|
+
private cursorIndex: number;
|
|
118
|
+
private scrollOffset = 0;
|
|
119
|
+
private lastViewportSize = 1;
|
|
120
|
+
|
|
121
|
+
constructor(
|
|
122
|
+
private readonly agents: readonly SelectableAgent[],
|
|
123
|
+
private readonly currentAgentName: string | null,
|
|
124
|
+
private readonly theme: ThemeLike,
|
|
125
|
+
private readonly maxRenderRows: number,
|
|
126
|
+
private readonly onSelect: (agentName: string | null) => void,
|
|
127
|
+
private readonly requestRender: () => void,
|
|
128
|
+
) {
|
|
129
|
+
const currentIndex = currentAgentName
|
|
130
|
+
? agents.findIndex((agent) => agent.name === currentAgentName)
|
|
131
|
+
: -1;
|
|
132
|
+
this.cursorIndex = currentIndex >= 0 ? currentIndex : 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
render(_width: number): string[] {
|
|
136
|
+
const lines: string[] = [];
|
|
137
|
+
const frameInnerWidth = resolveOverlayOptions().width - 2;
|
|
138
|
+
const maxRows = this.resolveMaxRenderRows();
|
|
139
|
+
const viewportSize = this.resolveViewportSize(maxRows);
|
|
140
|
+
this.lastViewportSize = viewportSize;
|
|
141
|
+
this.ensureCursorVisible(viewportSize);
|
|
142
|
+
|
|
143
|
+
const start = this.scrollOffset;
|
|
144
|
+
const end = Math.min(this.agents.length, start + viewportSize);
|
|
145
|
+
const currentText = this.currentAgentName ?? "none";
|
|
146
|
+
const statsText = `CURRENT: ${currentText} VISIBLE: ${this.agents.length === 0 ? "0-0/0" : `${start + 1}-${end}/${this.agents.length}`}`;
|
|
147
|
+
|
|
148
|
+
lines.push(frameTop(frameInnerWidth));
|
|
149
|
+
lines.push(
|
|
150
|
+
formatTheme(
|
|
151
|
+
this.theme,
|
|
152
|
+
"accent",
|
|
153
|
+
formatBold(this.theme, frameLine(` ${TITLE_TEXT}`, frameInnerWidth)),
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
lines.push(formatTheme(this.theme, "dim", frameLine(` ${statsText}`, frameInnerWidth)));
|
|
157
|
+
lines.push(frameDivider(frameInnerWidth));
|
|
158
|
+
|
|
159
|
+
if (this.agents.length === 0) {
|
|
160
|
+
lines.push(formatTheme(this.theme, "dim", frameLine(" No agents available.", frameInnerWidth)));
|
|
161
|
+
} else {
|
|
162
|
+
for (let index = start; index < end; index += 1) {
|
|
163
|
+
const agent = this.agents[index];
|
|
164
|
+
const row = frameLine(
|
|
165
|
+
buildAgentRow(agent, agent.name === this.currentAgentName, index === this.cursorIndex, frameInnerWidth),
|
|
166
|
+
frameInnerWidth,
|
|
167
|
+
);
|
|
168
|
+
lines.push(
|
|
169
|
+
index === this.cursorIndex
|
|
170
|
+
? formatTheme(this.theme, "accent", formatBold(this.theme, row))
|
|
171
|
+
: row,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
lines.push(frameDivider(frameInnerWidth));
|
|
177
|
+
lines.push(formatTheme(this.theme, "dim", frameLine(" ↑/↓/j/k: move Enter: select Esc/q: cancel ", frameInnerWidth)));
|
|
178
|
+
lines.push(frameBottom(frameInnerWidth));
|
|
179
|
+
return lines;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
handleInput(data: string): void {
|
|
183
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || matchesKey(data, "q")) {
|
|
184
|
+
this.onSelect(null);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
189
|
+
this.moveCursor(-1);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
194
|
+
this.moveCursor(1);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (matchesKey(data, "pageUp")) {
|
|
199
|
+
this.moveCursor(-Math.max(1, this.lastViewportSize - 1));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (matchesKey(data, "pageDown")) {
|
|
204
|
+
this.moveCursor(Math.max(1, this.lastViewportSize - 1));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (matchesKey(data, "home")) {
|
|
209
|
+
this.cursorIndex = 0;
|
|
210
|
+
this.ensureCursorVisible(this.lastViewportSize);
|
|
211
|
+
this.requestRender();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (matchesKey(data, "end")) {
|
|
216
|
+
this.cursorIndex = Math.max(0, this.agents.length - 1);
|
|
217
|
+
this.ensureCursorVisible(this.lastViewportSize);
|
|
218
|
+
this.requestRender();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (matchesKey(data, "return")) {
|
|
223
|
+
const agent = this.agents[this.cursorIndex];
|
|
224
|
+
this.onSelect(agent?.name ?? null);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private moveCursor(delta: number): void {
|
|
229
|
+
if (this.agents.length === 0) {
|
|
230
|
+
this.cursorIndex = 0;
|
|
231
|
+
this.scrollOffset = 0;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
this.cursorIndex = clamp(this.cursorIndex + delta, 0, this.agents.length - 1);
|
|
236
|
+
this.ensureCursorVisible(this.lastViewportSize);
|
|
237
|
+
this.requestRender();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private resolveMaxRenderRows(): number {
|
|
241
|
+
const terminalRows =
|
|
242
|
+
typeof process.stdout.rows === "number" && Number.isFinite(process.stdout.rows) && process.stdout.rows > 0
|
|
243
|
+
? Math.floor(process.stdout.rows)
|
|
244
|
+
: this.maxRenderRows;
|
|
245
|
+
|
|
246
|
+
return Math.max(10, Math.min(this.maxRenderRows, terminalRows));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private resolveViewportSize(maxRows: number): number {
|
|
250
|
+
const reservedRows = 6;
|
|
251
|
+
return Math.max(1, maxRows - reservedRows);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private ensureCursorVisible(viewportSize: number): void {
|
|
255
|
+
if (this.agents.length === 0) {
|
|
256
|
+
this.cursorIndex = 0;
|
|
257
|
+
this.scrollOffset = 0;
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
this.cursorIndex = clamp(this.cursorIndex, 0, this.agents.length - 1);
|
|
262
|
+
|
|
263
|
+
if (this.cursorIndex < this.scrollOffset) {
|
|
264
|
+
this.scrollOffset = this.cursorIndex;
|
|
265
|
+
} else if (this.cursorIndex >= this.scrollOffset + viewportSize) {
|
|
266
|
+
this.scrollOffset = this.cursorIndex - viewportSize + 1;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const maxOffset = Math.max(0, this.agents.length - viewportSize);
|
|
270
|
+
this.scrollOffset = clamp(this.scrollOffset, 0, maxOffset);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function showAgentTargetPicker(
|
|
275
|
+
ctx: ExtensionCommandContext,
|
|
276
|
+
agents: readonly SelectableAgent[],
|
|
277
|
+
currentAgentName: string | null,
|
|
278
|
+
): Promise<string | null> {
|
|
279
|
+
const overlayOptions = resolveOverlayOptions();
|
|
280
|
+
let selectedAgentName: string | null = null;
|
|
281
|
+
let resolved = false;
|
|
282
|
+
|
|
283
|
+
await ctx.ui.custom<void>(
|
|
284
|
+
(tui, theme, _keybindings, done) =>
|
|
285
|
+
new AgentTargetPicker(
|
|
286
|
+
agents,
|
|
287
|
+
currentAgentName,
|
|
288
|
+
theme,
|
|
289
|
+
overlayOptions.maxHeight,
|
|
290
|
+
(agentName) => {
|
|
291
|
+
resolved = true;
|
|
292
|
+
selectedAgentName = agentName;
|
|
293
|
+
done();
|
|
294
|
+
},
|
|
295
|
+
() => {
|
|
296
|
+
tui.requestRender();
|
|
297
|
+
},
|
|
298
|
+
),
|
|
299
|
+
{
|
|
300
|
+
overlay: true,
|
|
301
|
+
overlayOptions,
|
|
302
|
+
},
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
return resolved ? selectedAgentName : null;
|
|
306
|
+
}
|