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