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