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