pi-model-profiles 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1175 @@
1
+ import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2
+ import { Input, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
3
+
4
+ import { MODAL_MIN_HEIGHT, calculateModalHeight, resolveModalOverlayOptions } from "./constants.js";
5
+ import { toErrorMessage } from "./errors.js";
6
+ import { loadModalTheme, BOX, type ResolvedModalTheme } from "./modal-theme.js";
7
+ import { formatProfileFieldValue } from "./profile-fields.js";
8
+ import { getAvailableSortOrders, getCurrentSortOrder, getSortOrderLabel, persistSortOrder, sortProfiles } from "./profile-sort-service.js";
9
+ import type { AppliedProfileOutcome, ProfileSortOrder, ProfilesFile, SavedProfile, SavedProfileAgent } from "./types.js";
10
+
11
+ interface ThemeLike {
12
+ name?: unknown;
13
+ fg?: unknown;
14
+ bold?: unknown;
15
+ }
16
+
17
+ interface ModalMessage {
18
+ text: string;
19
+ level: "info" | "warning" | "error";
20
+ }
21
+
22
+ interface ProfileModalMutationResult {
23
+ data: ProfilesFile;
24
+ message: string;
25
+ selectedProfileId?: string;
26
+ }
27
+
28
+ interface ProfileModalActions {
29
+ renameProfile(profileId: string, nextName: string): Promise<ProfileModalMutationResult>;
30
+ addCurrentProfile(): Promise<ProfileModalMutationResult>;
31
+ applyProfile(profileId: string): Promise<AppliedProfileOutcome>;
32
+ removeProfile(profileId: string): Promise<ProfileModalMutationResult>;
33
+ updateProfile(profileId: string): Promise<ProfileModalMutationResult>;
34
+ }
35
+
36
+ interface SortMenuOption {
37
+ order: ProfileSortOrder;
38
+ label: string;
39
+ isSelected: boolean;
40
+ }
41
+
42
+ export type ProfileModalResult =
43
+ | { type: "closed" }
44
+ | {
45
+ type: "applied";
46
+ outcome: AppliedProfileOutcome;
47
+ };
48
+
49
+ type FocusedPane = "snapshots" | "details";
50
+ type ConfirmationAction = "remove" | "update";
51
+
52
+ interface ConfirmationState {
53
+ action: ConfirmationAction;
54
+ profileId: string;
55
+ prompt: string;
56
+ input: Input;
57
+ }
58
+
59
+ interface ConfirmationRequest {
60
+ action: ConfirmationAction;
61
+ profile: SavedProfile;
62
+ prompt: string;
63
+ busyMessage: string;
64
+ onConfirm(profileId: string): Promise<void>;
65
+ }
66
+
67
+ interface TableColumnLayout {
68
+ agent: number;
69
+ model: number;
70
+ temp: number;
71
+ reasoning: number;
72
+ reasoningHeader: string;
73
+ gap: string;
74
+ }
75
+
76
+ const MODAL_FALLBACK_VIEWPORT = 10;
77
+ const OUTER_HORIZONTAL_PADDING = 1;
78
+ const PANE_GAP = 1;
79
+ const SNAPSHOT_TITLE = "SNAPSHOTS";
80
+ const DETAILS_TITLE = "DETAILS";
81
+ const ACTIVE_PANE_LABEL = "[ACTIVE]";
82
+ const EMPTY_PROFILE_HINT = "No saved snapshots yet.";
83
+ const EMPTY_DETAILS_HINT = "Select a snapshot to inspect its saved agent models.";
84
+ const ABSENT_DISPLAY_VALUE = "absent";
85
+
86
+ function clamp(value: number, min: number, max: number): number {
87
+ return Math.max(min, Math.min(max, value));
88
+ }
89
+
90
+ function formatTimestamp(value: string): string {
91
+ const date = new Date(value);
92
+ if (Number.isNaN(date.getTime())) {
93
+ return value;
94
+ }
95
+
96
+ return new Intl.DateTimeFormat(undefined, {
97
+ dateStyle: "short",
98
+ timeStyle: "short",
99
+ }).format(date);
100
+ }
101
+
102
+ function padEndToWidth(text: string, width: number): string {
103
+ const safeWidth = Math.max(0, width);
104
+ const padding = Math.max(0, safeWidth - visibleWidth(text));
105
+ return `${text}${" ".repeat(padding)}`;
106
+ }
107
+
108
+ function fitText(text: string, width: number): string {
109
+ const safeWidth = Math.max(1, width);
110
+ return padEndToWidth(truncateToWidth(text, safeWidth, "…", true), safeWidth);
111
+ }
112
+
113
+ function centerText(text: string, width: number): string {
114
+ const safeWidth = Math.max(1, width);
115
+ const clipped = truncateToWidth(text, safeWidth, "…", true);
116
+ const remaining = Math.max(0, safeWidth - visibleWidth(clipped));
117
+ const left = Math.floor(remaining / 2);
118
+ const right = remaining - left;
119
+ return `${" ".repeat(left)}${clipped}${" ".repeat(right)}`;
120
+ }
121
+
122
+ function alignRight(text: string, width: number): string {
123
+ const safeWidth = Math.max(1, width);
124
+ const clipped = truncateToWidth(text, safeWidth, "…", true);
125
+ const padding = Math.max(0, safeWidth - visibleWidth(clipped));
126
+ return `${" ".repeat(padding)}${clipped}`;
127
+ }
128
+
129
+ function fitLineToWidth(text: string, width: number): string {
130
+ const safeWidth = Math.max(1, width);
131
+ if (visibleWidth(text) > safeWidth) {
132
+ return truncateToWidth(text, safeWidth, "…", true);
133
+ }
134
+ return padEndToWidth(text, safeWidth);
135
+ }
136
+
137
+ function centerLineInWidth(text: string, width: number): string {
138
+ const safeWidth = Math.max(1, width);
139
+ const clipped = visibleWidth(text) > safeWidth ? truncateToWidth(text, safeWidth, "…", true) : text;
140
+ const padding = Math.max(0, Math.floor((safeWidth - visibleWidth(clipped)) / 2));
141
+ return fitLineToWidth(`${" ".repeat(padding)}${clipped}`, safeWidth);
142
+ }
143
+
144
+ function splitPaneWidths(totalWidth: number, snapshotNameWidth: number): { left: number; right: number } {
145
+ const safeWidth = Math.max(56, totalWidth);
146
+ const preferredLeft = clamp(snapshotNameWidth + 6, 24, 32);
147
+ const minLeft = Math.max(22, Math.min(26, safeWidth - 46));
148
+ const maxLeft = Math.max(minLeft, Math.min(34, safeWidth - 40));
149
+ const left = clamp(preferredLeft, minLeft, maxLeft);
150
+ const right = Math.max(36, safeWidth - left);
151
+ return { left, right };
152
+ }
153
+
154
+ function wrapText(text: string, width: number): string[] {
155
+ const safeWidth = Math.max(1, width);
156
+ const normalized = text.trim();
157
+ if (!normalized) {
158
+ return [""];
159
+ }
160
+
161
+ const words = normalized.split(/\s+/);
162
+ const lines: string[] = [];
163
+ let current = "";
164
+
165
+ for (const word of words) {
166
+ const candidate = current ? `${current} ${word}` : word;
167
+ if (visibleWidth(candidate) <= safeWidth) {
168
+ current = candidate;
169
+ continue;
170
+ }
171
+
172
+ if (current) {
173
+ lines.push(current);
174
+ current = "";
175
+ }
176
+
177
+ if (visibleWidth(word) <= safeWidth) {
178
+ current = word;
179
+ continue;
180
+ }
181
+
182
+ let remainder = word;
183
+ while (visibleWidth(remainder) > safeWidth) {
184
+ lines.push(truncateToWidth(remainder, safeWidth, "", false));
185
+ remainder = remainder.slice(Math.max(1, safeWidth));
186
+ }
187
+ current = remainder;
188
+ }
189
+
190
+ if (current) {
191
+ lines.push(current);
192
+ }
193
+
194
+ return lines.length > 0 ? lines : [""];
195
+ }
196
+
197
+ function renderOuterFrame(lines: string[], width: number, title: string, theme: ResolvedModalTheme): string[] {
198
+ const frameWidth = Math.max(4, Math.floor(width));
199
+ const innerWidth = Math.max(1, frameWidth - 2);
200
+ const colorBorder = (text: string): string => theme.color("accent", text);
201
+ const safeTitle = truncateToWidth(title, innerWidth, "…", true);
202
+ const titleWidth = innerWidth >= visibleWidth(safeTitle) + 2 ? visibleWidth(safeTitle) + 2 : visibleWidth(safeTitle);
203
+ const paddedTitle = innerWidth >= visibleWidth(safeTitle) + 2 ? ` ${theme.bold(safeTitle)} ` : theme.bold(safeTitle);
204
+ const fillWidth = Math.max(0, innerWidth - titleWidth);
205
+ const topLine = `${colorBorder(BOX.CORNER_TL)}${colorBorder(paddedTitle)}${colorBorder(BOX.H_LINE.repeat(fillWidth))}${colorBorder(BOX.CORNER_TR)}`;
206
+ const bottomLine = `${colorBorder(BOX.CORNER_BL)}${colorBorder(BOX.H_LINE.repeat(innerWidth))}${colorBorder(BOX.CORNER_BR)}`;
207
+ const contentLines = (lines.length > 0 ? lines : [""]).map((line) => {
208
+ const padded = fitText(line, innerWidth);
209
+ return `${colorBorder(BOX.V_LINE)}${padded}${colorBorder(BOX.V_LINE)}`;
210
+ });
211
+
212
+ return [topLine, ...contentLines, bottomLine];
213
+ }
214
+
215
+ function colorPaneBorder(theme: ResolvedModalTheme, active: boolean, text: string): string {
216
+ return theme.color(active ? "accent" : "borderMuted", text, { bold: active });
217
+ }
218
+
219
+ function buildPaneTopBorder(theme: ResolvedModalTheme, width: number, title: string, active: boolean): string {
220
+ const innerWidth = Math.max(1, width - 2);
221
+ const label = active ? `${title} ${ACTIVE_PANE_LABEL}` : title;
222
+ const labelText = truncateToWidth(` ${label} `, innerWidth, "…", true);
223
+ const fillWidth = Math.max(0, innerWidth - visibleWidth(labelText));
224
+ return `${colorPaneBorder(theme, active, BOX.CORNER_TL)}${colorPaneBorder(theme, active, labelText)}${colorPaneBorder(theme, active, BOX.H_LINE.repeat(fillWidth))}${colorPaneBorder(theme, active, BOX.CORNER_TR)}`;
225
+ }
226
+
227
+ function buildPaneBottomBorder(theme: ResolvedModalTheme, width: number, active: boolean): string {
228
+ return `${colorPaneBorder(theme, active, BOX.CORNER_BL)}${colorPaneBorder(theme, active, BOX.H_LINE.repeat(width - 2))}${colorPaneBorder(theme, active, BOX.CORNER_BR)}`;
229
+ }
230
+
231
+ function buildPaneLine(theme: ResolvedModalTheme, width: number, content: string, active: boolean): string {
232
+ const inner = fitText(content, width - 2);
233
+ return `${colorPaneBorder(theme, active, BOX.V_LINE)}${inner}${colorPaneBorder(theme, active, BOX.V_LINE)}`;
234
+ }
235
+
236
+ function formatDisplayedFieldValue(agent: SavedProfileAgent, key: "model" | "temperature" | "reasoningEffort"): string {
237
+ const raw = formatProfileFieldValue(key, agent.fields);
238
+ return raw === "(absent)" ? ABSENT_DISPLAY_VALUE : raw;
239
+ }
240
+
241
+ function buildProfileScrollIndicator(offset: number, totalItems: number, visibleItems: number): string {
242
+ const shownEnd = Math.min(totalItems, offset + visibleItems);
243
+ const remainingAbove = offset;
244
+ const remainingBelow = Math.max(0, totalItems - shownEnd);
245
+
246
+ if (remainingAbove > 0 && remainingBelow > 0) {
247
+ return `[↑ ${remainingAbove} | ↓ ${remainingBelow}]`;
248
+ }
249
+ if (remainingBelow > 0) {
250
+ return `[↓ ${remainingBelow} more]`;
251
+ }
252
+ return `[↑ ${remainingAbove} above]`;
253
+ }
254
+
255
+ function buildAgentScrollIndicator(offset: number, totalItems: number, visibleItems: number): string {
256
+ const shownEnd = Math.min(totalItems, offset + visibleItems);
257
+ const remainingAbove = offset;
258
+ const remainingBelow = Math.max(0, totalItems - shownEnd);
259
+
260
+ if (remainingAbove > 0 && remainingBelow > 0) {
261
+ return `[ ↑ ${remainingAbove} | ↓ ${remainingBelow} more ]`;
262
+ }
263
+ if (remainingBelow > 0) {
264
+ return `[ ↓ Scroll (${remainingBelow} more) ]`;
265
+ }
266
+ return `[ ↑ Scroll (${remainingAbove} above) ]`;
267
+ }
268
+
269
+ function renderMetadataLine(theme: ResolvedModalTheme, label: string, value: string, width: number): string {
270
+ const prefix = `${label.padEnd(8, " ")}`;
271
+ const safeValueWidth = Math.max(1, width - visibleWidth(prefix));
272
+ const valueText = truncateToWidth(value, safeValueWidth, "…", true);
273
+ const trailing = " ".repeat(Math.max(0, width - visibleWidth(prefix) - visibleWidth(valueText)));
274
+ return `${theme.color("dim", prefix)}${theme.color("text", valueText)}${trailing}`;
275
+ }
276
+
277
+ function computeProfileNameWidth(data: ProfilesFile): number {
278
+ let maxWidth = 0;
279
+ for (const profile of data.profiles) {
280
+ maxWidth = Math.max(maxWidth, visibleWidth(profile.name));
281
+ }
282
+ return maxWidth;
283
+ }
284
+
285
+ function getMaximumContentWidth(data: ProfilesFile): number {
286
+ let maxWidth = 40;
287
+ for (const profile of data.profiles) {
288
+ maxWidth = Math.max(maxWidth, visibleWidth(profile.name) + 18);
289
+ for (const agent of profile.agents) {
290
+ maxWidth = Math.max(maxWidth, visibleWidth(agent.agentName));
291
+ maxWidth = Math.max(maxWidth, visibleWidth(formatDisplayedFieldValue(agent, "model")));
292
+ maxWidth = Math.max(maxWidth, visibleWidth(formatDisplayedFieldValue(agent, "temperature")));
293
+ maxWidth = Math.max(maxWidth, visibleWidth(formatDisplayedFieldValue(agent, "reasoningEffort")));
294
+ }
295
+ }
296
+ return maxWidth;
297
+ }
298
+
299
+ function buildTableLayout(profile: SavedProfile, totalWidth: number): TableColumnLayout {
300
+ const gap = " ";
301
+ const gapWidth = visibleWidth(gap) * 3;
302
+ const available = Math.max(24, totalWidth - gapWidth);
303
+ const reasoningHeader = totalWidth >= 60 ? "REASONING" : "REASON";
304
+
305
+ let agent = Math.max("AGENT".length, ...profile.agents.map((entry) => visibleWidth(entry.agentName)));
306
+ agent = clamp(agent, 10, 18);
307
+
308
+ const temp = Math.max(
309
+ 6,
310
+ "TEMP".length,
311
+ ...profile.agents.map((entry) => visibleWidth(formatDisplayedFieldValue(entry, "temperature"))),
312
+ );
313
+
314
+ let reasoning = Math.max(
315
+ reasoningHeader.length,
316
+ ...profile.agents.map((entry) => visibleWidth(formatDisplayedFieldValue(entry, "reasoningEffort"))),
317
+ );
318
+ reasoning = clamp(reasoning, reasoningHeader.length, 12);
319
+
320
+ const minimumModel = 16;
321
+ let model = available - agent - temp - reasoning;
322
+
323
+ while (model < minimumModel && agent > 10) {
324
+ agent -= 1;
325
+ model += 1;
326
+ }
327
+
328
+ while (model < minimumModel && reasoning > reasoningHeader.length) {
329
+ reasoning -= 1;
330
+ model += 1;
331
+ }
332
+
333
+ if (model < minimumModel) {
334
+ model = Math.max(12, model);
335
+ }
336
+
337
+ const totalUsed = agent + model + temp + reasoning;
338
+ const slack = Math.max(0, available - totalUsed);
339
+ model += slack;
340
+
341
+ return {
342
+ agent,
343
+ model,
344
+ temp,
345
+ reasoning,
346
+ reasoningHeader,
347
+ gap,
348
+ };
349
+ }
350
+
351
+ function buildTableHeaderLine(theme: ResolvedModalTheme, layout: TableColumnLayout): string {
352
+ return [
353
+ theme.color("accent", fitText("AGENT", layout.agent), { bold: true }),
354
+ layout.gap,
355
+ theme.color("accent", fitText("MODEL", layout.model), { bold: true }),
356
+ layout.gap,
357
+ theme.color("accent", alignRight("TEMP", layout.temp), { bold: true }),
358
+ layout.gap,
359
+ theme.color("accent", fitText(layout.reasoningHeader, layout.reasoning), { bold: true }),
360
+ ].join("");
361
+ }
362
+
363
+ function buildTableSeparatorLine(theme: ResolvedModalTheme, width: number): string {
364
+ return theme.color("borderMuted", BOX.H_LINE.repeat(width));
365
+ }
366
+
367
+ function buildTableDataLine(theme: ResolvedModalTheme, layout: TableColumnLayout, agent: SavedProfileAgent): string {
368
+ return [
369
+ theme.color("text", fitText(agent.agentName, layout.agent)),
370
+ layout.gap,
371
+ theme.color("text", fitText(formatDisplayedFieldValue(agent, "model"), layout.model)),
372
+ layout.gap,
373
+ theme.color("text", alignRight(formatDisplayedFieldValue(agent, "temperature"), layout.temp)),
374
+ layout.gap,
375
+ theme.color("text", fitText(formatDisplayedFieldValue(agent, "reasoningEffort"), layout.reasoning)),
376
+ ].join("");
377
+ }
378
+
379
+ class ProfileListModal {
380
+ private data: ProfilesFile;
381
+ private selectedProfileId: string | null;
382
+ private listScrollOffset = 0;
383
+ private detailScrollOffset = 0;
384
+ private focusedPane: FocusedPane = "snapshots";
385
+ private lastPaneContentRows = MODAL_FALLBACK_VIEWPORT;
386
+ private lastDetailViewportRows = 1;
387
+ private renameInput: Input | null = null;
388
+ private renameTargetId: string | null = null;
389
+ private confirmation: ConfirmationState | null = null;
390
+ private sortMenuOpen = false;
391
+ private sortMenuSelectedIndex = 0;
392
+ private message: ModalMessage | null = null;
393
+ private busyMessage: string | null = null;
394
+ private finished = false;
395
+ private currentSortOrder: ProfileSortOrder;
396
+
397
+ constructor(
398
+ initialData: ProfilesFile,
399
+ private readonly theme: ResolvedModalTheme,
400
+ private readonly actions: ProfileModalActions,
401
+ private readonly activeAgentName: string | null,
402
+ private readonly done: (result: ProfileModalResult) => void,
403
+ private readonly requestRender: () => void,
404
+ ) {
405
+ this.data = initialData;
406
+ this.currentSortOrder = getCurrentSortOrder();
407
+ this.selectedProfileId = this.getSortedProfiles()[0]?.id ?? null;
408
+ if (theme.warnings.length > 0) {
409
+ this.message = { text: theme.warnings.join(" "), level: "warning" };
410
+ }
411
+ }
412
+
413
+ invalidate(): void {
414
+ // Rendering is fully state driven.
415
+ }
416
+
417
+ render(width: number): string[] {
418
+ const contentWidth = Math.max(1, Math.floor(width));
419
+ const paneAreaWidth = Math.max(40, contentWidth - OUTER_HORIZONTAL_PADDING * 2 - PANE_GAP);
420
+ const paneWidths = splitPaneWidths(paneAreaWidth, computeProfileNameWidth(this.data));
421
+ const footerLines = this.buildFooterLines(contentWidth);
422
+ const selectedProfile = this.getSelectedProfile();
423
+ const agentCount = selectedProfile?.agents.length ?? 0;
424
+ const paneContentRows = this.resolvePaneContentRows(agentCount, footerLines.length);
425
+ this.lastPaneContentRows = paneContentRows;
426
+ const leftPaneLines = this.buildSnapshotPaneBox(paneWidths.left, paneContentRows);
427
+ const rightPaneLines = this.buildDetailsPaneBox(selectedProfile, paneWidths.right, paneContentRows);
428
+ const paneRowCount = Math.max(leftPaneLines.length, rightPaneLines.length);
429
+ const lines: string[] = [];
430
+
431
+ for (let index = 0; index < paneRowCount; index += 1) {
432
+ const left = leftPaneLines[index] ?? " ".repeat(paneWidths.left);
433
+ const right = rightPaneLines[index] ?? " ".repeat(paneWidths.right);
434
+ const content = `${" ".repeat(OUTER_HORIZONTAL_PADDING)}${left}${" ".repeat(PANE_GAP)}${right}${" ".repeat(OUTER_HORIZONTAL_PADDING)}`;
435
+ lines.push(fitText(content, contentWidth));
436
+ }
437
+
438
+ lines.push(" ".repeat(contentWidth));
439
+ for (const footerLine of footerLines) {
440
+ lines.push(fitText(footerLine, contentWidth));
441
+ }
442
+
443
+ return lines;
444
+ }
445
+
446
+ handleInput(data: string): void {
447
+ if (this.renameInput) {
448
+ this.renameInput.handleInput(data);
449
+ this.requestRender();
450
+ return;
451
+ }
452
+
453
+ if (this.confirmation) {
454
+ this.confirmation.input.handleInput(data);
455
+ this.requestRender();
456
+ return;
457
+ }
458
+
459
+ if (this.busyMessage) {
460
+ return;
461
+ }
462
+
463
+ if (this.sortMenuOpen) {
464
+ this.handleSortMenuInput(data);
465
+ return;
466
+ }
467
+
468
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
469
+ this.finish({ type: "closed" });
470
+ return;
471
+ }
472
+
473
+ if (matchesKey(data, "tab")) {
474
+ this.toggleFocusedPane();
475
+ return;
476
+ }
477
+
478
+ if (matchesKey(data, "left")) {
479
+ this.setFocusedPane("snapshots");
480
+ return;
481
+ }
482
+
483
+ if (matchesKey(data, "right")) {
484
+ this.setFocusedPane("details");
485
+ return;
486
+ }
487
+
488
+ if (matchesKey(data, "r")) {
489
+ this.startRename();
490
+ return;
491
+ }
492
+
493
+ if (matchesKey(data, "delete") || matchesKey(data, "ctrl+d")) {
494
+ this.startRemove();
495
+ return;
496
+ }
497
+
498
+ if (matchesKey(data, "ctrl+u")) {
499
+ this.startUpdate();
500
+ return;
501
+ }
502
+
503
+ if (matchesKey(data, "ctrl+s")) {
504
+ this.toggleSortMenu();
505
+ return;
506
+ }
507
+
508
+ if (matchesKey(data, "s") && !this.sortMenuOpen) {
509
+ this.addCurrentProfile();
510
+ return;
511
+ }
512
+
513
+ if (this.focusedPane === "snapshots") {
514
+ this.handleSnapshotPaneInput(data);
515
+ return;
516
+ }
517
+
518
+ this.handleDetailsPaneInput(data);
519
+ }
520
+
521
+ private handleSnapshotPaneInput(data: string): void {
522
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
523
+ this.moveSelection(-1);
524
+ return;
525
+ }
526
+
527
+ if (matchesKey(data, "down") || matchesKey(data, "j")) {
528
+ this.moveSelection(1);
529
+ return;
530
+ }
531
+
532
+ if (matchesKey(data, "pageup")) {
533
+ this.moveSelection(-Math.max(1, this.getSnapshotViewportRows()));
534
+ return;
535
+ }
536
+
537
+ if (matchesKey(data, "pagedown")) {
538
+ this.moveSelection(Math.max(1, this.getSnapshotViewportRows()));
539
+ return;
540
+ }
541
+
542
+ if (matchesKey(data, "home")) {
543
+ this.moveSelectionToBoundary("start");
544
+ return;
545
+ }
546
+
547
+ if (matchesKey(data, "end")) {
548
+ this.moveSelectionToBoundary("end");
549
+ return;
550
+ }
551
+
552
+ if (matchesKey(data, "return")) {
553
+ this.applySelectedProfile();
554
+ }
555
+ }
556
+
557
+ private handleDetailsPaneInput(data: string): void {
558
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
559
+ this.scrollDetails(-1);
560
+ return;
561
+ }
562
+
563
+ if (matchesKey(data, "down") || matchesKey(data, "j")) {
564
+ this.scrollDetails(1);
565
+ return;
566
+ }
567
+
568
+ if (matchesKey(data, "pageup")) {
569
+ this.scrollDetails(-Math.max(1, this.lastDetailViewportRows));
570
+ return;
571
+ }
572
+
573
+ if (matchesKey(data, "pagedown")) {
574
+ this.scrollDetails(Math.max(1, this.lastDetailViewportRows));
575
+ return;
576
+ }
577
+
578
+ if (matchesKey(data, "home")) {
579
+ this.scrollDetailsToBoundary("start");
580
+ return;
581
+ }
582
+
583
+ if (matchesKey(data, "end")) {
584
+ this.scrollDetailsToBoundary("end");
585
+ return;
586
+ }
587
+
588
+ if (matchesKey(data, "return")) {
589
+ this.applySelectedProfile();
590
+ }
591
+ }
592
+
593
+ private buildSnapshotPaneBox(width: number, contentRows: number): string[] {
594
+ const lines: string[] = [];
595
+ const innerWidth = Math.max(1, width - 2);
596
+ const profiles = this.getSortedProfiles();
597
+ const isFocused = this.focusedPane === "snapshots";
598
+ const needsIndicator = profiles.length > contentRows;
599
+ const viewportRows = Math.max(1, contentRows - (needsIndicator ? 1 : 0));
600
+ this.ensureSelectedVisible(viewportRows);
601
+
602
+ lines.push(buildPaneTopBorder(this.theme, width, SNAPSHOT_TITLE, isFocused));
603
+
604
+ if (profiles.length === 0) {
605
+ lines.push(buildPaneLine(this.theme, width, this.theme.color("dim", fitText(EMPTY_PROFILE_HINT, innerWidth)), isFocused));
606
+ for (let index = 1; index < contentRows; index += 1) {
607
+ lines.push(buildPaneLine(this.theme, width, " ".repeat(innerWidth), isFocused));
608
+ }
609
+ lines.push(buildPaneBottomBorder(this.theme, width, isFocused));
610
+ return lines;
611
+ }
612
+
613
+ for (let index = 0; index < viewportRows; index += 1) {
614
+ const profile = profiles[this.listScrollOffset + index];
615
+ if (!profile) {
616
+ lines.push(buildPaneLine(this.theme, width, " ".repeat(innerWidth), isFocused));
617
+ continue;
618
+ }
619
+
620
+ const isSelected = profile.id === this.selectedProfileId;
621
+ const label = fitText(`${isSelected ? ">" : " "} ${profile.name}`, innerWidth);
622
+ if (isSelected) {
623
+ const content = isFocused
624
+ ? this.theme.color("selectedText", label, { background: "selectedBg", bold: true })
625
+ : this.theme.color("accent", label, { bold: true });
626
+ lines.push(buildPaneLine(this.theme, width, content, isFocused));
627
+ continue;
628
+ }
629
+
630
+ lines.push(buildPaneLine(this.theme, width, this.theme.color("text", label), isFocused));
631
+ }
632
+
633
+ if (needsIndicator) {
634
+ const indicator = alignRight(buildProfileScrollIndicator(this.listScrollOffset, profiles.length, viewportRows), innerWidth);
635
+ lines.push(buildPaneLine(this.theme, width, this.theme.color("dim", indicator), isFocused));
636
+ }
637
+
638
+ while (lines.length < contentRows + 1) {
639
+ lines.push(buildPaneLine(this.theme, width, " ".repeat(innerWidth), isFocused));
640
+ }
641
+
642
+ lines.push(buildPaneBottomBorder(this.theme, width, isFocused));
643
+ return lines;
644
+ }
645
+
646
+ private buildDetailsPaneBox(profile: SavedProfile | null, width: number, contentRows: number): string[] {
647
+ const lines: string[] = [];
648
+ const innerWidth = Math.max(1, width - 2);
649
+ const isFocused = this.focusedPane === "details";
650
+ lines.push(buildPaneTopBorder(this.theme, width, DETAILS_TITLE, isFocused));
651
+
652
+ if (!profile) {
653
+ this.lastDetailViewportRows = 1;
654
+ lines.push(buildPaneLine(this.theme, width, renderMetadataLine(this.theme, "Name:", "-", innerWidth), isFocused));
655
+ lines.push(buildPaneLine(this.theme, width, renderMetadataLine(this.theme, "Updated:", "-", innerWidth), isFocused));
656
+ lines.push(buildPaneLine(this.theme, width, " ".repeat(innerWidth), isFocused));
657
+ lines.push(buildPaneLine(this.theme, width, this.theme.color("dim", fitText(EMPTY_DETAILS_HINT, innerWidth)), isFocused));
658
+ while (lines.length < contentRows + 1) {
659
+ lines.push(buildPaneLine(this.theme, width, " ".repeat(innerWidth), isFocused));
660
+ }
661
+ lines.push(buildPaneBottomBorder(this.theme, width, isFocused));
662
+ return lines;
663
+ }
664
+
665
+ const fixedRowsBeforeData = 5;
666
+ const availableDataArea = Math.max(1, contentRows - fixedRowsBeforeData);
667
+ const needsIndicator = profile.agents.length > availableDataArea;
668
+ const viewportRows = Math.max(1, availableDataArea - (needsIndicator ? 1 : 0));
669
+ this.lastDetailViewportRows = viewportRows;
670
+ const maxOffset = Math.max(0, profile.agents.length - viewportRows);
671
+ this.detailScrollOffset = clamp(this.detailScrollOffset, 0, maxOffset);
672
+ const layout = buildTableLayout(profile, innerWidth);
673
+
674
+ lines.push(buildPaneLine(this.theme, width, renderMetadataLine(this.theme, "Name:", profile.name, innerWidth), isFocused));
675
+ lines.push(buildPaneLine(this.theme, width, renderMetadataLine(this.theme, "Updated:", formatTimestamp(profile.updatedAt), innerWidth), isFocused));
676
+ lines.push(buildPaneLine(this.theme, width, " ".repeat(innerWidth), isFocused));
677
+ lines.push(buildPaneLine(this.theme, width, buildTableHeaderLine(this.theme, layout), isFocused));
678
+ lines.push(buildPaneLine(this.theme, width, buildTableSeparatorLine(this.theme, innerWidth), isFocused));
679
+
680
+ for (let index = 0; index < viewportRows; index += 1) {
681
+ const agent = profile.agents[this.detailScrollOffset + index];
682
+ const content = agent ? buildTableDataLine(this.theme, layout, agent) : " ".repeat(innerWidth);
683
+ lines.push(buildPaneLine(this.theme, width, content, isFocused));
684
+ }
685
+
686
+ if (needsIndicator) {
687
+ const indicator = centerText(buildAgentScrollIndicator(this.detailScrollOffset, profile.agents.length, viewportRows), innerWidth);
688
+ lines.push(buildPaneLine(this.theme, width, this.theme.color("dim", indicator), isFocused));
689
+ }
690
+
691
+ while (lines.length < contentRows + 1) {
692
+ lines.push(buildPaneLine(this.theme, width, " ".repeat(innerWidth), isFocused));
693
+ }
694
+
695
+ lines.push(buildPaneBottomBorder(this.theme, width, isFocused));
696
+ return lines;
697
+ }
698
+
699
+ private buildFooterLines(width: number): string[] {
700
+ if (this.busyMessage) {
701
+ return [this.theme.color("warning", fitText(`Working: ${this.busyMessage}`, width))];
702
+ }
703
+
704
+ if (this.renameInput) {
705
+ const renameLine = this.renameInput.render(width)[0] ?? "";
706
+ return [
707
+ this.theme.color("warning", fitText("Rename selected snapshot:", width)),
708
+ fitText(renameLine, width),
709
+ this.theme.color("dim", fitText("[Enter] Save [Esc] Cancel Rename", width)),
710
+ ];
711
+ }
712
+
713
+ if (this.confirmation) {
714
+ const confirmLine = this.confirmation.input.render(width)[0] ?? "";
715
+ return [
716
+ this.theme.color("warning", fitText(this.confirmation.prompt, width)),
717
+ fitText(confirmLine, width),
718
+ this.theme.color("dim", fitText("Type 'yes', then [Enter] to confirm. [Esc] Cancel", width)),
719
+ ];
720
+ }
721
+
722
+ if (this.sortMenuOpen) {
723
+ return [
724
+ this.theme.color("dim", fitText("Use ↑↓ to select sort order, [Enter] to apply", width)),
725
+ ...this.buildSortMenuLines(width),
726
+ ];
727
+ }
728
+
729
+ if (this.message) {
730
+ const slot = this.message.level === "error" ? "error" : this.message.level === "warning" ? "warning" : "success";
731
+ return wrapText(this.message.text, width).map((line) => this.theme.color(slot, fitText(line, width)));
732
+ }
733
+
734
+ const gap = " ";
735
+ const leftWidth = Math.max(24, Math.min(34, Math.floor((width - visibleWidth(gap)) * 0.44)));
736
+ const rightWidth = Math.max(24, width - visibleWidth(gap) - leftWidth);
737
+ const row = (left: string, right: string, accent = false): string => {
738
+ const leftText = accent ? this.theme.color("accent", fitText(left, leftWidth), { bold: true }) : this.theme.color("dim", fitText(left, leftWidth));
739
+ const rightText = accent ? this.theme.color("accent", fitText(right, rightWidth), { bold: true }) : this.theme.color("dim", fitText(right, rightWidth));
740
+ return `${leftText}${gap}${rightText}`;
741
+ };
742
+
743
+ const sortLabel = getSortOrderLabel(this.currentSortOrder);
744
+
745
+ return [
746
+ row("NAVIGATION", "ACTIONS", true),
747
+ row("[↑↓] Select Item", "[Enter] Apply Snapshot"),
748
+ row("[Tab/→] Switch Pane", "[s] Save Current"),
749
+ row("[Esc] Close Modal", "[r] Rename Snapshot"),
750
+ row("", "[Del/Ctrl+D] Delete Snapshot"),
751
+ row("", "[Ctrl+U] Update Snapshot"),
752
+ row("", "[Ctrl+S] Sort Profiles"),
753
+ row("", `[Sort: ${sortLabel}]`),
754
+ ];
755
+ }
756
+
757
+ private getSortedProfiles(): SavedProfile[] {
758
+ return sortProfiles(this.data, this.currentSortOrder).sortedProfiles;
759
+ }
760
+
761
+ private getSelectedProfile(): SavedProfile | null {
762
+ const selected = this.getSortedProfiles().find((profile) => profile.id === this.selectedProfileId);
763
+ return selected ?? null;
764
+ }
765
+
766
+ private resolvePaneContentRows(agentCount: number, footerRows: number): number {
767
+ const hasTerminalRows =
768
+ typeof process.stdout.rows === "number" && Number.isFinite(process.stdout.rows) && process.stdout.rows > 0;
769
+ if (!hasTerminalRows) {
770
+ return Math.max(MODAL_MIN_HEIGHT - footerRows - 6, MODAL_FALLBACK_VIEWPORT);
771
+ }
772
+
773
+ return Math.max(8, calculateModalHeight(agentCount) - footerRows - 6);
774
+ }
775
+
776
+ private getSnapshotViewportRows(): number {
777
+ return Math.max(1, this.lastPaneContentRows - 1);
778
+ }
779
+
780
+ private ensureSelectedVisible(viewportSize: number): void {
781
+ const profiles = this.getSortedProfiles();
782
+ if (profiles.length === 0) {
783
+ this.listScrollOffset = 0;
784
+ return;
785
+ }
786
+
787
+ const selectedIndex = Math.max(0, profiles.findIndex((profile) => profile.id === this.selectedProfileId));
788
+ if (selectedIndex < this.listScrollOffset) {
789
+ this.listScrollOffset = selectedIndex;
790
+ }
791
+ if (selectedIndex >= this.listScrollOffset + viewportSize) {
792
+ this.listScrollOffset = selectedIndex - viewportSize + 1;
793
+ }
794
+ }
795
+
796
+ private setFocusedPane(nextPane: FocusedPane): void {
797
+ if (this.focusedPane === nextPane) {
798
+ return;
799
+ }
800
+ this.focusedPane = nextPane;
801
+ this.requestRender();
802
+ }
803
+
804
+ private toggleFocusedPane(): void {
805
+ this.focusedPane = this.focusedPane === "snapshots" ? "details" : "snapshots";
806
+ this.requestRender();
807
+ }
808
+
809
+ private moveSelection(delta: number): void {
810
+ const profiles = this.getSortedProfiles();
811
+ if (profiles.length === 0) {
812
+ return;
813
+ }
814
+
815
+ const currentIndex = Math.max(0, profiles.findIndex((profile) => profile.id === this.selectedProfileId));
816
+ const nextIndex = clamp(currentIndex + delta, 0, profiles.length - 1);
817
+ this.selectedProfileId = profiles[nextIndex]?.id ?? null;
818
+ this.detailScrollOffset = 0;
819
+ this.message = null;
820
+ this.ensureSelectedVisible(this.getSnapshotViewportRows());
821
+ this.requestRender();
822
+ }
823
+
824
+ private moveSelectionToBoundary(boundary: "start" | "end"): void {
825
+ const profiles = this.getSortedProfiles();
826
+ if (profiles.length === 0) {
827
+ return;
828
+ }
829
+ this.selectedProfileId = boundary === "start" ? profiles[0]?.id ?? null : profiles[profiles.length - 1]?.id ?? null;
830
+ this.detailScrollOffset = 0;
831
+ this.message = null;
832
+ this.ensureSelectedVisible(this.getSnapshotViewportRows());
833
+ this.requestRender();
834
+ }
835
+
836
+ private scrollDetails(delta: number): void {
837
+ const profile = this.getSelectedProfile();
838
+ if (!profile) {
839
+ return;
840
+ }
841
+
842
+ const maxOffset = Math.max(0, profile.agents.length - this.lastDetailViewportRows);
843
+ this.detailScrollOffset = clamp(this.detailScrollOffset + delta, 0, maxOffset);
844
+ this.requestRender();
845
+ }
846
+
847
+ private scrollDetailsToBoundary(boundary: "start" | "end"): void {
848
+ const profile = this.getSelectedProfile();
849
+ if (!profile) {
850
+ return;
851
+ }
852
+
853
+ if (boundary === "start") {
854
+ this.detailScrollOffset = 0;
855
+ this.requestRender();
856
+ return;
857
+ }
858
+
859
+ this.detailScrollOffset = Math.max(0, profile.agents.length - this.lastDetailViewportRows);
860
+ this.requestRender();
861
+ }
862
+
863
+ private startRename(): void {
864
+ const profile = this.getSelectedProfile();
865
+ if (!profile) {
866
+ this.message = { text: "Select a saved snapshot before renaming.", level: "warning" };
867
+ this.requestRender();
868
+ return;
869
+ }
870
+
871
+ const input = new Input();
872
+ input.focused = true;
873
+ input.setValue(profile.name);
874
+ input.onSubmit = (value: string) => {
875
+ const targetId = this.renameTargetId;
876
+ this.renameInput = null;
877
+ this.renameTargetId = null;
878
+ if (!targetId) {
879
+ return;
880
+ }
881
+ this.runAction(`Renaming '${profile.name}'...`, async () => {
882
+ const result = await this.actions.renameProfile(targetId, value);
883
+ this.data = result.data;
884
+ this.selectedProfileId = result.selectedProfileId ?? targetId;
885
+ this.message = { text: result.message, level: "info" };
886
+ });
887
+ };
888
+ input.onEscape = () => {
889
+ this.renameInput = null;
890
+ this.renameTargetId = null;
891
+ this.message = { text: "Rename cancelled.", level: "info" };
892
+ this.requestRender();
893
+ };
894
+
895
+ this.renameInput = input;
896
+ this.renameTargetId = profile.id;
897
+ this.message = null;
898
+ this.requestRender();
899
+ }
900
+
901
+ private startConfirmation(request: ConfirmationRequest): void {
902
+ const input = new Input();
903
+ input.focused = true;
904
+ input.setValue("");
905
+ input.onSubmit = (value: string) => {
906
+ const confirmation = this.confirmation;
907
+ this.confirmation = null;
908
+
909
+ if (
910
+ !confirmation ||
911
+ confirmation.action !== request.action ||
912
+ confirmation.profileId !== request.profile.id ||
913
+ value.trim().toLowerCase() !== "yes"
914
+ ) {
915
+ this.message = { text: `${request.action === "remove" ? "Remove" : "Update"} cancelled.`, level: "info" };
916
+ this.requestRender();
917
+ return;
918
+ }
919
+
920
+ this.runAction(request.busyMessage, async () => request.onConfirm(confirmation.profileId));
921
+ };
922
+ input.onEscape = () => {
923
+ this.confirmation = null;
924
+ this.message = { text: `${request.action === "remove" ? "Remove" : "Update"} cancelled.`, level: "info" };
925
+ this.requestRender();
926
+ };
927
+
928
+ this.confirmation = {
929
+ action: request.action,
930
+ profileId: request.profile.id,
931
+ prompt: request.prompt,
932
+ input,
933
+ };
934
+ this.message = null;
935
+ this.requestRender();
936
+ }
937
+
938
+ private startRemove(): void {
939
+ const profile = this.getSelectedProfile();
940
+ if (!profile) {
941
+ this.message = { text: "Select a saved snapshot before removing.", level: "warning" };
942
+ this.requestRender();
943
+ return;
944
+ }
945
+
946
+ this.startConfirmation({
947
+ action: "remove",
948
+ profile,
949
+ prompt: `Remove profile '${profile.name}'? This cannot be undone.`,
950
+ busyMessage: `Removing '${profile.name}'...`,
951
+ onConfirm: async (targetId) => {
952
+ const result = await this.actions.removeProfile(targetId);
953
+ this.data = result.data;
954
+ this.selectedProfileId = result.selectedProfileId ?? this.getSortedProfiles()[0]?.id ?? null;
955
+ this.detailScrollOffset = 0;
956
+ this.message = { text: result.message, level: "info" };
957
+ },
958
+ });
959
+ }
960
+
961
+ private addCurrentProfile(): void {
962
+ const activeHint = this.activeAgentName ? ` from ${this.activeAgentName}` : "";
963
+ this.runAction(`Capturing current snapshot${activeHint}...`, async () => {
964
+ const result = await this.actions.addCurrentProfile();
965
+ this.data = result.data;
966
+ this.selectedProfileId = result.selectedProfileId ?? this.selectedProfileId;
967
+ this.detailScrollOffset = 0;
968
+ this.message = { text: result.message, level: "info" };
969
+ });
970
+ }
971
+
972
+ private startUpdate(): void {
973
+ const profile = this.getSelectedProfile();
974
+ if (!profile) {
975
+ this.message = { text: "Select a saved snapshot before updating.", level: "warning" };
976
+ this.requestRender();
977
+ return;
978
+ }
979
+
980
+ const currentAgentCount = profile.agents.length;
981
+ this.startConfirmation({
982
+ action: "update",
983
+ profile,
984
+ prompt: `Update '${profile.name}' with current agent state? This will overwrite ${currentAgentCount} agents.`,
985
+ busyMessage: `Updating '${profile.name}' with current agent state...`,
986
+ onConfirm: async (targetId) => {
987
+ const result = await this.actions.updateProfile(targetId);
988
+ this.data = result.data;
989
+ this.selectedProfileId = result.selectedProfileId ?? targetId;
990
+ this.detailScrollOffset = 0;
991
+ this.message = { text: result.message, level: "info" };
992
+ },
993
+ });
994
+ }
995
+
996
+ private applySelectedProfile(): void {
997
+ const profile = this.getSelectedProfile();
998
+ if (!profile) {
999
+ this.message = { text: "Select a saved snapshot before applying.", level: "warning" };
1000
+ this.requestRender();
1001
+ return;
1002
+ }
1003
+
1004
+ this.runAction(`Applying '${profile.name}' across saved agent files...`, async () => {
1005
+ const outcome = await this.actions.applyProfile(profile.id);
1006
+ this.finish({
1007
+ type: "applied",
1008
+ outcome,
1009
+ });
1010
+ });
1011
+ }
1012
+
1013
+ private runAction(label: string, action: () => Promise<void>): void {
1014
+ if (this.busyMessage) {
1015
+ return;
1016
+ }
1017
+
1018
+ this.busyMessage = label;
1019
+ this.requestRender();
1020
+ void action()
1021
+ .catch((error: unknown) => {
1022
+ this.message = { text: toErrorMessage(error), level: "error" };
1023
+ })
1024
+ .finally(() => {
1025
+ this.busyMessage = null;
1026
+ this.requestRender();
1027
+ });
1028
+ }
1029
+
1030
+ private finish(result: ProfileModalResult): void {
1031
+ if (this.finished) {
1032
+ return;
1033
+ }
1034
+ this.finished = true;
1035
+ this.done(result);
1036
+ }
1037
+
1038
+ private getSortMenuOptions(): SortMenuOption[] {
1039
+ return getAvailableSortOrders().map((option) => ({
1040
+ ...option,
1041
+ isSelected: this.currentSortOrder === option.order,
1042
+ }));
1043
+ }
1044
+
1045
+ private toggleSortMenu(): void {
1046
+ if (this.sortMenuOpen) {
1047
+ this.closeSortMenu();
1048
+ } else {
1049
+ this.openSortMenu();
1050
+ }
1051
+ }
1052
+
1053
+ private openSortMenu(): void {
1054
+ const options = this.getSortMenuOptions();
1055
+ const currentIndex = options.findIndex((option) => option.order === this.currentSortOrder);
1056
+ this.sortMenuOpen = true;
1057
+ this.sortMenuSelectedIndex = Math.max(0, currentIndex);
1058
+ this.requestRender();
1059
+ }
1060
+
1061
+ private closeSortMenu(): void {
1062
+ this.sortMenuOpen = false;
1063
+ this.sortMenuSelectedIndex = 0;
1064
+ this.requestRender();
1065
+ }
1066
+
1067
+ private handleSortMenuInput(data: string): void {
1068
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
1069
+ this.closeSortMenu();
1070
+ return;
1071
+ }
1072
+
1073
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
1074
+ this.sortMenuSelectedIndex = Math.max(0, this.sortMenuSelectedIndex - 1);
1075
+ this.requestRender();
1076
+ return;
1077
+ }
1078
+
1079
+ if (matchesKey(data, "down") || matchesKey(data, "j")) {
1080
+ const lastIndex = Math.max(0, this.getSortMenuOptions().length - 1);
1081
+ this.sortMenuSelectedIndex = Math.min(lastIndex, this.sortMenuSelectedIndex + 1);
1082
+ this.requestRender();
1083
+ return;
1084
+ }
1085
+
1086
+ if (matchesKey(data, "return")) {
1087
+ this.applySortFromMenu();
1088
+ return;
1089
+ }
1090
+ }
1091
+
1092
+ private applySortFromMenu(): void {
1093
+ const options = this.getSortMenuOptions();
1094
+ const selected = options[this.sortMenuSelectedIndex];
1095
+ if (!selected) {
1096
+ this.closeSortMenu();
1097
+ return;
1098
+ }
1099
+
1100
+ this.currentSortOrder = selected.order;
1101
+ persistSortOrder(selected.order);
1102
+ this.detailScrollOffset = 0;
1103
+ this.ensureSelectedVisible(this.getSnapshotViewportRows());
1104
+ this.message = { text: `Sorted by ${selected.label}`, level: "info" };
1105
+ this.closeSortMenu();
1106
+ }
1107
+
1108
+ private buildSortMenuLines(width: number): string[] {
1109
+ if (!this.sortMenuOpen) {
1110
+ return [];
1111
+ }
1112
+
1113
+ const options = this.getSortMenuOptions();
1114
+ const menuWidth = clamp(width - 2, 32, 54);
1115
+ const innerWidth = Math.max(1, menuWidth - 2);
1116
+ const lines: string[] = [];
1117
+ const title = this.theme.color("accent", " SORT PROFILES ", { bold: true });
1118
+ const titleFill = Math.max(0, innerWidth - visibleWidth(title));
1119
+ lines.push(`${BOX.CORNER_TL}${title}${BOX.H_LINE.repeat(titleFill)}${BOX.CORNER_TR}`);
1120
+
1121
+ for (let i = 0; i < options.length; i++) {
1122
+ const option = options[i];
1123
+ const isSelected = i === this.sortMenuSelectedIndex;
1124
+ const marker = isSelected ? ">" : " ";
1125
+ const checkmark = option.isSelected ? "✓" : " ";
1126
+ const label = `${marker} [${checkmark}] ${option.label}`;
1127
+ const padded = fitText(label, innerWidth);
1128
+ const styled = isSelected
1129
+ ? this.theme.color("selectedText", padded, { background: "selectedBg", bold: true })
1130
+ : this.theme.color("text", padded);
1131
+ lines.push(`${BOX.V_LINE}${styled}${BOX.V_LINE}`);
1132
+ }
1133
+
1134
+ const hint = "[Enter] Apply [Esc] Cancel";
1135
+ lines.push(`${BOX.V_LINE}${this.theme.color("dim", fitText(hint, innerWidth))}${BOX.V_LINE}`);
1136
+ lines.push(`${BOX.CORNER_BL}${BOX.H_LINE.repeat(innerWidth)}${BOX.CORNER_BR}`);
1137
+
1138
+ return lines.map((line) => centerLineInWidth(line, width));
1139
+ }
1140
+ }
1141
+
1142
+ export async function openProfilesModal(
1143
+ ctx: ExtensionCommandContext,
1144
+ data: ProfilesFile,
1145
+ activeAgentName: string | null,
1146
+ actions: ProfileModalActions,
1147
+ ): Promise<ProfileModalResult> {
1148
+ const overlayOptions = resolveModalOverlayOptions(getMaximumContentWidth(data));
1149
+
1150
+ return await ctx.ui.custom<ProfileModalResult>(
1151
+ (
1152
+ tui: { requestRender(): void },
1153
+ theme: ThemeLike,
1154
+ _keybindings: unknown,
1155
+ done: (result: ProfileModalResult) => void,
1156
+ ) => {
1157
+ const resolvedTheme = loadModalTheme(theme);
1158
+ const contentInstance = new ProfileListModal(data, resolvedTheme, actions, activeAgentName, done, () => tui.requestRender());
1159
+
1160
+ return {
1161
+ render(width: number): string[] {
1162
+ return renderOuterFrame(contentInstance.render(Math.max(1, width - 2)), width, "MODEL PROFILES", resolvedTheme);
1163
+ },
1164
+ invalidate(): void {
1165
+ contentInstance.invalidate();
1166
+ },
1167
+ handleInput(input: string): void {
1168
+ contentInstance.handleInput(input);
1169
+ tui.requestRender();
1170
+ },
1171
+ };
1172
+ },
1173
+ { overlay: true, overlayOptions },
1174
+ );
1175
+ }