@pi-archimedes/subagent 2.1.0 → 2.3.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,1442 @@
1
+ /**
2
+ * Agent Manager TUI panel.
3
+ * Overlay with 5 screens: List, Detail, Edit, Name Input, Confirm Delete.
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import {
9
+ matchesKey,
10
+ Key,
11
+ truncateToWidth,
12
+ CURSOR_MARKER,
13
+ } from "@earendil-works/pi-tui";
14
+ import type { AgentConfig } from "./agents.js";
15
+ import { discoverAgentsAll } from "./agents.js";
16
+ import { validateAgentName } from "./frontmatter-io.js";
17
+ import {
18
+ visibleWidth,
19
+ padEnd,
20
+ wrapText,
21
+ renderHeader,
22
+ renderFooter,
23
+ wrapWithBorder,
24
+ } from "@pi-archimedes/core/overlay";
25
+ import { saveAgent, type ManagerState } from "./agent-store.js";
26
+
27
+ // ── Screen constants ────────────────────────────────────────────────────────
28
+
29
+ const LIST_VIEWPORT = 8;
30
+ const DETAIL_VIEWPORT_HEIGHT = 14;
31
+ const EDIT_PROMPT_VIEWPORT_HEIGHT = 8;
32
+ const MODEL_SELECTOR_HEIGHT = 10;
33
+ const TOOL_PICKER_HEIGHT = 14;
34
+ const EDIT_FIELDS = ["name", "description", "tools", "model", "thinking"] as const;
35
+ type EditField = (typeof EDIT_FIELDS)[number];
36
+
37
+ // ── Theme helper type ───────────────────────────────────────────────────────
38
+
39
+ interface Theme {
40
+ fg(token: string, text: string): string;
41
+ bold(text: string): string;
42
+ }
43
+
44
+ // ── TUI context ─────────────────────────────────────────────────────────────
45
+
46
+ interface TUIContext {
47
+ requestRender(): void;
48
+ }
49
+
50
+ interface ModelInfo {
51
+ id: string;
52
+ provider: string;
53
+ fullId: string;
54
+ }
55
+
56
+ interface ToolInfo {
57
+ name: string;
58
+ description: string;
59
+ }
60
+
61
+ // ── Component return type ───────────────────────────────────────────────────
62
+
63
+ interface Component {
64
+ render(width: number): string[];
65
+ handleInput(data: string): void;
66
+ invalidate(): void;
67
+ dispose(): void;
68
+ }
69
+
70
+ // ── Helper functions ────────────────────────────────────────────────────────
71
+
72
+ function fuzzyFilter(items: AgentConfig[], query: string): AgentConfig[] {
73
+ if (!query) return items;
74
+ const q = query.toLowerCase();
75
+ return items.filter(
76
+ (a) =>
77
+ a.name.toLowerCase().includes(q) ||
78
+ a.description.toLowerCase().includes(q),
79
+ );
80
+ }
81
+
82
+ function scopeLabel(source: "global" | "user" | "project"): string {
83
+ if (source === "global") return "home";
84
+ return source === "user" ? "user" : "proj";
85
+ }
86
+
87
+ function agentModel(a: AgentConfig): string {
88
+ return a.model ?? "default";
89
+ }
90
+
91
+ function filterModels(models: ModelInfo[], query: string): ModelInfo[] {
92
+ if (!query) return models;
93
+ const q = query.toLowerCase();
94
+ return models.filter(
95
+ (m) =>
96
+ m.fullId.toLowerCase().includes(q) ||
97
+ m.id.toLowerCase().includes(q) ||
98
+ m.provider.toLowerCase().includes(q),
99
+ );
100
+ }
101
+
102
+ // ── List screen ─────────────────────────────────────────────────────────────
103
+
104
+ function renderList(state: ManagerState, width: number, theme: Theme): string[] {
105
+ const lines: string[] = [];
106
+ const filtered = fuzzyFilter(state.agents, state.filterQuery);
107
+
108
+ // Header
109
+ lines.push(renderHeader(` Agents [${state.agents.length}] `, width, theme));
110
+ lines.push(padEnd("", width));
111
+
112
+ // Search bar
113
+ if (state.filterMode || state.filterQuery.length > 0) {
114
+ const cursor = state.filterMode ? CURSOR_MARKER : "";
115
+ const queryText = state.filterQuery.length > 0 ? state.filterQuery : "type to filter...";
116
+ const placeholder = state.filterQuery.length === 0;
117
+ const searchLine = `◎ ${placeholder ? theme.fg("dim", queryText) : queryText}${cursor}`;
118
+ lines.push(padEnd(searchLine, width));
119
+ } else {
120
+ lines.push(padEnd(`◎ ${theme.fg("dim", "type to filter...")}`, width));
121
+ }
122
+ lines.push(padEnd("", width));
123
+
124
+ // Agent rows or empty state
125
+ const start = state.listScroll;
126
+ const end = Math.min(start + LIST_VIEWPORT, filtered.length);
127
+
128
+ if (filtered.length === 0) {
129
+ lines.push(padEnd("", width));
130
+ lines.push(padEnd(theme.fg("dim", "No agents found"), width));
131
+ lines.push(padEnd(theme.fg("dim", "Press n to create your first agent"), width));
132
+ } else {
133
+ for (let i = start; i < end; i++) {
134
+ const agent = filtered[i];
135
+ if (!agent) continue;
136
+ const isCursor = i === state.listCursor;
137
+ const cursorMark = isCursor ? ">" : " ";
138
+
139
+ const name = truncateToWidth(agent.name, 16);
140
+ const model = truncateToWidth(agentModel(agent), 12);
141
+ const scope = `[${scopeLabel(agent.source)}]`;
142
+ const desc = truncateToWidth(agent.description, Math.max(1, width - 1 - 16 - 1 - 12 - 1 - 8 - 1));
143
+
144
+ const nameCol = isCursor
145
+ ? theme.fg("accent", padEnd(name, 16))
146
+ : padEnd(name, 16);
147
+ const modelCol = theme.fg("dim", padEnd(model, 12));
148
+ const scopeCol = theme.fg("dim", padEnd(scope, 8));
149
+ const descCol = theme.fg("dim", desc);
150
+
151
+ const line = `${cursorMark} ${nameCol} ${modelCol} ${scopeCol} ${descCol}`;
152
+ lines.push(padEnd(line, width));
153
+ }
154
+ }
155
+
156
+ // Fill remaining viewport rows
157
+ while (lines.length < 4 + LIST_VIEWPORT + 2) {
158
+ lines.push(padEnd("", width));
159
+ }
160
+
161
+ // Preview bar
162
+ if (filtered.length > 0 && state.listCursor >= 0 && state.listCursor < filtered.length) {
163
+ const previewAgent = filtered[state.listCursor];
164
+ if (!previewAgent) {
165
+ lines.push(padEnd("", width));
166
+ } else {
167
+ const preview = theme.fg(
168
+ "dim",
169
+ truncateToWidth(`Preview: ${previewAgent.description}`, width),
170
+ );
171
+ lines.push(preview);
172
+ }
173
+ }
174
+
175
+ // Footer
176
+ lines.push(renderFooter(" [enter] view [n] new [c] clone [d] delete [/] search [esc] close ", width, theme));
177
+
178
+ return lines;
179
+ }
180
+
181
+ function handleListInput(
182
+ state: ManagerState,
183
+ data: string,
184
+ done: () => void,
185
+ requestRender: () => void,
186
+ ): "close" | void {
187
+ const filtered = fuzzyFilter(state.agents, state.filterQuery);
188
+
189
+ if (matchesKey(data, Key.up)) {
190
+ if (state.listCursor > 0) {
191
+ state.listCursor--;
192
+ if (state.listCursor < state.listScroll) {
193
+ state.listScroll = state.listCursor;
194
+ }
195
+ requestRender();
196
+ }
197
+ } else if (matchesKey(data, Key.down)) {
198
+ if (state.listCursor < filtered.length - 1) {
199
+ state.listCursor++;
200
+ if (state.listCursor >= state.listScroll + LIST_VIEWPORT) {
201
+ state.listScroll = state.listCursor - LIST_VIEWPORT + 1;
202
+ }
203
+ requestRender();
204
+ }
205
+ } else if (matchesKey(data, Key.enter)) {
206
+ const selected = filtered[state.listCursor];
207
+ if (selected) {
208
+ state.screen = "detail";
209
+ state.detailAgent = selected;
210
+ state.detailScroll = 0;
211
+ requestRender();
212
+ }
213
+ } else if (matchesKey(data, "n")) {
214
+ state.screen = "name-input";
215
+ state.nameInputMode = "new";
216
+ state.nameInputBuffer = "";
217
+ state.nameInputCursor = 0;
218
+ state.nameInputScope = "user";
219
+ state.nameInputSource = null;
220
+ state.nameInputError = null;
221
+ state.isNew = true;
222
+ requestRender();
223
+ } else if (matchesKey(data, "c")) {
224
+ const source = filtered[state.listCursor];
225
+ if (source) {
226
+ state.screen = "name-input";
227
+ state.nameInputMode = "clone";
228
+ state.nameInputBuffer = `${source.name}-copy`;
229
+ state.nameInputCursor = state.nameInputBuffer.length;
230
+ state.nameInputScope = source.source;
231
+ state.nameInputSource = source;
232
+ state.nameInputError = null;
233
+ state.isNew = true;
234
+ requestRender();
235
+ }
236
+ } else if (matchesKey(data, "d")) {
237
+ const target = filtered[state.listCursor];
238
+ if (target) {
239
+ state.screen = "confirm-delete";
240
+ state.deleteTarget = target;
241
+ state.deleteFromScreen = "list";
242
+ requestRender();
243
+ }
244
+ } else if (matchesKey(data, "/")) {
245
+ state.filterMode = true;
246
+ requestRender();
247
+ } else if (matchesKey(data, Key.backspace)) {
248
+ if (state.filterQuery.length > 0) {
249
+ state.filterQuery = state.filterQuery.slice(0, -1);
250
+ state.listCursor = 0;
251
+ state.listScroll = 0;
252
+ requestRender();
253
+ }
254
+ } else if (matchesKey(data, Key.escape)) {
255
+ if (state.filterQuery.length > 0) {
256
+ state.filterQuery = "";
257
+ state.filterMode = false;
258
+ state.listCursor = 0;
259
+ state.listScroll = 0;
260
+ requestRender();
261
+ } else {
262
+ return "close";
263
+ }
264
+ } else {
265
+ // Single printable char
266
+ if (state.filterMode || state.filterQuery.length > 0) {
267
+ if (data.length === 1 && data >= " " && data <= "~") {
268
+ state.filterQuery += data;
269
+ state.filterMode = false;
270
+ state.listCursor = 0;
271
+ state.listScroll = 0;
272
+ requestRender();
273
+ }
274
+ }
275
+ }
276
+ }
277
+
278
+ // ── Detail screen ───────────────────────────────────────────────────────────
279
+
280
+ function renderDetail(state: ManagerState, width: number, theme: Theme): string[] {
281
+ const lines: string[] = [];
282
+ const agent = state.detailAgent;
283
+ if (!agent) {
284
+ lines.push(renderHeader(" No agent selected ", width, theme));
285
+ lines.push(renderFooter(" [esc] back ", width, theme));
286
+ return lines;
287
+ }
288
+
289
+ // Header
290
+ lines.push(renderHeader(` Agent: ${agent.name} [${scopeLabel(agent.source)}] `, width, theme));
291
+
292
+ // Frontmatter section
293
+ const fieldLines: string[] = [];
294
+ fieldLines.push(theme.fg("accent", `name:`) + ` ${agent.name}`);
295
+ fieldLines.push(theme.fg("accent", `description:`) + ` ${agent.description}`);
296
+ if (agent.tools && agent.tools.length > 0) {
297
+ fieldLines.push(theme.fg("accent", `tools:`) + ` ${agent.tools.join(", ")}`);
298
+ } else {
299
+ fieldLines.push(theme.fg("accent", `tools:`) + ` ${theme.fg("dim", "(none)")}`);
300
+ }
301
+ fieldLines.push(theme.fg("accent", `model:`) + ` ${agentModel(agent)}`);
302
+ fieldLines.push(theme.fg("accent", `thinking:`) + ` ${agent.thinking ?? theme.fg("dim", "(none)")}`);
303
+
304
+ for (const fl of fieldLines) {
305
+ lines.push(padEnd(fl, width));
306
+ }
307
+
308
+ // Extra fields
309
+ if (agent.extraFields && Object.keys(agent.extraFields).length > 0) {
310
+ lines.push(padEnd(theme.fg("dim", "─".repeat(width)), width));
311
+ for (const [key, value] of Object.entries(agent.extraFields).sort()) {
312
+ lines.push(padEnd(theme.fg("dim", `${key}: ${value}`), width));
313
+ }
314
+ }
315
+
316
+ // Body separator
317
+ lines.push(padEnd(theme.fg("dim", "---"), width));
318
+
319
+ // Body (systemPrompt) - scrollable
320
+ const bodyLines = wrapText(agent.systemPrompt, width);
321
+ const bodyViewport = Math.max(6, 14 - lines.length);
322
+ const bodyStart = state.detailScroll;
323
+ const bodyEnd = Math.min(bodyStart + bodyViewport, bodyLines.length);
324
+
325
+ // Scroll indicator: more above
326
+ if (bodyStart > 0) {
327
+ lines.push(padEnd(theme.fg("dim", `↑ ${bodyStart} more`), width));
328
+ }
329
+
330
+ for (let i = bodyStart; i < bodyEnd; i++) {
331
+ const line = bodyLines[i];
332
+ if (line != null) lines.push(padEnd(line, width));
333
+ }
334
+
335
+ // Scroll indicator: more below
336
+ const remainingBelow = bodyLines.length - bodyEnd;
337
+ if (remainingBelow > 0) {
338
+ lines.push(padEnd(theme.fg("dim", `↓ ${remainingBelow} more`), width));
339
+ }
340
+
341
+ // Footer
342
+ lines.push(renderFooter(" [e] edit [d] delete [esc] back ", width, theme));
343
+
344
+ return lines;
345
+ }
346
+
347
+ function handleDetailInput(
348
+ state: ManagerState,
349
+ data: string,
350
+ requestRender: () => void,
351
+ ): void {
352
+ if (matchesKey(data, Key.up)) {
353
+ if (state.detailScroll > 0) {
354
+ state.detailScroll--;
355
+ requestRender();
356
+ }
357
+ } else if (matchesKey(data, Key.down)) {
358
+ if (state.detailAgent) {
359
+ const bodyLines = wrapText(state.detailAgent.systemPrompt, state.lastContentWidth);
360
+ const bodyViewport = Math.max(6, DETAIL_VIEWPORT_HEIGHT - 9);
361
+ if (state.detailScroll < bodyLines.length - bodyViewport) {
362
+ state.detailScroll++;
363
+ requestRender();
364
+ }
365
+ }
366
+ requestRender();
367
+ } else if (matchesKey(data, "e")) {
368
+ // Create mutable copy
369
+ const agent = state.detailAgent;
370
+ if (agent) {
371
+ state.screen = "edit";
372
+ const copy: AgentConfig = { ...agent };
373
+ if (agent.tools) copy.tools = [...agent.tools];
374
+ state.editAgent = copy;
375
+ state.editOriginal = { ...agent };
376
+ if (agent.tools) state.editOriginal.tools = [...agent.tools];
377
+ state.editReturnScreen = "detail";
378
+ state.editFieldIndex = 0;
379
+ state.editInField = false;
380
+ state.editDirty = false;
381
+ state.editFieldCursor = 0;
382
+ state.editPromptMode = false;
383
+ state.editPromptCursor = 0;
384
+ state.editPromptScroll = 0;
385
+ state.editDiscardPrompt = false;
386
+ state.editError = null;
387
+ state.isNew = false;
388
+ requestRender();
389
+ }
390
+ } else if (matchesKey(data, "d")) {
391
+ state.screen = "confirm-delete";
392
+ state.deleteTarget = state.detailAgent;
393
+ state.deleteFromScreen = "detail";
394
+ requestRender();
395
+ } else if (matchesKey(data, Key.escape)) {
396
+ state.screen = "list";
397
+ requestRender();
398
+ }
399
+ }
400
+
401
+ // ── Edit screen ─────────────────────────────────────────────────────────────
402
+
403
+ function renderEdit(state: ManagerState, width: number, theme: Theme): string[] {
404
+ const lines: string[] = [];
405
+ const agent = state.editAgent;
406
+ if (!agent) return lines;
407
+
408
+ // Discard prompt
409
+ if (state.editDiscardPrompt) {
410
+ lines.push(renderHeader(" Discard changes? ", width, theme));
411
+ lines.push(padEnd("", width));
412
+ lines.push(padEnd(theme.fg("dim", "Unsaved changes will be lost."), width));
413
+ lines.push(padEnd("", width));
414
+ lines.push(renderFooter(" [y] discard [n / esc] keep editing ", width, theme));
415
+ return lines;
416
+ }
417
+
418
+ // Model picker
419
+ if (state.modelPickerOpen) {
420
+ return renderModelPicker(state, width, theme);
421
+ }
422
+
423
+ // Tool picker
424
+ if (state.toolPickerOpen) {
425
+ return renderToolPicker(state, width, theme);
426
+ }
427
+
428
+ // Header
429
+ const dirtyMark = state.editDirty ? " *" : "";
430
+ lines.push(renderHeader(` Edit: ${agent.name}${dirtyMark} `, width, theme));
431
+
432
+ // Error line
433
+ if (state.editError) {
434
+ lines.push(padEnd(theme.fg("error", `Error: ${state.editError}`), width));
435
+ }
436
+
437
+ // System prompt edit mode
438
+ if (state.editPromptMode) {
439
+ lines.push(padEnd(theme.fg("dim", "systemPrompt:"), width));
440
+ const promptLines = wrapText(agent.systemPrompt, width);
441
+ const promptViewport = Math.max(6, 14 - lines.length - 2);
442
+ const promptStart = state.editPromptScroll;
443
+ const promptEnd = Math.min(promptStart + promptViewport, promptLines.length);
444
+
445
+ for (let i = promptStart; i < promptEnd; i++) {
446
+ const line = promptLines[i];
447
+ if (line != null) lines.push(padEnd(line, width));
448
+ }
449
+
450
+ // Hint line
451
+ lines.push(padEnd(theme.fg("dim", " [↑↓] scroll [ctrl+s] save [esc] done "), width));
452
+ return lines;
453
+ }
454
+
455
+ // Field list
456
+ const fields: { key: EditField; value: string; empty: boolean }[] = EDIT_FIELDS.map((key) => {
457
+ let value: string;
458
+ let empty: boolean;
459
+ switch (key) {
460
+ case "name":
461
+ value = agent.name;
462
+ empty = false;
463
+ break;
464
+ case "description":
465
+ value = agent.description;
466
+ empty = value.length === 0;
467
+ break;
468
+ case "tools":
469
+ value = agent.tools ? agent.tools.join(", ") : "";
470
+ empty = value.length === 0;
471
+ break;
472
+ case "model":
473
+ value = agent.model ?? "";
474
+ empty = value.length === 0;
475
+ break;
476
+ case "thinking":
477
+ value = agent.thinking ?? "";
478
+ empty = value.length === 0;
479
+ break;
480
+ }
481
+ return { key, value, empty };
482
+ });
483
+
484
+ for (let i = 0; i < fields.length; i++) {
485
+ const field = fields[i];
486
+ if (!field) continue;
487
+ const { key, value, empty } = field;
488
+ const isCurrent = i === state.editFieldIndex;
489
+ const prefix = isCurrent ? "> " : " ";
490
+
491
+ if (isCurrent && state.editInField) {
492
+ // In-field editing
493
+ const label = `${key}: `;
494
+ const labelWidth = visibleWidth(label);
495
+ const availWidth = width - labelWidth - 2; // prefix takes 2
496
+
497
+ if (key === "description") {
498
+ // Multi-line description editing (3 lines viewport)
499
+ const descLines = wrapText(value, availWidth);
500
+ lines.push(padEnd(`${prefix}${label}`, width));
501
+ for (let j = 0; j < 3 && j < descLines.length; j++) {
502
+ const descLine = descLines[j];
503
+ const lineContent = padEnd(descLine ?? "", availWidth);
504
+ // Place cursor at end of last visible line
505
+ const displayLine = j === 2 || j === descLines.length - 1
506
+ ? lineContent + CURSOR_MARKER
507
+ : lineContent;
508
+ lines.push(padEnd(` ${displayLine}`, width));
509
+ }
510
+ } else {
511
+ // Single-line editing
512
+ const truncated = truncateToWidth(value, availWidth);
513
+ const inputLine = `${prefix}${label}${truncated}${CURSOR_MARKER}`;
514
+ lines.push(padEnd(inputLine, width));
515
+ }
516
+ } else {
517
+ // Normal field display
518
+ const label = `${key}: `;
519
+ const displayValue = empty
520
+ ? theme.fg("dim", "(not set)")
521
+ : truncateToWidth(value, width - visibleWidth(prefix + label));
522
+ const display = isCurrent
523
+ ? theme.fg("accent", `${prefix}${label}`) + displayValue
524
+ : `${prefix}${label}${displayValue}`;
525
+ lines.push(padEnd(display, width));
526
+ }
527
+ }
528
+
529
+ // Hint
530
+ lines.push(renderFooter(" [↑↓] fields [enter] edit [t] tools [m] model [p] prompt [ctrl+s] save [esc] back ", width, theme));
531
+
532
+ return lines;
533
+ }
534
+
535
+ function renderModelPicker(state: ManagerState, width: number, theme: Theme): string[] {
536
+ const lines: string[] = [];
537
+
538
+ // Header
539
+ lines.push(renderHeader(" Select Model ", width, theme));
540
+ lines.push(padEnd("", width));
541
+
542
+ // Search box
543
+ const searchLine = `Search: ${state.modelSearchQuery}${CURSOR_MARKER}`;
544
+ lines.push(padEnd(searchLine, width));
545
+ lines.push(padEnd("", width));
546
+
547
+ // Current model
548
+ const currentModel = state.editAgent ? agentModel(state.editAgent) : "default";
549
+ lines.push(
550
+ padEnd(theme.fg("dim", "Current: ") + theme.fg("warning", currentModel), width),
551
+ );
552
+ lines.push(padEnd("", width));
553
+
554
+ // Model list
555
+ const list = state.filteredModels;
556
+ if (list.length === 0) {
557
+ lines.push(padEnd(theme.fg("dim", "No matching models"), width));
558
+ } else {
559
+ let startIdx = 0;
560
+ if (list.length > MODEL_SELECTOR_HEIGHT) {
561
+ startIdx = Math.max(0, state.modelCursor - Math.floor(MODEL_SELECTOR_HEIGHT / 2));
562
+ startIdx = Math.min(startIdx, list.length - MODEL_SELECTOR_HEIGHT);
563
+ }
564
+ const endIdx = Math.min(startIdx + MODEL_SELECTOR_HEIGHT, list.length);
565
+
566
+ if (startIdx > 0) {
567
+ lines.push(padEnd(theme.fg("dim", `↑ ${startIdx} more`), width));
568
+ }
569
+
570
+ for (let i = startIdx; i < endIdx; i++) {
571
+ const model = list[i];
572
+ if (!model) continue;
573
+ const isSelected = i === state.modelCursor;
574
+ const prefix = isSelected ? theme.fg("accent", "> ") : " ";
575
+ const modelText = isSelected ? theme.fg("accent", model.id) : model.id;
576
+ const provider = theme.fg("dim", ` [${model.provider}]`);
577
+ lines.push(padEnd(`${prefix}${modelText}${provider}`, width));
578
+ }
579
+
580
+ const remaining = list.length - endIdx;
581
+ if (remaining > 0) {
582
+ lines.push(padEnd(theme.fg("dim", `↓ ${remaining} more`), width));
583
+ }
584
+ }
585
+
586
+ // Pad to fixed height
587
+ while (lines.length < 18) {
588
+ lines.push(padEnd("", width));
589
+ }
590
+
591
+ // Footer
592
+ lines.push(renderFooter(" [enter] select [esc] cancel type to search ", width, theme));
593
+
594
+ return lines;
595
+ }
596
+
597
+ function renderToolPicker(state: ManagerState, width: number, theme: Theme): string[] {
598
+ const lines: string[] = [];
599
+
600
+ // Header
601
+ lines.push(renderHeader(" Select Tools ", width, theme));
602
+ lines.push(padEnd("", width));
603
+
604
+ // Search box
605
+ const searchLine = `Search: ${state.toolSearch}`;
606
+ lines.push(padEnd(searchLine, width));
607
+ lines.push(padEnd("", width));
608
+
609
+ // Help line
610
+ lines.push(padEnd(theme.fg("dim", "space toggle · enter confirm · esc cancel · ↑↓ navigate"), width));
611
+ lines.push(padEnd("", width));
612
+
613
+ // Tool list
614
+ const list = state.filteredTools;
615
+ if (list.length === 0) {
616
+ lines.push(padEnd(theme.fg("dim", "No matching tools"), width));
617
+ } else {
618
+ let startIdx = 0;
619
+ if (list.length > TOOL_PICKER_HEIGHT) {
620
+ startIdx = Math.max(0, state.toolCursor - Math.floor(TOOL_PICKER_HEIGHT / 2));
621
+ startIdx = Math.min(startIdx, list.length - TOOL_PICKER_HEIGHT);
622
+ }
623
+ const endIdx = Math.min(startIdx + TOOL_PICKER_HEIGHT, list.length);
624
+
625
+ if (startIdx > 0) {
626
+ lines.push(padEnd(theme.fg("dim", `↑ ${startIdx} more`), width));
627
+ }
628
+
629
+ for (let i = startIdx; i < endIdx; i++) {
630
+ const tool = list[i];
631
+ if (!tool) continue;
632
+ const isCursor = i === state.toolCursor;
633
+ const checked = state.toolSelected.has(tool.name);
634
+ const cursor = isCursor ? theme.fg("accent", "> ") : " ";
635
+ const box = checked ? theme.fg("accent", "[x] ") : "[ ] ";
636
+ const nameText = isCursor ? theme.fg("accent", tool.name) : tool.name;
637
+ const desc = tool.description ? ` ${theme.fg("dim", "— " + tool.description)}` : "";
638
+ const rowText = cursor + box + nameText + desc;
639
+ lines.push(padEnd(truncateToWidth(rowText, width), width));
640
+ }
641
+
642
+ const remaining = list.length - endIdx;
643
+ if (remaining > 0) {
644
+ lines.push(padEnd(theme.fg("dim", `↓ ${remaining} more`), width));
645
+ }
646
+ }
647
+
648
+ // Pad to fixed height
649
+ while (lines.length < 18) {
650
+ lines.push(padEnd("", width));
651
+ }
652
+
653
+ // Footer
654
+ lines.push(renderFooter(" [enter] confirm [esc] cancel [space] toggle [type] search ", width, theme));
655
+
656
+ return lines;
657
+ }
658
+
659
+ function handleEditInput(
660
+ state: ManagerState,
661
+ data: string,
662
+ requestRender: () => void,
663
+ ): void {
664
+ // Discard prompt handling
665
+ if (state.editDiscardPrompt) {
666
+ if (matchesKey(data, "y")) {
667
+ state.editDiscardPrompt = false;
668
+ state.editDirty = false;
669
+ // Re-read from original
670
+ if (state.editOriginal) {
671
+ const origCopy: AgentConfig = { ...state.editOriginal };
672
+ if (state.editOriginal.tools) origCopy.tools = [...state.editOriginal.tools];
673
+ state.editAgent = origCopy;
674
+ }
675
+ state.editFieldIndex = 0;
676
+ state.editInField = false;
677
+ state.editPromptMode = false;
678
+ state.editError = null;
679
+ requestRender();
680
+ } else if (matchesKey(data, "n") || matchesKey(data, Key.escape)) {
681
+ state.editDiscardPrompt = false;
682
+ requestRender();
683
+ }
684
+ return;
685
+ }
686
+
687
+ if (!state.editAgent) return;
688
+
689
+ // Model picker mode
690
+ if (state.modelPickerOpen) {
691
+ if (matchesKey(data, Key.escape)) {
692
+ state.modelPickerOpen = false;
693
+ state.modelSearchQuery = "";
694
+ requestRender();
695
+ } else if (matchesKey(data, Key.enter)) {
696
+ const selected = state.filteredModels[state.modelCursor];
697
+ if (selected) {
698
+ state.editAgent.model = selected.fullId;
699
+ state.modelPickerOpen = false;
700
+ state.modelSearchQuery = "";
701
+ state.editDirty = true;
702
+ requestRender();
703
+ }
704
+ } else if (matchesKey(data, Key.up)) {
705
+ if (state.filteredModels.length > 0) {
706
+ state.modelCursor =
707
+ state.modelCursor > 0
708
+ ? state.modelCursor - 1
709
+ : state.filteredModels.length - 1;
710
+ requestRender();
711
+ }
712
+ } else if (matchesKey(data, Key.down)) {
713
+ if (state.filteredModels.length > 0) {
714
+ state.modelCursor =
715
+ state.modelCursor < state.filteredModels.length - 1
716
+ ? state.modelCursor + 1
717
+ : 0;
718
+ requestRender();
719
+ }
720
+ } else if (matchesKey(data, Key.backspace)) {
721
+ if (state.modelSearchQuery.length > 0) {
722
+ state.modelSearchQuery = state.modelSearchQuery.slice(0, -1);
723
+ state.filteredModels = filterModels(state.models, state.modelSearchQuery);
724
+ state.modelCursor = Math.min(state.modelCursor, Math.max(0, state.filteredModels.length - 1));
725
+ requestRender();
726
+ }
727
+ } else if (data.length === 1 && data >= " " && data <= "~") {
728
+ state.modelSearchQuery += data;
729
+ state.filteredModels = filterModels(state.models, state.modelSearchQuery);
730
+ state.modelCursor = Math.min(state.modelCursor, Math.max(0, state.filteredModels.length - 1));
731
+ requestRender();
732
+ }
733
+ return;
734
+ }
735
+
736
+ // Tool picker mode
737
+ if (state.toolPickerOpen) {
738
+ if (matchesKey(data, Key.escape)) {
739
+ state.toolPickerOpen = false;
740
+ state.toolSearch = "";
741
+ requestRender();
742
+ } else if (matchesKey(data, Key.enter)) {
743
+ const names = [...state.toolSelected];
744
+ if (names.length > 0) {
745
+ state.editAgent.tools = names;
746
+ } else {
747
+ delete state.editAgent.tools;
748
+ }
749
+ state.toolPickerOpen = false;
750
+ state.toolSearch = "";
751
+ state.editDirty = true;
752
+ requestRender();
753
+ } else if (matchesKey(data, Key.up)) {
754
+ if (state.filteredTools.length > 0) {
755
+ state.toolCursor = state.toolCursor > 0 ? state.toolCursor - 1 : state.filteredTools.length - 1;
756
+ requestRender();
757
+ }
758
+ } else if (matchesKey(data, Key.down)) {
759
+ if (state.filteredTools.length > 0) {
760
+ state.toolCursor = state.toolCursor < state.filteredTools.length - 1 ? state.toolCursor + 1 : 0;
761
+ requestRender();
762
+ }
763
+ } else if (matchesKey(data, Key.pageUp)) {
764
+ if (state.filteredTools.length > 0) {
765
+ state.toolCursor = Math.max(0, state.toolCursor - TOOL_PICKER_HEIGHT);
766
+ requestRender();
767
+ }
768
+ } else if (matchesKey(data, Key.pageDown)) {
769
+ if (state.filteredTools.length > 0) {
770
+ state.toolCursor = Math.min(state.filteredTools.length - 1, state.toolCursor + TOOL_PICKER_HEIGHT);
771
+ requestRender();
772
+ }
773
+ } else if (matchesKey(data, Key.home)) {
774
+ if (state.filteredTools.length > 0) {
775
+ state.toolCursor = 0;
776
+ requestRender();
777
+ }
778
+ } else if (matchesKey(data, Key.end)) {
779
+ if (state.filteredTools.length > 0) {
780
+ state.toolCursor = state.filteredTools.length - 1;
781
+ requestRender();
782
+ }
783
+ } else if (matchesKey(data, Key.space) || matchesKey(data, Key.tab)) {
784
+ // Toggle current tool
785
+ const tool = state.filteredTools[state.toolCursor];
786
+ if (tool) {
787
+ if (state.toolSelected.has(tool.name)) {
788
+ state.toolSelected.delete(tool.name);
789
+ } else {
790
+ state.toolSelected.add(tool.name);
791
+ }
792
+ requestRender();
793
+ }
794
+ } else if (matchesKey(data, Key.backspace)) {
795
+ if (state.toolSearch.length > 0) {
796
+ state.toolSearch = state.toolSearch.slice(0, -1);
797
+ const q = state.toolSearch.toLowerCase();
798
+ state.filteredTools = state.tools.filter(
799
+ (t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q),
800
+ );
801
+ state.toolCursor = Math.min(state.toolCursor, Math.max(0, state.filteredTools.length - 1));
802
+ requestRender();
803
+ }
804
+ } else if (data.length === 1 && data >= " " && data <= "~") {
805
+ state.toolSearch += data;
806
+ const q = state.toolSearch.toLowerCase();
807
+ state.filteredTools = state.tools.filter(
808
+ (t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q),
809
+ );
810
+ state.toolCursor = Math.min(state.toolCursor, Math.max(0, state.filteredTools.length - 1));
811
+ requestRender();
812
+ }
813
+ return;
814
+ }
815
+
816
+ // System prompt edit mode
817
+ if (state.editPromptMode) {
818
+ if (matchesKey(data, Key.ctrl("s"))) {
819
+ saveAgent(state, requestRender);
820
+ } else if (matchesKey(data, Key.escape)) {
821
+ state.editPromptMode = false;
822
+ state.editDirty = true;
823
+ requestRender();
824
+ } else if (matchesKey(data, Key.enter)) {
825
+ // Insert newline at cursor
826
+ const before = state.editAgent.systemPrompt.slice(0, state.editPromptCursor);
827
+ const after = state.editAgent.systemPrompt.slice(state.editPromptCursor);
828
+ state.editAgent.systemPrompt = before + "\n" + after;
829
+ state.editPromptCursor++;
830
+ state.editDirty = true;
831
+ requestRender();
832
+ } else if (matchesKey(data, Key.up)) {
833
+ if (state.editPromptScroll > 0) {
834
+ state.editPromptScroll--;
835
+ requestRender();
836
+ }
837
+ } else if (matchesKey(data, Key.down)) {
838
+ const promptLines = wrapText(state.editAgent.systemPrompt, state.lastContentWidth);
839
+ const promptViewport = Math.max(6, EDIT_PROMPT_VIEWPORT_HEIGHT - 4 - 2);
840
+ if (state.editPromptScroll < promptLines.length - promptViewport) {
841
+ state.editPromptScroll++;
842
+ requestRender();
843
+ }
844
+ } else if (data.length === 1 && (data >= " " && data <= "~")) {
845
+ // Append char to systemPrompt at cursor
846
+ const before = state.editAgent.systemPrompt.slice(0, state.editPromptCursor);
847
+ const after = state.editAgent.systemPrompt.slice(state.editPromptCursor);
848
+ state.editAgent.systemPrompt = before + data + after;
849
+ state.editPromptCursor++;
850
+ state.editDirty = true;
851
+ requestRender();
852
+ } else if (matchesKey(data, Key.backspace)) {
853
+ if (state.editPromptCursor > 0) {
854
+ const before = state.editAgent.systemPrompt.slice(0, state.editPromptCursor - 1);
855
+ const after = state.editAgent.systemPrompt.slice(state.editPromptCursor);
856
+ state.editAgent.systemPrompt = before + after;
857
+ state.editPromptCursor--;
858
+ state.editDirty = true;
859
+ requestRender();
860
+ }
861
+ }
862
+ return;
863
+ }
864
+
865
+ // In-field edit mode
866
+ if (state.editInField) {
867
+ const field = EDIT_FIELDS[state.editFieldIndex];
868
+ if (!field) return;
869
+ if (matchesKey(data, Key.enter)) {
870
+ // Exit field edit, mark dirty
871
+ state.editInField = false;
872
+ state.editDirty = true;
873
+ requestRender();
874
+ } else if (matchesKey(data, Key.escape)) {
875
+ state.editInField = false;
876
+ state.editDirty = true;
877
+ requestRender();
878
+ } else if (matchesKey(data, Key.ctrl("a"))) {
879
+ state.editFieldCursor = 0;
880
+ requestRender();
881
+ } else if (matchesKey(data, Key.ctrl("e"))) {
882
+ const val = getFieldValue(state.editAgent, field);
883
+ state.editFieldCursor = val.length;
884
+ requestRender();
885
+ } else if (matchesKey(data, Key.left)) {
886
+ if (state.editFieldCursor > 0) {
887
+ state.editFieldCursor--;
888
+ requestRender();
889
+ }
890
+ } else if (matchesKey(data, Key.right)) {
891
+ const val = getFieldValue(state.editAgent, field);
892
+ if (state.editFieldCursor < val.length) {
893
+ state.editFieldCursor++;
894
+ requestRender();
895
+ }
896
+ } else if (matchesKey(data, Key.backspace)) {
897
+ if (state.editFieldCursor > 0) {
898
+ const val = getFieldValue(state.editAgent, field);
899
+ const newVal = val.slice(0, state.editFieldCursor - 1) + val.slice(state.editFieldCursor);
900
+ setFieldValue(state.editAgent, field, newVal);
901
+ state.editFieldCursor--;
902
+ state.editDirty = true;
903
+ requestRender();
904
+ }
905
+ } else if (data.length === 1 && data >= " " && data <= "~") {
906
+ const val = getFieldValue(state.editAgent, field);
907
+ const newVal = val.slice(0, state.editFieldCursor) + data + val.slice(state.editFieldCursor);
908
+ setFieldValue(state.editAgent, field, newVal);
909
+ state.editFieldCursor++;
910
+ state.editDirty = true;
911
+ requestRender();
912
+ }
913
+ return;
914
+ }
915
+
916
+ // Normal edit mode (field cycling)
917
+ if (matchesKey(data, Key.up)) {
918
+ if (state.editFieldIndex > 0) {
919
+ state.editFieldIndex--;
920
+ requestRender();
921
+ }
922
+ } else if (matchesKey(data, Key.down)) {
923
+ if (state.editFieldIndex < EDIT_FIELDS.length - 1) {
924
+ state.editFieldIndex++;
925
+ requestRender();
926
+ }
927
+ } else if (matchesKey(data, Key.enter)) {
928
+ const field = EDIT_FIELDS[state.editFieldIndex];
929
+ if (field === "model") {
930
+ state.modelPickerOpen = true;
931
+ state.modelSearchQuery = "";
932
+ state.filteredModels = state.models;
933
+ const current = agentModel(state.editAgent);
934
+ const idx = state.models.findIndex(
935
+ (m) => m.fullId === current || m.id === current,
936
+ );
937
+ state.modelCursor = idx >= 0 ? idx : 0;
938
+ requestRender();
939
+ } else if (field === "tools") {
940
+ state.toolPickerOpen = true;
941
+ state.toolSelected = new Set(state.editAgent.tools ?? []);
942
+ state.toolSearch = "";
943
+ state.filteredTools = state.tools;
944
+ state.toolCursor = 0;
945
+ requestRender();
946
+ } else if (field) {
947
+ state.editInField = true;
948
+ state.editFieldCursor = getFieldValue(state.editAgent, field).length;
949
+ requestRender();
950
+ }
951
+ } else if (matchesKey(data, "m")) {
952
+ const field = EDIT_FIELDS[state.editFieldIndex];
953
+ if (field === "model") {
954
+ state.modelPickerOpen = true;
955
+ state.modelSearchQuery = "";
956
+ state.filteredModels = state.models;
957
+ const current = agentModel(state.editAgent);
958
+ const idx = state.models.findIndex(
959
+ (m) => m.fullId === current || m.id === current,
960
+ );
961
+ state.modelCursor = idx >= 0 ? idx : 0;
962
+ requestRender();
963
+ }
964
+ } else if (matchesKey(data, "t")) {
965
+ const field = EDIT_FIELDS[state.editFieldIndex];
966
+ if (field === "tools") {
967
+ state.toolPickerOpen = true;
968
+ state.toolSelected = new Set(state.editAgent.tools ?? []);
969
+ state.toolSearch = "";
970
+ state.filteredTools = state.tools;
971
+ state.toolCursor = 0;
972
+ requestRender();
973
+ }
974
+ } else if (matchesKey(data, "p")) {
975
+ state.editPromptMode = true;
976
+ state.editPromptCursor = state.editAgent.systemPrompt.length;
977
+ state.editPromptScroll = 0;
978
+ requestRender();
979
+ } else if (matchesKey(data, Key.ctrl("s"))) {
980
+ saveAgent(state, requestRender);
981
+ } else if (matchesKey(data, Key.escape)) {
982
+ if (state.editDirty) {
983
+ state.editDiscardPrompt = true;
984
+ requestRender();
985
+ } else {
986
+ state.screen = state.editReturnScreen;
987
+ requestRender();
988
+ }
989
+ }
990
+ }
991
+
992
+ function getFieldValue(agent: AgentConfig, field: EditField): string {
993
+ switch (field) {
994
+ case "name":
995
+ return agent.name;
996
+ case "description":
997
+ return agent.description;
998
+ case "tools":
999
+ return agent.tools ? agent.tools.join(", ") : "";
1000
+ case "model":
1001
+ return agent.model ?? "";
1002
+ case "thinking":
1003
+ return agent.thinking ?? "";
1004
+ }
1005
+ }
1006
+
1007
+ function setFieldValue(agent: AgentConfig, field: EditField, value: string): void {
1008
+ switch (field) {
1009
+ case "name":
1010
+ agent.name = value;
1011
+ break;
1012
+ case "description":
1013
+ agent.description = value;
1014
+ break;
1015
+ case "tools": {
1016
+ const parsed = value
1017
+ ? value.split(",").map((t) => t.trim()).filter(Boolean)
1018
+ : [];
1019
+ if (parsed.length > 0) {
1020
+ agent.tools = parsed;
1021
+ } else {
1022
+ delete agent.tools;
1023
+ }
1024
+ break;
1025
+ }
1026
+ case "model":
1027
+ if (value) {
1028
+ agent.model = value;
1029
+ } else {
1030
+ delete agent.model;
1031
+ }
1032
+ break;
1033
+ case "thinking":
1034
+ if (value) {
1035
+ agent.thinking = value;
1036
+ } else {
1037
+ delete agent.thinking;
1038
+ }
1039
+ break;
1040
+ }
1041
+ }
1042
+
1043
+ // ── Name Input screen ───────────────────────────────────────────────────────
1044
+
1045
+ function renderNameInput(state: ManagerState, width: number, theme: Theme): string[] {
1046
+ const lines: string[] = [];
1047
+
1048
+ // Header
1049
+ const title = state.nameInputMode === "new" ? " New Agent " : " Clone Agent ";
1050
+ lines.push(renderHeader(title, width, theme));
1051
+
1052
+ // Label
1053
+ lines.push(padEnd(theme.fg("accent", "Name:"), width));
1054
+
1055
+ // Input box
1056
+ const boxWidth = Math.min(width - 2, 60);
1057
+ const boxInner = boxWidth - 2;
1058
+ const beforeCursor = state.nameInputBuffer.slice(0, state.nameInputCursor);
1059
+ const afterCursor = state.nameInputBuffer.slice(state.nameInputCursor);
1060
+ const inputContent = `${beforeCursor}${CURSOR_MARKER}${afterCursor}`;
1061
+ const paddedInput = padEnd(inputContent, boxInner);
1062
+ lines.push(padEnd(`│${paddedInput}│`, width));
1063
+
1064
+ // Scope indicator
1065
+ const scopeText = `Scope: [${state.nameInputScope}] [tab] toggle`;
1066
+ lines.push(padEnd(theme.fg("dim", scopeText), width));
1067
+
1068
+ // Cross-scope collision warning — check higher-precedence scopes
1069
+ const scopeOrder = ["global", "user", "project"];
1070
+ const currentIdx = scopeOrder.indexOf(state.nameInputScope);
1071
+ let collisionAgent: AgentConfig | undefined;
1072
+ let collisionScope: string | undefined;
1073
+ for (const scope of scopeOrder.slice(currentIdx + 1)) {
1074
+ const scopeAgents = scope === "global" ? state.globalAgents : scope === "user" ? state.userAgents : state.projectAgents;
1075
+ const found = scopeAgents.find((a) => a.name === state.nameInputBuffer.trim());
1076
+ if (found) {
1077
+ collisionAgent = found;
1078
+ collisionScope = scope;
1079
+ break;
1080
+ }
1081
+ }
1082
+ if (collisionAgent) {
1083
+ lines.push(padEnd(theme.fg("warning", `Warning: a ${collisionScope} agent "${collisionAgent.name}" exists and will take precedence`), width));
1084
+ } else if (state.nameInputError) {
1085
+ lines.push(padEnd(theme.fg("error", ` ${state.nameInputError}`), width));
1086
+ } else {
1087
+ lines.push(padEnd("", width));
1088
+ }
1089
+
1090
+ // Footer
1091
+ lines.push(renderFooter(" [enter] continue [esc] cancel ", width, theme));
1092
+
1093
+ return lines;
1094
+ }
1095
+
1096
+ function handleNameInput(
1097
+ state: ManagerState,
1098
+ data: string,
1099
+ requestRender: () => void,
1100
+ ): void {
1101
+ if (matchesKey(data, Key.tab)) {
1102
+ const scopes = ["global", "user", "project"];
1103
+ const currentIdx = scopes.indexOf(state.nameInputScope);
1104
+ const nextIdx = (currentIdx + 1) % scopes.length;
1105
+ state.nameInputScope = scopes[nextIdx] as "global" | "user" | "project";
1106
+ if (state.nameInputScope === "global" && !state.globalDir) {
1107
+ state.nameInputError = "No global agents directory found";
1108
+ } else if (state.nameInputScope === "project" && !state.projectDir) {
1109
+ state.nameInputError = "No project agents directory found";
1110
+ } else {
1111
+ state.nameInputError = null;
1112
+ }
1113
+ requestRender();
1114
+ } else if (matchesKey(data, Key.backspace)) {
1115
+ if (state.nameInputCursor > 0) {
1116
+ state.nameInputBuffer =
1117
+ state.nameInputBuffer.slice(0, state.nameInputCursor - 1) +
1118
+ state.nameInputBuffer.slice(state.nameInputCursor);
1119
+ state.nameInputCursor--;
1120
+ state.nameInputError = null;
1121
+ requestRender();
1122
+ }
1123
+ } else if (matchesKey(data, Key.left)) {
1124
+ if (state.nameInputCursor > 0) {
1125
+ state.nameInputCursor--;
1126
+ requestRender();
1127
+ }
1128
+ } else if (matchesKey(data, Key.right)) {
1129
+ if (state.nameInputCursor < state.nameInputBuffer.length) {
1130
+ state.nameInputCursor++;
1131
+ requestRender();
1132
+ }
1133
+ } else if (matchesKey(data, Key.enter)) {
1134
+ const name = state.nameInputBuffer.trim();
1135
+ const nameError = validateAgentName(name);
1136
+ if (nameError) {
1137
+ state.nameInputError = nameError;
1138
+ requestRender();
1139
+ return;
1140
+ }
1141
+ if (state.nameInputScope === "global" && !state.globalDir) {
1142
+ state.nameInputError = "No global agents directory found";
1143
+ requestRender();
1144
+ return;
1145
+ }
1146
+ if (state.nameInputScope === "project" && !state.projectDir) {
1147
+ state.nameInputError = "No project agents directory found";
1148
+ requestRender();
1149
+ return;
1150
+ }
1151
+
1152
+ // Check for duplicate name
1153
+ const duplicate = state.agents.find(
1154
+ (a) => a.name === name && a.source === state.nameInputScope,
1155
+ );
1156
+ if (duplicate) {
1157
+ state.nameInputError = `Agent "${name}" already exists`;
1158
+ requestRender();
1159
+ return;
1160
+ }
1161
+
1162
+ const dir = state.nameInputScope === "global" ? state.globalDir
1163
+ : state.nameInputScope === "user" ? state.userDir
1164
+ : state.projectDir;
1165
+ if (!dir) {
1166
+ state.nameInputError = "Target directory not available";
1167
+ requestRender();
1168
+ return;
1169
+ }
1170
+
1171
+ const filePath = path.join(dir, `${name}.md`);
1172
+
1173
+ let newAgent: AgentConfig;
1174
+ if (state.nameInputMode === "clone" && state.nameInputSource) {
1175
+ const src = state.nameInputSource;
1176
+ newAgent = {
1177
+ ...src,
1178
+ name,
1179
+ source: state.nameInputScope,
1180
+ filePath,
1181
+ };
1182
+ if (src.tools) newAgent.tools = [...src.tools];
1183
+ if (src.extraFields) newAgent.extraFields = { ...src.extraFields };
1184
+ } else {
1185
+ newAgent = {
1186
+ name,
1187
+ description: "",
1188
+ systemPrompt: "",
1189
+ source: state.nameInputScope,
1190
+ filePath,
1191
+ };
1192
+ }
1193
+
1194
+ // Switch to edit screen with new agent
1195
+ state.editAgent = newAgent;
1196
+ state.editOriginal = { ...newAgent };
1197
+ if (newAgent.tools) state.editOriginal.tools = [...newAgent.tools];
1198
+ state.editReturnScreen = "list";
1199
+ state.editFieldIndex = 0;
1200
+ state.editInField = false;
1201
+ state.editDirty = false;
1202
+ state.editFieldCursor = 0;
1203
+ state.editPromptMode = false;
1204
+ state.editPromptCursor = 0;
1205
+ state.editPromptScroll = 0;
1206
+ state.editDiscardPrompt = false;
1207
+ state.editError = null;
1208
+ state.isNew = true;
1209
+ state.screen = "edit";
1210
+ requestRender();
1211
+ } else if (matchesKey(data, Key.escape)) {
1212
+ state.screen = "list";
1213
+ requestRender();
1214
+ } else if (data.length === 1 && data >= " " && data <= "~") {
1215
+ state.nameInputBuffer =
1216
+ state.nameInputBuffer.slice(0, state.nameInputCursor) +
1217
+ data +
1218
+ state.nameInputBuffer.slice(state.nameInputCursor);
1219
+ state.nameInputCursor++;
1220
+ state.nameInputError = null;
1221
+ requestRender();
1222
+ }
1223
+ }
1224
+
1225
+ // ── Confirm Delete screen ───────────────────────────────────────────────────
1226
+
1227
+ function renderConfirmDelete(state: ManagerState, width: number, theme: Theme): string[] {
1228
+ const lines: string[] = [];
1229
+ const target = state.deleteTarget;
1230
+
1231
+ if (!target) {
1232
+ lines.push(renderHeader(" Delete? ", width, theme));
1233
+ lines.push(renderFooter(" [esc] cancel ", width, theme));
1234
+ return lines;
1235
+ }
1236
+
1237
+ // Header
1238
+ lines.push(renderHeader(` Delete "${target.name}"? `, width, theme));
1239
+
1240
+ // File path
1241
+ lines.push(padEnd(theme.fg("dim", `File: ${target.filePath}`), width));
1242
+
1243
+ // Warning
1244
+ lines.push(padEnd(theme.fg("error", "This cannot be undone."), width));
1245
+
1246
+ // Spacer
1247
+ lines.push(padEnd("", width));
1248
+
1249
+ // Footer
1250
+ lines.push(renderFooter(" [y] confirm [n / esc] cancel ", width, theme));
1251
+
1252
+ return lines;
1253
+ }
1254
+
1255
+ function handleConfirmDelete(
1256
+ state: ManagerState,
1257
+ data: string,
1258
+ requestRender: () => void,
1259
+ ): void {
1260
+ if (matchesKey(data, "y") || data === "Y") {
1261
+ if (state.deleteTarget) {
1262
+ try {
1263
+ fs.unlinkSync(state.deleteTarget.filePath);
1264
+ } catch {
1265
+ // File may not exist
1266
+ }
1267
+
1268
+ // Refresh agents list
1269
+ const cwd = process.cwd();
1270
+ const discovery = discoverAgentsAll(cwd);
1271
+ state.globalAgents = discovery.global;
1272
+ state.userAgents = discovery.user;
1273
+ state.projectAgents = discovery.project;
1274
+ state.globalDir = discovery.globalDir;
1275
+ state.agents = [...discovery.global, ...discovery.user, ...discovery.project];
1276
+
1277
+ state.listCursor = 0;
1278
+ state.listScroll = 0;
1279
+ state.filterQuery = "";
1280
+ state.filterMode = false;
1281
+ }
1282
+ state.screen = "list";
1283
+ requestRender();
1284
+ } else if (matchesKey(data, "n") || data === "N" || matchesKey(data, Key.escape)) {
1285
+ state.screen = state.deleteFromScreen;
1286
+ requestRender();
1287
+ }
1288
+ }
1289
+
1290
+ // ── Main panel factory ──────────────────────────────────────────────────────
1291
+
1292
+ export function createAgentPanel(
1293
+ globalAgents: AgentConfig[],
1294
+ userAgents: AgentConfig[],
1295
+ projectAgents: AgentConfig[],
1296
+ globalDir: string | null,
1297
+ userDir: string,
1298
+ projectDir: string | null,
1299
+ tui: TUIContext,
1300
+ theme: Theme,
1301
+ done: () => void,
1302
+ models: ModelInfo[],
1303
+ tools: ToolInfo[],
1304
+ ): Component {
1305
+ const state: ManagerState = {
1306
+ screen: "list",
1307
+ agents: [...globalAgents, ...userAgents, ...projectAgents],
1308
+ globalAgents,
1309
+ userAgents,
1310
+ projectAgents,
1311
+ globalDir,
1312
+ userDir,
1313
+ projectDir,
1314
+
1315
+ listCursor: 0,
1316
+ listScroll: 0,
1317
+ filterQuery: "",
1318
+ filterMode: false,
1319
+
1320
+ detailAgent: null,
1321
+ detailScroll: 0,
1322
+
1323
+ editAgent: null,
1324
+ editFieldIndex: 0,
1325
+ editInField: false,
1326
+ editDirty: false,
1327
+ editFieldCursor: 0,
1328
+ editPromptMode: false,
1329
+ editPromptCursor: 0,
1330
+ editPromptScroll: 0,
1331
+ editDiscardPrompt: false,
1332
+ editError: null,
1333
+ editOriginal: null,
1334
+ editReturnScreen: "list",
1335
+
1336
+ nameInputBuffer: "",
1337
+ nameInputCursor: 0,
1338
+ nameInputScope: "user",
1339
+ nameInputMode: "new",
1340
+ nameInputSource: null,
1341
+ nameInputError: null,
1342
+
1343
+ models,
1344
+ modelPickerOpen: false,
1345
+ modelSearchQuery: "",
1346
+ modelCursor: 0,
1347
+ filteredModels: models,
1348
+
1349
+ tools,
1350
+ toolPickerOpen: false,
1351
+ toolCursor: 0,
1352
+ toolSelected: new Set<string>(),
1353
+ toolSearch: "",
1354
+ filteredTools: tools,
1355
+
1356
+ deleteTarget: null,
1357
+ deleteFromScreen: "list",
1358
+
1359
+ isNew: false,
1360
+
1361
+ lastWidth: 84,
1362
+ lastContentWidth: 80,
1363
+ };
1364
+
1365
+ let cachedWidth: number | undefined;
1366
+ let cachedLines: string[] | undefined;
1367
+
1368
+ function requestRender(): void {
1369
+ cachedWidth = undefined;
1370
+ cachedLines = undefined;
1371
+ tui.requestRender();
1372
+ }
1373
+
1374
+ function handleInput(data: string): void {
1375
+ const result: "close" | void = (() => {
1376
+ switch (state.screen) {
1377
+ case "list":
1378
+ return handleListInput(state, data, done, requestRender);
1379
+ case "detail":
1380
+ return handleDetailInput(state, data, requestRender);
1381
+ case "edit":
1382
+ return handleEditInput(state, data, requestRender);
1383
+ case "name-input":
1384
+ return handleNameInput(state, data, requestRender);
1385
+ case "confirm-delete":
1386
+ return handleConfirmDelete(state, data, requestRender);
1387
+ }
1388
+ })();
1389
+
1390
+ if (result === "close") {
1391
+ done();
1392
+ }
1393
+ }
1394
+
1395
+ return {
1396
+ render(width: number): string[] {
1397
+ state.lastWidth = width;
1398
+ const innerWidth = Math.max(1, width - 2);
1399
+ const contentWidth = Math.max(1, innerWidth - 2); // minus 1 space padding each side
1400
+ state.lastContentWidth = contentWidth;
1401
+ if (cachedLines && cachedWidth === width) {
1402
+ return cachedLines;
1403
+ }
1404
+
1405
+ // Pass content width (minus border + padding) to screen renderers
1406
+ let lines: string[];
1407
+ switch (state.screen) {
1408
+ case "list":
1409
+ lines = renderList(state, contentWidth, theme);
1410
+ break;
1411
+ case "detail":
1412
+ lines = renderDetail(state, contentWidth, theme);
1413
+ break;
1414
+ case "edit":
1415
+ lines = renderEdit(state, contentWidth, theme);
1416
+ break;
1417
+ case "name-input":
1418
+ lines = renderNameInput(state, contentWidth, theme);
1419
+ break;
1420
+ case "confirm-delete":
1421
+ lines = renderConfirmDelete(state, contentWidth, theme);
1422
+ break;
1423
+ }
1424
+
1425
+ const bordered = wrapWithBorder(lines, width, theme);
1426
+ cachedWidth = width;
1427
+ cachedLines = bordered;
1428
+ return bordered;
1429
+ },
1430
+
1431
+ handleInput,
1432
+
1433
+ invalidate(): void {
1434
+ cachedWidth = undefined;
1435
+ cachedLines = undefined;
1436
+ },
1437
+
1438
+ dispose(): void {
1439
+ // No resources to clean up
1440
+ },
1441
+ };
1442
+ }