pi-session-cleanup 1.1.0 → 1.1.2
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 +27 -17
- package/package.json +3 -3
- package/src/agent-target.ts +361 -361
- package/src/index.ts +1 -1
- package/src/session-agent.ts +103 -103
- package/src/session-cleanup-command.ts +268 -268
- package/src/session-delete.ts +165 -11
- package/src/session-entry.ts +23 -23
- package/src/session-format.ts +98 -98
- package/src/session-nix-command.ts +353 -329
- package/src/session-quit-shutdown.ts +40 -40
- 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 -306
- package/src/tui/session-cleanup-picker.ts +592 -592
- package/src/types-shims.d.ts +23 -9
- package/src/types.ts +1 -1
package/src/session-source.ts
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
SessionManager,
|
|
3
|
-
type ExtensionCommandContext,
|
|
4
|
-
} from "@
|
|
5
|
-
|
|
6
|
-
import { enrichSessionWithResponsibleAgent } from "./session-agent.js";
|
|
7
|
-
import { sortSessionsNewestFirst } from "./session-sort.js";
|
|
8
|
-
import type { SessionCleanupSession, SessionScope } from "./types.js";
|
|
9
|
-
|
|
10
|
-
function ensureSessionArray(value: unknown): SessionCleanupSession[] {
|
|
11
|
-
if (!Array.isArray(value)) {
|
|
12
|
-
throw new Error("Session manager returned a non-array response.");
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
return value as SessionCleanupSession[];
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export async function loadSessions(
|
|
19
|
-
ctx: ExtensionCommandContext,
|
|
20
|
-
scope: SessionScope,
|
|
21
|
-
): Promise<SessionCleanupSession[]> {
|
|
22
|
-
const loaded =
|
|
23
|
-
scope === "all"
|
|
24
|
-
? await SessionManager.listAll()
|
|
25
|
-
: await SessionManager.list(
|
|
26
|
-
ctx.sessionManager.getCwd(),
|
|
27
|
-
ctx.sessionManager.getSessionDir(),
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
const sortedSessions = sortSessionsNewestFirst(ensureSessionArray(loaded));
|
|
31
|
-
return Promise.all(sortedSessions.map((session) => enrichSessionWithResponsibleAgent(session)));
|
|
32
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
SessionManager,
|
|
3
|
+
type ExtensionCommandContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import { enrichSessionWithResponsibleAgent } from "./session-agent.js";
|
|
7
|
+
import { sortSessionsNewestFirst } from "./session-sort.js";
|
|
8
|
+
import type { SessionCleanupSession, SessionScope } from "./types.js";
|
|
9
|
+
|
|
10
|
+
function ensureSessionArray(value: unknown): SessionCleanupSession[] {
|
|
11
|
+
if (!Array.isArray(value)) {
|
|
12
|
+
throw new Error("Session manager returned a non-array response.");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return value as SessionCleanupSession[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function loadSessions(
|
|
19
|
+
ctx: ExtensionCommandContext,
|
|
20
|
+
scope: SessionScope,
|
|
21
|
+
): Promise<SessionCleanupSession[]> {
|
|
22
|
+
const loaded =
|
|
23
|
+
scope === "all"
|
|
24
|
+
? await SessionManager.listAll()
|
|
25
|
+
: await SessionManager.list(
|
|
26
|
+
ctx.sessionManager.getCwd(),
|
|
27
|
+
ctx.sessionManager.getSessionDir(),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const sortedSessions = sortSessionsNewestFirst(ensureSessionArray(loaded));
|
|
31
|
+
return Promise.all(sortedSessions.map((session) => enrichSessionWithResponsibleAgent(session)));
|
|
32
|
+
}
|
|
@@ -1,306 +1,306 @@
|
|
|
1
|
-
import type { ExtensionCommandContext } from "@
|
|
2
|
-
import { matchesKey, truncateToWidth, visibleWidth, type Component } from "@
|
|
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
|
-
}
|
|
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 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
|
+
}
|