@compilr-dev/cli 0.8.10 → 0.9.1

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.
Files changed (32) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/agent.d.ts +9 -0
  3. package/dist/agent.js +1 -0
  4. package/dist/commands-v2/handlers/project.js +88 -229
  5. package/dist/commands-v2/handlers/skill.js +10 -6
  6. package/dist/commands-v2/handlers/team.d.ts +19 -1
  7. package/dist/commands-v2/handlers/team.js +178 -3
  8. package/dist/compilr-diff-companion.vsix +0 -0
  9. package/dist/index.js +55 -3
  10. package/dist/multi-agent/agent-edit.d.ts +75 -0
  11. package/dist/multi-agent/agent-edit.js +84 -0
  12. package/dist/multi-agent/capability-loader.d.ts +15 -5
  13. package/dist/multi-agent/capability-loader.js +18 -8
  14. package/dist/multi-agent/custom-agents.d.ts +12 -0
  15. package/dist/multi-agent/custom-agents.js +22 -1
  16. package/dist/repl-helpers.d.ts +6 -1
  17. package/dist/repl-helpers.js +14 -7
  18. package/dist/repl-v2.d.ts +10 -0
  19. package/dist/repl-v2.js +43 -2
  20. package/dist/ui/overlay/impl/agent-edit-overlay-v2.d.ts +105 -0
  21. package/dist/ui/overlay/impl/agent-edit-overlay-v2.js +364 -0
  22. package/dist/ui/overlay/impl/custom-agent-form-overlay-v2.d.ts +1 -8
  23. package/dist/ui/overlay/impl/custom-agent-form-overlay-v2.js +16 -120
  24. package/dist/ui/overlay/impl/project-edit-overlay-v2.d.ts +2 -1
  25. package/dist/ui/overlay/impl/project-edit-overlay-v2.js +26 -19
  26. package/dist/ui/overlay/impl/skills-overlay-v2.d.ts +42 -2
  27. package/dist/ui/overlay/impl/skills-overlay-v2.js +238 -185
  28. package/dist/ui/overlay/impl/team-overlay-v2.d.ts +1 -2
  29. package/dist/ui/overlay/impl/team-overlay-v2.js +6 -9
  30. package/dist/ui/terminal-types.d.ts +8 -0
  31. package/dist/utils/model-tiers.js +5 -0
  32. package/package.json +2 -2
@@ -5,7 +5,7 @@
5
5
  * and improve maintainability. These functions support the REPL but don't
6
6
  * depend on REPL instance state.
7
7
  */
8
- import { detectCollisions, formatCollisionWarnings, resolveSkillPrompt, getAllAvailableSkills, platformSkills } from '@compilr-dev/sdk';
8
+ import { detectCollisions, formatCollisionWarnings, resolveSkillPrompt, getAllAvailableSkills, platformMacros } from '@compilr-dev/sdk';
9
9
  import { getSkillsDir } from './utils/skill-paths.js';
10
10
  import { getProjectPath } from './utils/project-status.js';
11
11
  import { codingSkills } from '@compilr-dev/agents-coding';
@@ -202,7 +202,7 @@ export function hasBacklogItems(backlogPath) {
202
202
  }
203
203
  }
204
204
  /** Get the current project directory (best effort). */
205
- function getProjectDir() {
205
+ export function getProjectDir() {
206
206
  try {
207
207
  return getProjectPath(process.cwd());
208
208
  }
@@ -211,7 +211,7 @@ function getProjectDir() {
211
211
  }
212
212
  }
213
213
  /** Get CLI skill sources (factory + coding). */
214
- function getCliSkillSources() {
214
+ export function getCliSkillSources() {
215
215
  return { factorySkills, codingSkills };
216
216
  }
217
217
  /**
@@ -229,16 +229,16 @@ function getCliSkillSources() {
229
229
  * of date again, so the rule now reads the skill's own tag. The SDK has a matching guard
230
230
  * asserting every canvas skill carries it.
231
231
  */
232
- const DESKTOP_ONLY_SKILLS = new Set(platformSkills.filter((skill) => skill.tags?.includes('canvas')).map((skill) => skill.name));
232
+ const DESKTOP_ONLY_MACROS = new Set(platformMacros.filter((skill) => skill.tags?.includes('canvas')).map((skill) => skill.name));
233
233
  /**
234
234
  * Get skill prompt by name. Delegates to SDK resolveSkillPrompt, minus any
235
- * Desktop-only SDK skills (see DESKTOP_ONLY_SKILLS).
235
+ * Desktop-only SDK skills (see DESKTOP_ONLY_MACROS).
236
236
  */
237
237
  export function getSkillPrompt(name) {
238
238
  const resolution = resolveSkillPrompt(name, getProjectDir(), getCliSkillSources());
239
239
  if (!resolution)
240
240
  return null;
241
- if (resolution.source === 'sdk' && DESKTOP_ONLY_SKILLS.has(resolution.skillName))
241
+ if (resolution.source === 'sdk' && DESKTOP_ONLY_MACROS.has(resolution.skillName))
242
242
  return null;
243
243
  return resolution.prompt;
244
244
  }
@@ -250,7 +250,7 @@ export function getSkillResolution(name) {
250
250
  const resolution = resolveSkillPrompt(name, getProjectDir(), getCliSkillSources());
251
251
  if (!resolution)
252
252
  return null;
253
- if (resolution.source === 'sdk' && DESKTOP_ONLY_SKILLS.has(resolution.skillName))
253
+ if (resolution.source === 'sdk' && DESKTOP_ONLY_MACROS.has(resolution.skillName))
254
254
  return null;
255
255
  return {
256
256
  prompt: resolution.prompt,
@@ -267,6 +267,13 @@ export function getSkillAutocompleteEntries() {
267
267
  const entries = getAllAvailableSkills(getProjectDir(), getCliSkillSources());
268
268
  return entries
269
269
  .filter((e) => e.source !== 'sdk' && e.source !== 'agents') // SDK/agents skills already in built-in commands
270
+ /*
271
+ ⚠️ A DISABLED SKILL MUST NOT BE OFFERED. Pressing Space in `/skill` writes `enabled: false`
272
+ into the SKILL.md and `resolveSkillPrompt` then refuses it — but autocomplete filtered only
273
+ on `source`, so the skill kept appearing, and accepting the suggestion answered "Unknown
274
+ command". Desktop's `getSkillList` has always filtered on this; the CLI did not.
275
+ */
276
+ .filter((e) => e.enabled !== false)
270
277
  .map((e) => {
271
278
  const desc = e.description.length > 60 ? e.description.slice(0, 57) + '...' : e.description;
272
279
  return {
package/dist/repl-v2.d.ts CHANGED
@@ -304,6 +304,16 @@ export declare class ReplV2 {
304
304
  * For foreground: sets currentSession and foregroundAgentId
305
305
  * For background: only adds to sessions map
306
306
  */
307
+ /**
308
+ * A warning to prepend when a macro needs tools the active agent does not have.
309
+ *
310
+ * ⚠️ WITHOUT IT THE AGENT PROMISES AND THEN FAILS. `/design` tells the agent to use `ask_user`
311
+ * and `workitem_add`; sent to an agent that cannot reach those it announces a requirements
312
+ * session and dies partway, which reads as the agent being broken. Addressed to the agent, not
313
+ * shown as a terminal warning: the user does not know which tools /design needs or which
314
+ * teammate has them, and the agent can explain and offer the handoff.
315
+ */
316
+ private macroPreamble;
307
317
  private startSession;
308
318
  /**
309
319
  * End an agent session.
package/dist/repl-v2.js CHANGED
@@ -56,6 +56,7 @@ import { isMemoryInput } from './input-handlers/index.js';
56
56
  import { addAnchor } from './anchors/index.js';
57
57
  import { getActiveProject } from './tools/project-db.js';
58
58
  import { getSkillResolution, getSkillAutocompleteEntries, checkSkillCollisions } from './repl-helpers.js';
59
+ import { macroToolGap, macroToolGapPreamble } from '@compilr-dev/sdk';
59
60
  import { getProjectSessionManager } from './session/project-session-manager.js';
60
61
  import { ResumeOverlayV2 } from './ui/overlay/impl/resume-overlay-v2.js';
61
62
  import { updateCacheInfo, updateHistoryEstimate, formatBreakdownCompact, formatCacheInfo, estimateTokens } from './utils/token-tracker.js';
@@ -302,8 +303,36 @@ export class ReplV2 {
302
303
  * For foreground: sets currentSession and foregroundAgentId
303
304
  * For background: only adds to sessions map
304
305
  */
306
+ /**
307
+ * A warning to prepend when a macro needs tools the active agent does not have.
308
+ *
309
+ * ⚠️ WITHOUT IT THE AGENT PROMISES AND THEN FAILS. `/design` tells the agent to use `ask_user`
310
+ * and `workitem_add`; sent to an agent that cannot reach those it announces a requirements
311
+ * session and dies partway, which reads as the agent being broken. Addressed to the agent, not
312
+ * shown as a terminal warning: the user does not know which tools /design needs or which
313
+ * teammate has them, and the agent can explain and offer the handoff.
314
+ */
315
+ macroPreamble(macroName) {
316
+ const active = this.team?.getActive();
317
+ if (!active?.toolFilter)
318
+ return null;
319
+ const gap = macroToolGap(macroName, active.toolFilter);
320
+ return gap ? macroToolGapPreamble(macroName, gap) : null;
321
+ }
305
322
  startSession(session) {
306
323
  this.sessions.set(session.agentId, session);
324
+ /*
325
+ ⚠️ TELLS THE SDK THE AGENT IS MID-REPLY, so `/team` -> `e` cannot save an edit that
326
+ rebuilds it. A rebuild snapshots the history and drops the instance, but the in-flight run
327
+ holds its own reference and finishes anyway — the reply arrives and the rebuilt agent has
328
+ no memory of having sent it. Nothing errors; the agent just answers the next question from
329
+ a history the user can see is wrong.
330
+
331
+ Reachable here through BACKGROUND agents (`$agent msg &`): a foreground turn blocks the
332
+ REPL, so `/team` is out of reach until it finishes, but a background one leaves the prompt
333
+ free. Bracketing start/end covers both, and they are the one chokepoint every run passes.
334
+ */
335
+ this.team?.get(session.agentId)?.beginTurn();
307
336
  if (!session.isBackground) {
308
337
  this.currentSession = session;
309
338
  this.foregroundAgentId = session.agentId;
@@ -316,6 +345,8 @@ export class ReplV2 {
316
345
  */
317
346
  endSession(agentId) {
318
347
  this.sessions.delete(agentId);
348
+ // Pairs with beginTurn() in startSession, however the turn ended.
349
+ this.team?.get(agentId)?.endTurn();
319
350
  if (this.foregroundAgentId === agentId) {
320
351
  this.currentSession = null;
321
352
  this.foregroundAgentId = null;
@@ -2047,7 +2078,13 @@ export class ReplV2 {
2047
2078
  },
2048
2079
  // Queue agent message (for commands that invoke the agent)
2049
2080
  queueAgentMessage: (options) => {
2050
- this.ui.queueAgentMessage(options);
2081
+ /*
2082
+ A macro assumes tools. Warn the AGENT here — the one place every hardcoded macro
2083
+ handler funnels through — rather than in each handler, which would be eight copies of
2084
+ a rule that must not drift.
2085
+ */
2086
+ const preamble = options.macro ? this.macroPreamble(options.macro) : null;
2087
+ this.ui.queueAgentMessage(preamble ? { ...options, message: `${preamble}\n\n---\n\n${options.message}` } : options);
2051
2088
  },
2052
2089
  // Session stats
2053
2090
  startTime: this.startTime,
@@ -2092,7 +2129,11 @@ export class ReplV2 {
2092
2129
  this.ui.print({ type: 'info', message: `🛠 /${cmd} → ${resolution.skillName}${forkInfo}` });
2093
2130
  }
2094
2131
  this.ui.print({ type: 'user-message', text: `/${cmd}${args ? ' ' + args : ''}` });
2095
- const fullPrompt = args ? `${resolution.prompt}\n\nUser request: ${args}` : resolution.prompt;
2132
+ // Same warning as the hardcoded macros get — this path serves every other macro name,
2133
+ // including a user's own skill invoked as one.
2134
+ const macroPreamble = this.macroPreamble(cmd);
2135
+ const body = args ? `${resolution.prompt}\n\nUser request: ${args}` : resolution.prompt;
2136
+ const fullPrompt = macroPreamble ? `${macroPreamble}\n\n---\n\n${body}` : body;
2096
2137
  if (this.agent) {
2097
2138
  await this.runAgentReal(fullPrompt);
2098
2139
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Agent Edit Overlay V2
3
+ *
4
+ * Standalone overlay for editing an existing team agent's attributes.
5
+ * Opened from /team (via 'e' key) — see commands-v2/handlers/team.ts.
6
+ *
7
+ * Adapted from ProjectEditOverlayV2, which has the same shape: a list of fields,
8
+ * one selected, Enter to change it. The additions here are choice fields (model
9
+ * tier, tool profile, mascot) which cycle rather than accept typing.
10
+ *
11
+ * ⚠️ THIS OVERLAY DOES NOT SAVE. It returns the edited values and the caller applies
12
+ * them. `AgentTeam.updateAgent` is async and the REPL's live-agent reference may need
13
+ * replacing afterwards (see the handler); overlay key handling is synchronous.
14
+ */
15
+ import { BaseOverlayV2 } from '../../base/index.js';
16
+ import type { RenderContext, OverlayAction, KeyEvent } from '../types.js';
17
+ import { type ToolProfile, type MascotExpression } from '@compilr-dev/sdk';
18
+ import { type ModelTier } from '../../../models/index.js';
19
+ type FieldKey = 'displayName' | 'specialty' | 'personality' | 'instructions' | 'modelTier' | 'toolProfile' | 'mascot';
20
+ interface EditableField {
21
+ key: FieldKey;
22
+ label: string;
23
+ /** 'text' fields are typed into; 'choice' fields cycle through `choices`. */
24
+ kind: 'text' | 'choice';
25
+ value: string;
26
+ placeholder?: string;
27
+ maxLength?: number;
28
+ choices?: string[];
29
+ }
30
+ interface AgentEditState {
31
+ fields: EditableField[];
32
+ selectedFieldIndex: number;
33
+ editingField: boolean;
34
+ inputBuffer: string;
35
+ error: string | null;
36
+ }
37
+ /** Everything the overlay needs to render, and to hand back unchanged if untouched. */
38
+ export interface AgentEditOptions {
39
+ agentId: string;
40
+ displayName: string;
41
+ mascot: MascotExpression;
42
+ /** TeamAgent.description — the one-line "what this agent is for". */
43
+ specialty: string;
44
+ /** Tone only. Composed at initialize() as "Your manner: …" (SDK 0.23+). */
45
+ personality: string;
46
+ /**
47
+ * The user's OWN instructions — never the composed prompt.
48
+ * For a custom agent this comes from `CustomAgentDefinition.systemPromptAddition`; for a
49
+ * role agent, from the live `TeamAgent.systemPromptAddition`, which since SDK 0.23 holds
50
+ * user-authored text only. A role agent that has never been edited stores nothing here,
51
+ * so an empty box is the normal state rather than a loading failure.
52
+ */
53
+ instructions: string;
54
+ modelTier: ModelTier;
55
+ toolProfile: ToolProfile;
56
+ /** Custom agents regenerate their prompt on save; role agents do not. */
57
+ isCustom: boolean;
58
+ /** Mascots already in use by other agents, marked in the picker. */
59
+ takenMascots?: string[];
60
+ }
61
+ export interface AgentEditValues {
62
+ displayName: string;
63
+ specialty: string;
64
+ personality: string;
65
+ instructions: string;
66
+ modelTier: ModelTier;
67
+ toolProfile: ToolProfile;
68
+ mascot: MascotExpression;
69
+ }
70
+ export interface AgentEditResult {
71
+ saved: boolean;
72
+ agentId: string;
73
+ /** Present only when saved. The caller diffs these against the originals. */
74
+ values?: AgentEditValues;
75
+ }
76
+ export declare class AgentEditOverlayV2 extends BaseOverlayV2<AgentEditState, AgentEditResult> {
77
+ readonly type: "inline";
78
+ readonly id = "agent-edit-overlay-v2";
79
+ private readonly opts;
80
+ constructor(opts: AgentEditOptions);
81
+ /** Human-readable rendering of a choice field's current value. */
82
+ private describeChoice;
83
+ /**
84
+ * One-line preview of a text value.
85
+ *
86
+ * ⚠️ Collapse newlines BEFORE truncating. Instructions are multi-line, and a raw `\n`
87
+ * reaching the renderer makes one logical line occupy two physical rows, which breaks
88
+ * the overlay's line accounting.
89
+ */
90
+ private previewText;
91
+ /**
92
+ * Truncate plain text to fit the frame.
93
+ *
94
+ * ⚠️ Applied BEFORE styling. Cutting a styled string can slice through an ANSI escape
95
+ * and leave the rest of the terminal wearing the colour.
96
+ */
97
+ private fit;
98
+ protected renderContent(context: RenderContext): string[];
99
+ /** Move a choice field to its next/previous option, wrapping. */
100
+ private cycleChoice;
101
+ handleKey(key: KeyEvent): OverlayAction<AgentEditResult>;
102
+ private valueOf;
103
+ private saveAndClose;
104
+ }
105
+ export {};
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Agent Edit Overlay V2
3
+ *
4
+ * Standalone overlay for editing an existing team agent's attributes.
5
+ * Opened from /team (via 'e' key) — see commands-v2/handlers/team.ts.
6
+ *
7
+ * Adapted from ProjectEditOverlayV2, which has the same shape: a list of fields,
8
+ * one selected, Enter to change it. The additions here are choice fields (model
9
+ * tier, tool profile, mascot) which cycle rather than accept typing.
10
+ *
11
+ * ⚠️ THIS OVERLAY DOES NOT SAVE. It returns the edited values and the caller applies
12
+ * them. `AgentTeam.updateAgent` is async and the REPL's live-agent reference may need
13
+ * replacing afterwards (see the handler); overlay key handling is synchronous.
14
+ */
15
+ import { BaseOverlayV2, renderBorder } from '../../base/index.js';
16
+ import { PROFILE_INFO, CUSTOM_MASCOTS, } from '@compilr-dev/sdk';
17
+ import { MODEL_TIERS, TIER_INFO } from '../../../models/index.js';
18
+ const PROFILE_LIST = Object.keys(PROFILE_INFO);
19
+ // =============================================================================
20
+ // Agent Edit Overlay V2
21
+ // =============================================================================
22
+ export class AgentEditOverlayV2 extends BaseOverlayV2 {
23
+ type = 'inline';
24
+ id = 'agent-edit-overlay-v2';
25
+ opts;
26
+ constructor(opts) {
27
+ // A custom agent's mascot is user-chosen; keep the current one selectable even
28
+ // if another agent also holds it, so opening the editor can never silently
29
+ // move an agent off its own face.
30
+ const mascotChoices = Array.from(new Set([opts.mascot, ...CUSTOM_MASCOTS]));
31
+ super({
32
+ fields: [
33
+ {
34
+ key: 'displayName',
35
+ label: 'Display Name',
36
+ kind: 'text',
37
+ value: opts.displayName,
38
+ placeholder: 'e.g. Solution Architect',
39
+ maxLength: 60,
40
+ },
41
+ {
42
+ key: 'specialty',
43
+ label: 'Specialty',
44
+ kind: 'text',
45
+ value: opts.specialty,
46
+ placeholder: 'e.g. system design and trade-offs',
47
+ maxLength: 120,
48
+ },
49
+ {
50
+ key: 'personality',
51
+ label: 'Personality',
52
+ kind: 'text',
53
+ value: opts.personality,
54
+ placeholder: 'e.g. Pragmatic and thorough (tone only)',
55
+ maxLength: 200,
56
+ },
57
+ {
58
+ key: 'instructions',
59
+ label: 'Instructions',
60
+ kind: 'text',
61
+ value: opts.instructions,
62
+ // Empty is the normal state for a role agent: its role scaffold is composed by
63
+ // the SDK and was never the user's to edit.
64
+ placeholder: opts.isCustom
65
+ ? '(none — add your own instructions)'
66
+ : '(none — role behaviour is built in)',
67
+ maxLength: 2000,
68
+ },
69
+ {
70
+ key: 'modelTier',
71
+ label: 'Model Tier',
72
+ kind: 'choice',
73
+ value: opts.modelTier,
74
+ choices: [...MODEL_TIERS],
75
+ },
76
+ {
77
+ key: 'toolProfile',
78
+ label: 'Tool Profile',
79
+ kind: 'choice',
80
+ value: opts.toolProfile,
81
+ choices: [...PROFILE_LIST],
82
+ },
83
+ {
84
+ key: 'mascot',
85
+ label: 'Mascot',
86
+ kind: 'choice',
87
+ value: opts.mascot,
88
+ choices: mascotChoices,
89
+ },
90
+ ],
91
+ selectedFieldIndex: 0,
92
+ editingField: false,
93
+ inputBuffer: '',
94
+ error: null,
95
+ });
96
+ this.opts = opts;
97
+ this.minHeight = 24;
98
+ }
99
+ // ===========================================================================
100
+ // Rendering
101
+ // ===========================================================================
102
+ /** Human-readable rendering of a choice field's current value. */
103
+ describeChoice(field, context) {
104
+ const s = context.styles;
105
+ if (field.key === 'modelTier') {
106
+ const info = TIER_INFO[field.value];
107
+ return `${info.label} ${s.muted(`— ${info.shortDescription}`)}`;
108
+ }
109
+ if (field.key === 'toolProfile') {
110
+ const info = PROFILE_INFO[field.value];
111
+ const readOnly = info.isReadOnly ? s.muted(' [read-only]') : '';
112
+ return `${info.label}${readOnly}`;
113
+ }
114
+ // mascot
115
+ const taken = (this.opts.takenMascots ?? []).includes(field.value) && field.value !== this.opts.mascot;
116
+ return `${field.value}${taken ? s.muted(' (in use by another agent)') : ''}`;
117
+ }
118
+ /**
119
+ * One-line preview of a text value.
120
+ *
121
+ * ⚠️ Collapse newlines BEFORE truncating. Instructions are multi-line, and a raw `\n`
122
+ * reaching the renderer makes one logical line occupy two physical rows, which breaks
123
+ * the overlay's line accounting.
124
+ */
125
+ previewText(value, width) {
126
+ const collapsed = value.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim();
127
+ if (collapsed.length <= width)
128
+ return collapsed;
129
+ return collapsed.slice(0, Math.max(0, width - 1)) + '…';
130
+ }
131
+ /**
132
+ * Truncate plain text to fit the frame.
133
+ *
134
+ * ⚠️ Applied BEFORE styling. Cutting a styled string can slice through an ANSI escape
135
+ * and leave the rest of the terminal wearing the colour.
136
+ */
137
+ fit(text, cols) {
138
+ const max = Math.max(0, cols - 2);
139
+ if (text.length <= max)
140
+ return text;
141
+ return text.slice(0, Math.max(0, max - 1)) + '…';
142
+ }
143
+ renderContent(context) {
144
+ const s = context.styles;
145
+ /*
146
+ The width we were GIVEN, not the process's. `OverlayManager` builds the context from
147
+ `getTerminalWidth()`, so these are the same value in production — but rendering to the
148
+ width passed in is what makes the frame measurable, and leaves the overlay correct if
149
+ it is ever rendered somewhere narrower than the whole terminal.
150
+ */
151
+ const cols = context.width;
152
+ const border = renderBorder(cols, s);
153
+ const lines = [];
154
+ const labelWidth = 14;
155
+ const valueWidth = Math.max(20, cols - labelWidth - 8);
156
+ lines.push(border);
157
+ lines.push(` ${s.primaryBold('EDIT AGENT')}`);
158
+ lines.push('');
159
+ lines.push(s.muted(` $${this.opts.agentId} · ${this.opts.isCustom ? 'custom agent' : 'role agent'}`));
160
+ // The id is deliberately not editable — it is written into message history as
161
+ // literal text, so renaming it would strand every earlier $mention.
162
+ lines.push(s.muted(' ' +
163
+ this.fit(cols < 72
164
+ ? 'Agent ID cannot be changed'
165
+ : 'Agent ID cannot be changed (earlier $mentions would break)', cols)));
166
+ lines.push('');
167
+ lines.push(s.muted(' ' + '─'.repeat(Math.max(0, cols - 6))));
168
+ lines.push('');
169
+ for (let i = 0; i < this.state.fields.length; i++) {
170
+ const field = this.state.fields[i];
171
+ const isSelected = i === this.state.selectedFieldIndex;
172
+ const isEditing = isSelected && this.state.editingField;
173
+ const cursor = isSelected ? s.primary('❯ ') : ' ';
174
+ const label = field.label.padEnd(labelWidth);
175
+ if (isEditing) {
176
+ const shown = this.previewText(this.state.inputBuffer, valueWidth);
177
+ lines.push(`${cursor}${s.muted(label)} ${s.primary(shown)}█`);
178
+ continue;
179
+ }
180
+ let rendered;
181
+ if (field.kind === 'choice') {
182
+ rendered = this.describeChoice(field, context);
183
+ }
184
+ else if (field.value) {
185
+ rendered = this.previewText(field.value, valueWidth);
186
+ }
187
+ else {
188
+ rendered = s.muted(field.placeholder ?? '(not set)');
189
+ }
190
+ lines.push(`${cursor}${isSelected ? s.primary(label) : s.muted(label)} ${rendered}`);
191
+ }
192
+ lines.push('');
193
+ if (this.state.error) {
194
+ lines.push(s.error(' ' + this.fit(this.state.error, cols)));
195
+ }
196
+ else if (this.opts.isCustom) {
197
+ lines.push(s.muted(' ' +
198
+ this.fit(cols < 72
199
+ ? 'Saving regenerates the system prompt.'
200
+ : "Saving regenerates this agent's system prompt from its definition.", cols)));
201
+ }
202
+ else {
203
+ lines.push('');
204
+ }
205
+ lines.push(border);
206
+ if (this.state.editingField) {
207
+ lines.push(s.muted(' ' + this.fit('Enter Accept field · Esc Cancel edit', cols)));
208
+ }
209
+ else {
210
+ const selected = this.state.fields[this.state.selectedFieldIndex];
211
+ const verb = selected.kind === 'choice' ? 'Enter/←→ Change' : 'Enter Edit';
212
+ const hint = cols < 72
213
+ ? `↑↓ Move · ${verb} · s Save · Esc Cancel`
214
+ : `↑↓/jk Navigate · ${verb} field · s Save & close · Esc Cancel`;
215
+ lines.push(s.muted(' ' + this.fit(hint, cols)));
216
+ }
217
+ lines.push(border);
218
+ return lines;
219
+ }
220
+ // ===========================================================================
221
+ // Key Handling
222
+ // ===========================================================================
223
+ /** Move a choice field to its next/previous option, wrapping. */
224
+ cycleChoice(field, delta) {
225
+ const choices = field.choices ?? [];
226
+ if (choices.length === 0)
227
+ return;
228
+ const current = choices.indexOf(field.value);
229
+ // An unknown current value starts the cycle at the first option rather than
230
+ // throwing the index negative.
231
+ const base = current === -1 ? 0 : current;
232
+ const next = (base + delta + choices.length) % choices.length;
233
+ field.value = choices[next];
234
+ }
235
+ handleKey(key) {
236
+ // Ctrl+C always closes
237
+ if (this.isInterruptKey(key)) {
238
+ return this.close({ saved: false, agentId: this.opts.agentId });
239
+ }
240
+ // Handle field editing mode
241
+ if (this.state.editingField) {
242
+ if (key.name === 'escape') {
243
+ // Cancel field edit — the field keeps the value it had
244
+ this.state.editingField = false;
245
+ this.state.inputBuffer = '';
246
+ return this.rerender();
247
+ }
248
+ if (this.isEnterKey(key)) {
249
+ const field = this.state.fields[this.state.selectedFieldIndex];
250
+ field.value = this.state.inputBuffer;
251
+ this.state.editingField = false;
252
+ this.state.inputBuffer = '';
253
+ this.state.error = null;
254
+ return this.rerender();
255
+ }
256
+ if (key.name === 'backspace') {
257
+ this.state.inputBuffer = this.state.inputBuffer.slice(0, -1);
258
+ if (this.state.inputBuffer.length < (this.state.fields[this.state.selectedFieldIndex].maxLength ?? 200)) {
259
+ this.state.error = null;
260
+ }
261
+ return this.rerender();
262
+ }
263
+ // Regular character input
264
+ if (key.char && key.char.length === 1 && key.char >= ' ' && key.char <= '~') {
265
+ const field = this.state.fields[this.state.selectedFieldIndex];
266
+ const max = field.maxLength ?? 200;
267
+ /*
268
+ ⚠️ THE LIMIT BLOCKS GROWTH, NOT EDITING. It used to be `length < max`, which meant a
269
+ value ALREADY over the limit swallowed every keystroke in silence — the field looked
270
+ frozen and gave no reason. Real case: a pre-0.23 role agent carried 6,208 characters of
271
+ generated prompt in a 2,000-character field, so Instructions could not be edited at all.
272
+ The SDK no longer stores prose there, but an over-length value can still arrive from
273
+ disk, and a control that ignores input without saying why is the worse half of that bug.
274
+ */
275
+ if (this.state.inputBuffer.length < max) {
276
+ this.state.inputBuffer += key.char;
277
+ }
278
+ else {
279
+ this.state.error = `${field.label} is at its ${String(max)}-character limit — delete some text first.`;
280
+ }
281
+ return this.rerender();
282
+ }
283
+ return this.noAction();
284
+ }
285
+ // Handle navigation mode
286
+ if (key.name === 'escape') {
287
+ return this.close({ saved: false, agentId: this.opts.agentId });
288
+ }
289
+ // Navigate up
290
+ if (this.isUpKey(key) || key.char === 'k') {
291
+ if (this.state.selectedFieldIndex > 0) {
292
+ this.state.selectedFieldIndex--;
293
+ return this.rerender();
294
+ }
295
+ return this.noAction();
296
+ }
297
+ // Navigate down
298
+ if (this.isDownKey(key) || key.char === 'j') {
299
+ if (this.state.selectedFieldIndex < this.state.fields.length - 1) {
300
+ this.state.selectedFieldIndex++;
301
+ return this.rerender();
302
+ }
303
+ return this.noAction();
304
+ }
305
+ const field = this.state.fields[this.state.selectedFieldIndex];
306
+ // Left/right cycle a choice field
307
+ if (field.kind === 'choice' && (this.isLeftKey(key) || key.char === 'h')) {
308
+ this.cycleChoice(field, -1);
309
+ return this.rerender();
310
+ }
311
+ if (field.kind === 'choice' && (this.isRightKey(key) || key.char === 'l')) {
312
+ this.cycleChoice(field, 1);
313
+ return this.rerender();
314
+ }
315
+ // Enter edits a text field, or advances a choice field
316
+ if (this.isEnterKey(key)) {
317
+ if (field.kind === 'choice') {
318
+ this.cycleChoice(field, 1);
319
+ return this.rerender();
320
+ }
321
+ this.state.inputBuffer = field.value;
322
+ this.state.editingField = true;
323
+ return this.rerender();
324
+ }
325
+ // 's' to save and close
326
+ if (key.char === 's' || key.char === 'S') {
327
+ return this.saveAndClose();
328
+ }
329
+ return this.noAction();
330
+ }
331
+ valueOf(key) {
332
+ const field = this.state.fields.find((f) => f.key === key);
333
+ return field ? field.value : '';
334
+ }
335
+ saveAndClose() {
336
+ const displayName = this.valueOf('displayName').trim();
337
+ // The roster renders this everywhere an agent is named; an empty one leaves a
338
+ // blank where the agent should be, so refuse rather than write it.
339
+ if (!displayName) {
340
+ this.state.error = 'Display Name cannot be empty.';
341
+ this.state.selectedFieldIndex = 0;
342
+ return this.rerender();
343
+ }
344
+ return this.close({
345
+ saved: true,
346
+ agentId: this.opts.agentId,
347
+ values: {
348
+ displayName,
349
+ specialty: this.valueOf('specialty').trim(),
350
+ personality: this.valueOf('personality').trim(),
351
+ instructions: this.valueOf('instructions'),
352
+ modelTier: this.valueOf('modelTier'),
353
+ toolProfile: this.valueOf('toolProfile'),
354
+ /*
355
+ Every option came from `CUSTOM_MASCOTS` or from the agent's own current mascot,
356
+ so the value is always a real expression. `CUSTOM_MASCOTS` is declared `string[]`
357
+ in the SDK, which is why the narrowing has to be stated here — the same cast
358
+ `TeamAgent.fromCustomDefinition` makes on the way in.
359
+ */
360
+ mascot: this.valueOf('mascot'),
361
+ },
362
+ });
363
+ }
364
+ }