zcode-acp-server 0.21.4 → 0.22.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.
Files changed (51) hide show
  1. package/README.md +6 -7
  2. package/dist/cli.d.ts +8 -5
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +22 -14
  5. package/dist/cli.js.map +1 -1
  6. package/dist/handlers/session.d.ts +17 -1
  7. package/dist/handlers/session.d.ts.map +1 -1
  8. package/dist/handlers/session.js +92 -4
  9. package/dist/handlers/session.js.map +1 -1
  10. package/dist/remote/hub-server.d.ts +7 -7
  11. package/dist/remote/hub-server.d.ts.map +1 -1
  12. package/dist/remote/hub-server.js +33 -33
  13. package/dist/remote/hub-server.js.map +1 -1
  14. package/dist/server.d.ts +5 -0
  15. package/dist/server.d.ts.map +1 -1
  16. package/dist/server.js +6 -0
  17. package/dist/server.js.map +1 -1
  18. package/dist/tui.d.ts +38 -0
  19. package/dist/tui.d.ts.map +1 -0
  20. package/dist/tui.js +96 -0
  21. package/dist/tui.js.map +1 -0
  22. package/docs/CLI.md +33 -76
  23. package/docs/REMOTE-CLIENTS.md +13 -9
  24. package/docs/REMOTE.md +8 -11
  25. package/docs/TROUBLESHOOTING.md +5 -1
  26. package/docs/adr/0007-unified-cli-entry-and-bin-pruning.md +2 -0
  27. package/package.json +3 -7
  28. package/dist/repl/App.d.ts +0 -108
  29. package/dist/repl/App.d.ts.map +0 -1
  30. package/dist/repl/App.js +0 -732
  31. package/dist/repl/App.js.map +0 -1
  32. package/dist/repl/history.d.ts +0 -25
  33. package/dist/repl/history.d.ts.map +0 -1
  34. package/dist/repl/history.js +0 -73
  35. package/dist/repl/history.js.map +0 -1
  36. package/dist/repl/hub.d.ts +0 -87
  37. package/dist/repl/hub.d.ts.map +0 -1
  38. package/dist/repl/hub.js +0 -188
  39. package/dist/repl/hub.js.map +0 -1
  40. package/dist/repl/input-buffer.d.ts +0 -77
  41. package/dist/repl/input-buffer.d.ts.map +0 -1
  42. package/dist/repl/input-buffer.js +0 -174
  43. package/dist/repl/input-buffer.js.map +0 -1
  44. package/dist/repl/model.d.ts +0 -312
  45. package/dist/repl/model.d.ts.map +0 -1
  46. package/dist/repl/model.js +0 -674
  47. package/dist/repl/model.js.map +0 -1
  48. package/dist/repl/run.d.ts +0 -12
  49. package/dist/repl/run.d.ts.map +0 -1
  50. package/dist/repl/run.js +0 -1245
  51. package/dist/repl/run.js.map +0 -1
@@ -1,674 +0,0 @@
1
- /**
2
- * Pure REPL turn state machine: folds ACP SessionUpdate notifications for one
3
- * prompt turn into renderable entries. No React, no I/O — exported for unit
4
- * tests.
5
- *
6
- * Chunk ordering follows what the bridge emits per turn: thought chunks and
7
- * message chunks arrive interleaved as streams (buffered until the stream
8
- * switches), tool calls arrive as discrete events keyed by toolCallId, and a
9
- * tool_call_update may later change only the status of an existing row.
10
- */
11
- import stringWidth from "string-width";
12
- /**
13
- * Compact plan-quota summary for the prompt line's status row, e.g.
14
- * `"5h 34% · wk 8%"`. Token windows only (5h / weekly — all GLM exposes);
15
- * MCP detail lives in the full `/quota` card, not a one-line indicator, and
16
- * unknown future windows are skipped rather than mislabeled. Returns null
17
- * when there is nothing worth showing (fetch failed / auth expired /
18
- * transient busy) so the row degrades to its previous form instead of
19
- * nagging.
20
- */
21
- export function formatQuotaLine(result) {
22
- if (result === null || result.kind !== "success")
23
- return null;
24
- const parts = [];
25
- for (const item of result.items) {
26
- const label = item.key === "token_5h" ? "5h" : item.key === "token_week" ? "wk" : null;
27
- if (label === null)
28
- continue;
29
- // Compact status-line readout: integer percents on purpose — the 0.1-step
30
- // precision rule (quota/rounding.ts) belongs to the /quota card only.
31
- parts.push(`${label} ${Math.round(item.usedPercent)}%`);
32
- }
33
- return parts.length > 0 ? parts.join(" · ") : null;
34
- }
35
- /**
36
- * Estimated rendered line count of `text` when wrapped at `width` display
37
- * columns (CJK-aware via string-width). Word-wrap vs hard-cut can differ by a
38
- * line on pathological words — close enough for viewport slicing.
39
- */
40
- export function estimateLines(text, width) {
41
- if (width <= 0)
42
- return 1;
43
- let lines = 0;
44
- for (const raw of text.split("\n")) {
45
- lines += Math.max(1, Math.ceil(stringWidth(raw) / width));
46
- }
47
- return lines;
48
- }
49
- /**
50
- * Hard-wrap the (logically single-line, "\n"-tolerant) editor text into visual
51
- * rows of at most `width` display columns. CJK-aware via string-width; a
52
- * character wider than a whole row still occupies its own row.
53
- */
54
- export function wrapEditorLine(text, width) {
55
- if (width <= 0)
56
- return [{ text, start: 0 }];
57
- const rows = [];
58
- let current = "";
59
- let start = 0;
60
- let used = 0;
61
- const cps = Array.from(text);
62
- cps.forEach((ch, i) => {
63
- if (ch === "\n") {
64
- rows.push({ text: current, start });
65
- current = "";
66
- start = i + 1;
67
- used = 0;
68
- return;
69
- }
70
- const w = stringWidth(ch);
71
- if (used > 0 && used + w > width) {
72
- rows.push({ text: current, start });
73
- current = "";
74
- start = i;
75
- used = 0;
76
- }
77
- current += ch;
78
- used += w;
79
- });
80
- if (current || text.endsWith("\n") || text === "")
81
- rows.push({ text: current, start });
82
- // A "\n" at the exact end already closed an empty trailing row above.
83
- return rows.length > 0 ? rows : [{ text: "", start: 0 }];
84
- }
85
- /** Locate the caret within wrapEditorLine's layout. O(caret), fine for prompts. */
86
- export function locateCaret(text, caret, width) {
87
- const clamped = Math.max(0, Math.min(Array.from(text).length, caret));
88
- const rows = wrapEditorLine(text, width);
89
- // Width-based placement alone would misplace the caret when wide chars
90
- // straddle a cut boundary, so anchor on each row's start offset instead.
91
- const rowIndexAtStart = new Map();
92
- rows.forEach((r, idx) => rowIndexAtStart.set(r.start, idx));
93
- let row = 0;
94
- let col = 0;
95
- let rowOffset = 0;
96
- Array.from(text)
97
- .slice(0, clamped)
98
- .forEach((ch, i) => {
99
- const mapped = rowIndexAtStart.get(i);
100
- if (mapped !== undefined) {
101
- row = mapped;
102
- col = 0;
103
- rowOffset = 0;
104
- }
105
- col += stringWidth(ch);
106
- rowOffset += 1;
107
- });
108
- // Caret sitting exactly on a row's start offset belongs to THAT row's head
109
- // (start-of-next-line), not past the previous row's last column.
110
- const atBoundary = rowIndexAtStart.get(clamped);
111
- if (clamped > 0 && atBoundary !== undefined) {
112
- return { row: atBoundary, col: 0, rowOffset: 0, totalRows: rows.length };
113
- }
114
- return {
115
- row: Math.min(row, rows.length - 1),
116
- col,
117
- rowOffset,
118
- totalRows: rows.length,
119
- };
120
- }
121
- /**
122
- * Widest prompt prefix the input box ever shows ("starting… " while the
123
- * bridge session initializes). Text-row reservation for the app layout uses
124
- * this narrowest inner width so reserved rows NEVER undercount what
125
- * InputLine actually renders — an undercount makes ink's frame overflow the
126
- * terminal (see the completion-menu note in App.tsx).
127
- */
128
- const INPUT_PREFIX_COLS = stringWidth("starting… ");
129
- /** Visual text rows of the input editor at terminal width `cols`. */
130
- export function editorTextRows(text, cols) {
131
- const inner = Math.max(4, cols - 2 - INPUT_PREFIX_COLS);
132
- return wrapEditorLine(text, inner).length;
133
- }
134
- /**
135
- * Rows the whole bordered input box occupies: 2 border + status row + text.
136
- * Used by both the layout fold (App) and as InputLine's own height — keep in
137
- * one place so they can never drift apart.
138
- */
139
- export function inputBoxRows(editorText, cols) {
140
- return editorTextRows(editorText, cols) + 3;
141
- }
142
- /**
143
- * Sliding viewport over a picker's full item list: pinned to the head while
144
- * the selection fits in the first window, then the selection rides the
145
- * window's bottom edge one row per step until the list tail clamps it.
146
- * Pickers stay short no matter how many sessions/options exist — rendering
147
- * the full list would blow up the dynamic footer and jolt the scrollback.
148
- */
149
- export function pickerWindow(items, index, max = 8) {
150
- if (items.length === 0)
151
- return { slice: [], start: 0 };
152
- const size = Math.min(Math.max(1, max), items.length);
153
- const clampedIndex = Math.max(0, Math.min(index, items.length - 1));
154
- const start = Math.min(Math.max(0, clampedIndex - size + 1), items.length - size);
155
- return { slice: items.slice(start, start + size), start };
156
- }
157
- export function createTurnState() {
158
- return { entries: [], textBuf: "", thinkBuf: "", toolTails: {} };
159
- }
160
- /**
161
- * One-line live tail of a tool's output (collapsed whitespace, last 160
162
- * chars — same convention as the thinking tail). Exported so the footer
163
- * render and its height estimate share one string.
164
- */
165
- export function toolTailLine(tail) {
166
- return `⎿ ${tail.replace(/\s+/g, " ").slice(-160)}`;
167
- }
168
- /**
169
- * The LAST tool entry's live output tail (wrapped by toolTailLine), or null
170
- * when it has none. Older tools' tails are stale context and are ignored —
171
- * only the most recent tool row gets the live tail.
172
- */
173
- export function lastToolTail(turn) {
174
- for (let i = turn.entries.length - 1; i >= 0; i--) {
175
- const e = turn.entries[i];
176
- if (e.kind !== "tool")
177
- continue;
178
- const tail = e.id ? (turn.toolTails[e.id] ?? "") : "";
179
- return tail.trim() ? toolTailLine(tail) : null;
180
- }
181
- return null;
182
- }
183
- /** Extract displayable text from any content block shape (text/thought). */
184
- function blockText(content) {
185
- if (content && typeof content === "object" && "text" in content) {
186
- const t = content.text;
187
- return typeof t === "string" ? t : "";
188
- }
189
- return "";
190
- }
191
- /**
192
- * Plan text for an ExitPlanMode approval popup. The bridge ships it as the
193
- * permission request's `toolCall.rawInput` — `{ plan: string }` on current
194
- * backends (server-requests.ts also copies it into the tool_call's content
195
- * blocks). A bare string passes through; any other object falls back to
196
- * pretty-printed JSON so the reviewer never sees an empty popup. Null when
197
- * nothing readable is there.
198
- */
199
- export function planTextFromRawInput(raw) {
200
- if (typeof raw === "string")
201
- return raw.trim() ? raw : null;
202
- if (raw && typeof raw === "object") {
203
- const plan = raw.plan;
204
- if (typeof plan === "string" && plan.trim())
205
- return plan;
206
- // An empty object carries nothing worth showing.
207
- if (Object.keys(raw).length === 0)
208
- return null;
209
- try {
210
- const json = JSON.stringify(raw, null, 2);
211
- return json.trim() ? json : null;
212
- }
213
- catch {
214
- return null;
215
- }
216
- }
217
- return null;
218
- }
219
- /**
220
- * Fold pending stream buffers into entries. The live tail renders entries
221
- * first and the streaming buffers last, so a segment that starts later must
222
- * not stay buffered below entries that streamed earlier — flush on every
223
- * transition to keep the tail in stream order. At most one buffer is
224
- * non-empty between transitions; both are handled for safety.
225
- */
226
- function flushBuffers(next) {
227
- if (next.thinkBuf.trim()) {
228
- next.entries = [...next.entries, { kind: "thinking", text: next.thinkBuf.trim() }];
229
- next.thinkBuf = "";
230
- }
231
- if (next.textBuf.trim()) {
232
- next.entries = [...next.entries, { kind: "assistant", text: next.textBuf.trim() }];
233
- next.textBuf = "";
234
- }
235
- }
236
- /**
237
- * Apply one update to the live turn state. Returns a NEW state object (safe
238
- * for React-style always-replace updates). Unknown update kinds are ignored.
239
- */
240
- export function applyUpdate(state, update) {
241
- const next = {
242
- entries: state.entries,
243
- textBuf: state.textBuf,
244
- thinkBuf: state.thinkBuf,
245
- toolTails: state.toolTails,
246
- };
247
- switch (update.sessionUpdate) {
248
- case "user_message_chunk": {
249
- // User turns reach the REPL through two paths — replayed history and the
250
- // bridge's echo of a remote client's prompt (io.ts echoUserPromptToOthers).
251
- // Both were silently dropped before (no case here), welding neighbouring
252
- // assistant text across turn boundaries and hiding what a remote turn was
253
- // even asked. A user chunk is a hard boundary: flush pending buffers so
254
- // the prompt lands exactly between what streamed before and after it.
255
- flushBuffers(next);
256
- const chunk = blockText(update.content);
257
- if (chunk.trim())
258
- next.entries = [...next.entries, { kind: "user", text: chunk }];
259
- return next;
260
- }
261
- case "agent_message_chunk": {
262
- const chunk = blockText(update.content);
263
- // Entering prose flushes the thought stream as its own dim entry.
264
- // Continuation chunks must not re-flush, and the buffer side is
265
- // trim-tested: a whitespace-only thought chunk must not count as a
266
- // segment (it would otherwise shred every following prose chunk into
267
- // its own entry).
268
- if (chunk && next.thinkBuf.trim())
269
- flushBuffers(next);
270
- next.textBuf = next.textBuf + chunk;
271
- return next;
272
- }
273
- case "agent_thought_chunk": {
274
- const chunk = blockText(update.content);
275
- // Symmetric: entering thinking flushes the prose segment as its own
276
- // entry — otherwise it stays pinned below every later entry until the
277
- // turn ends and the tail renders out of stream order.
278
- if (chunk.trim() && !next.thinkBuf.trim())
279
- flushBuffers(next);
280
- next.thinkBuf = next.thinkBuf + chunk;
281
- return next;
282
- }
283
- case "tool_call":
284
- case "tool_call_update": {
285
- const id = update.toolCallId ?? "";
286
- // Capture the newest output tail so the footer can stream a running
287
- // tool's progress (the row renders title+status only). Progress updates
288
- // carry the backend's CUMULATIVE output snapshot, so a plain overwrite
289
- // is the latest state; updates without output (pure status flips) keep
290
- // the previous tail.
291
- const raw = update.rawOutput;
292
- if (id && typeof raw === "string" && raw.trim()) {
293
- next.toolTails = { ...next.toolTails, [id]: raw };
294
- }
295
- const title = update.title ?? id;
296
- const status = update.status ?? "pending";
297
- const idx = next.entries.findIndex((e) => e.kind === "tool" && e.id === id && id !== "");
298
- // tool_call_update may arrive for a call we never saw (pre-turn replay
299
- // leftovers); render it as a fresh row instead of dropping it.
300
- if (idx >= 0) {
301
- const old = next.entries[idx];
302
- if (old.kind === "tool") {
303
- const entries = [...next.entries];
304
- entries[idx] = { kind: "tool", id, title: update.title ?? old.title, status };
305
- next.entries = entries;
306
- }
307
- }
308
- else {
309
- // A fresh row starts a non-prose segment: flush pending buffers so
310
- // the row lands after everything streamed before it. In-place status
311
- // updates keep buffers alone — a live thinking stream would shred
312
- // into fragments on every status change.
313
- flushBuffers(next);
314
- next.entries = [...next.entries, { kind: "tool", id, title, status }];
315
- }
316
- return next;
317
- }
318
- case "plan": {
319
- // First version renders plans as a one-line note; full plan UI is a
320
- // follow-up. Counts entries when the shape provides them.
321
- flushBuffers(next);
322
- const items = Array.isArray(update.entries) ? update.entries.length : 0;
323
- next.entries = [
324
- ...next.entries,
325
- { kind: "note", text: items > 0 ? `plan · ${items} steps` : "plan updated" },
326
- ];
327
- return next;
328
- }
329
- default:
330
- return next;
331
- }
332
- }
333
- /**
334
- * Close out a turn: flush any pending thought/text buffers as final entries
335
- * and append the stop-reason note. Returns only the finished entries — the
336
- * turn state is discarded afterwards.
337
- */
338
- export function finishTurn(state, stopReason) {
339
- const entries = [...state.entries];
340
- if (state.thinkBuf.trim())
341
- entries.push({ kind: "thinking", text: state.thinkBuf.trim() });
342
- if (state.textBuf.trim())
343
- entries.push({ kind: "assistant", text: state.textBuf.trim() });
344
- if (stopReason && stopReason !== "end_turn") {
345
- entries.push({ kind: "note", text: `stopped: ${stopReason}` });
346
- }
347
- return entries;
348
- }
349
- export function parseCommand(text) {
350
- const t = text.trim();
351
- if (t === "/exit" || t === "/quit" || t === "/q")
352
- return "exit";
353
- // Shell muscle memory types `exit` without the slash — accept the bare
354
- // forms too (no whitespace-separated args: "exit now" stays a prompt).
355
- if (t === "exit" || t === "quit" || t === "q")
356
- return "exit";
357
- if (t === "/sessions")
358
- return "sessions";
359
- if (t === "/new")
360
- return "new";
361
- return null;
362
- }
363
- /**
364
- * Compact relative age for a session's `updatedAt` ("just now", "5m ago",
365
- * "2h ago", "3d ago", else the ISO date). Empty for absent/unparsable input.
366
- */
367
- export function relativeTime(iso) {
368
- if (!iso)
369
- return "";
370
- const t = Date.parse(iso);
371
- if (Number.isNaN(t))
372
- return "";
373
- const diff = Date.now() - t;
374
- if (diff < 60_000)
375
- return "just now";
376
- const mins = Math.floor(diff / 60_000);
377
- if (mins < 60)
378
- return `${mins}m ago`;
379
- const hours = Math.floor(mins / 60);
380
- if (hours < 24)
381
- return `${hours}h ago`;
382
- const days = Math.floor(hours / 24);
383
- if (days < 30)
384
- return `${days}d ago`;
385
- return new Date(t).toISOString().slice(0, 10);
386
- }
387
- export function createReplStatus() {
388
- return { commands: [], model: null, mode: null, thought: null };
389
- }
390
- /** Shared by both lists below so /help and /exit keep one description each. */
391
- const HELP_COMMAND = { name: "help", description: "list commands (REPL-local)" };
392
- const NEW_COMMAND = { name: "new", description: "start a fresh session" };
393
- const EXIT_COMMAND = { name: "exit", description: "quit the REPL" };
394
- /** Commands shown before the bridge's first `available_commands_update`. */
395
- export const FALLBACK_COMMANDS = [
396
- HELP_COMMAND,
397
- { name: "model", description: "show or switch model" },
398
- { name: "mode", description: "show or switch mode" },
399
- { name: "thought", description: "show or switch thought level" },
400
- { name: "compact", description: "compact conversation context" },
401
- { name: "mcp", description: "list configured MCP servers" },
402
- { name: "quota", description: "show plan usage card" },
403
- NEW_COMMAND,
404
- EXIT_COMMAND,
405
- ];
406
- /** REPL-local commands, always offered regardless of what the bridge advertises. */
407
- const LOCAL_COMMANDS = [
408
- HELP_COMMAND,
409
- { name: "sessions", description: "list and resume project sessions" },
410
- NEW_COMMAND,
411
- EXIT_COMMAND,
412
- ];
413
- /**
414
- * The command menu: bridge-advertised commands (or the fallback list before
415
- * the first push) with the REPL-local entries merged in front — without the
416
- * merge, /help and /exit vanish from completion and /help output as soon as
417
- * the bridge's own list lands (the bridge only advertises ITS commands).
418
- */
419
- export function commandMenu(status) {
420
- const advertised = status.commands.length > 0 ? status.commands : FALLBACK_COMMANDS;
421
- const locals = LOCAL_COMMANDS.filter((l) => !advertised.some((a) => a.name === l.name));
422
- return [...locals, ...advertised];
423
- }
424
- /**
425
- * Fold a status-relevant session update into the status. Called for EVERY
426
- * update (idle and in-turn): the config/command pushes that follow a switch
427
- * slash-command arrive while that turn is still active. Unknown kinds return
428
- * the SAME object reference so callers can skip the rerender.
429
- */
430
- export function applyStatusUpdate(status, update) {
431
- switch (update.sessionUpdate) {
432
- case "available_commands_update": {
433
- const list = (update.availableCommands ?? []).map((c) => ({
434
- name: c.name,
435
- description: c.description ?? "",
436
- }));
437
- return { ...status, commands: list };
438
- }
439
- case "config_option_update": {
440
- const next = { ...status };
441
- for (const opt of update.configOptions ?? []) {
442
- if (opt.id !== "model" && opt.id !== "mode" && opt.id !== "thought")
443
- continue;
444
- // The union also has boolean/toggle variants; only selects carry options.
445
- if (opt.type !== "select")
446
- continue;
447
- // Select entries are options or one-level groups — flatten groups.
448
- const options = (opt.options ?? []).flatMap((o) => "value" in o
449
- ? [{ value: o.value, name: o.name ?? o.value }]
450
- : (o.options ?? []).map((g) => ({ value: g.value, name: g.name ?? g.value })));
451
- next[opt.id] = { current: opt.currentValue, options };
452
- }
453
- return next;
454
- }
455
- case "current_mode_update": {
456
- return {
457
- ...status,
458
- mode: { current: update.currentModeId, options: status.mode?.options ?? [] },
459
- };
460
- }
461
- default:
462
- return status;
463
- }
464
- }
465
- /** Display label for a select's current value (name over raw value). */
466
- export function selectLabel(select) {
467
- if (!select)
468
- return "";
469
- return select.options.find((o) => o.value === select.current)?.name ?? select.current;
470
- }
471
- /**
472
- * Seed the status from the session/new RESPONSE: the bridge ships the initial
473
- * model/mode/thought selects in the response body, not as a follow-up
474
- * notification (later changes do push config_option_update notifications).
475
- */
476
- export function seedStatusFromNewSession(status, response) {
477
- return applyStatusUpdate(status, {
478
- sessionUpdate: "config_option_update",
479
- configOptions: response.configOptions ?? [],
480
- });
481
- }
482
- /**
483
- * Render a config select as note lines: one per option, current marked with a
484
- * bullet. The raw `value` is what the user types after /model etc., so it is
485
- * shown whenever it differs from the display name (third-party models).
486
- */
487
- export function formatConfigList(title, select) {
488
- if (!select || select.options.length === 0) {
489
- return [`${title}: no options advertised yet`];
490
- }
491
- const lines = [`${title}:`];
492
- for (const o of select.options) {
493
- const mark = o.value === select.current ? "●" : " ";
494
- const hint = o.value !== o.name ? ` (${o.value})` : "";
495
- lines.push(`${mark} ${o.name}${hint}`);
496
- }
497
- return lines;
498
- }
499
- function helpEntries(status) {
500
- const lines = ["commands:"];
501
- for (const c of commandMenu(status)) {
502
- lines.push(` /${c.name} — ${c.description}`);
503
- }
504
- lines.push("/model, /mode, /thought without an argument list options; with one they switch.");
505
- return lines.map((text) => ({ kind: "note", text }));
506
- }
507
- /**
508
- * REPL-local commands that never reach the bridge: `/help`, and the arg-less
509
- * listing form of `/model` `/mode` `/thought` (the bridge rejects those with
510
- * -32602). Everything else — including the switch forms with an argument —
511
- * returns null and is sent as a prompt (the bridge's slash interception is
512
- * the battle-tested path editors use).
513
- */
514
- export function handleLocalCommand(text, status) {
515
- const stripped = text.trim();
516
- if (!stripped.startsWith("/"))
517
- return null;
518
- const spaceIdx = stripped.indexOf(" ");
519
- const cmd = (spaceIdx < 0 ? stripped : stripped.slice(0, spaceIdx)).toLowerCase();
520
- const hasArg = spaceIdx >= 0 && stripped.slice(spaceIdx + 1).trim().length > 0;
521
- if (cmd === "/help")
522
- return helpEntries(status);
523
- if (cmd === "/model" || cmd === "/mode" || cmd === "/thought") {
524
- if (hasArg)
525
- return null;
526
- const key = cmd.slice(1);
527
- return formatConfigList(cmd, status[key]).map((text) => ({ kind: "note", text }));
528
- }
529
- return null;
530
- }
531
- /**
532
- * Max rows the menu renders — longer lists narrow with the typed prefix.
533
- * Also the FIXED slot count of the rendered menu block (blanks beyond the
534
- * candidate count): constant height keeps ink's frame updates equal-size,
535
- * which is what keeps the top highlight visible (see App.tsx menu block).
536
- */
537
- export const COMPLETION_LIMIT = 8;
538
- /** Commands whose single argument is completable from the config selects. */
539
- const CONFIG_COMMANDS = new Set(["model", "mode", "thought"]);
540
- /** Whether `cmd` (bare name, no slash) takes a completable config argument. */
541
- export function isConfigCommand(cmd) {
542
- return CONFIG_COMMANDS.has(cmd);
543
- }
544
- /**
545
- * True when the line sits in a config command's argument menu ("/model v…").
546
- * There the highlighted option IS the decision — enter executes the switch
547
- * directly. Every other completion context (command names, skill commands,
548
- * plugins) only FILLS the line; sending stays the user's explicit choice.
549
- * Only meaningful when a completion menu is open (line starts with "/").
550
- */
551
- export function isConfigArgumentMenu(value) {
552
- if (!value.startsWith("/"))
553
- return false;
554
- const spaceIdx = value.indexOf(" ");
555
- if (spaceIdx < 0)
556
- return false;
557
- return CONFIG_COMMANDS.has(value.slice(1, spaceIdx).toLowerCase());
558
- }
559
- /**
560
- * Bare command names whose whole action needs no input ("exit", "help", ...):
561
- * enter ON PICK runs them directly, like config argument menus. Anything not
562
- * listed here — skills, plugin commands, unknown advertised names — keeps
563
- * fill semantics because its bare form usually expects an argument, and
564
- * sending must stay the user's explicit act.
565
- */
566
- const ONE_SHOT_COMMANDS = new Set(["help", "sessions", "new", "exit", "compact", "mcp", "quota"]);
567
- /** Whether the "/"-prefixed single-token line executes immediately when picked. */
568
- export function isOneShotCommandValue(value) {
569
- if (!value.startsWith("/") || value.indexOf(" ") >= 0)
570
- return false;
571
- return ONE_SHOT_COMMANDS.has(value.slice(1).toLowerCase());
572
- }
573
- /**
574
- * Completion candidates for the current line, or null outside a completion
575
- * context:
576
- * - "/par" → advertised commands matching the prefix ("/" alone = all)
577
- * - "/model v" → model option values/names matching the argument prefix
578
- * - other text, a second space, or a select not yet advertised → null/[]
579
- */
580
- export function completionCandidates(value, status) {
581
- if (!value.startsWith("/"))
582
- return null;
583
- const spaceIdx = value.indexOf(" ");
584
- if (spaceIdx < 0) {
585
- const query = value.slice(1).toLowerCase();
586
- return commandMenu(status)
587
- .filter((c) => c.name.toLowerCase().startsWith(query))
588
- .slice(0, COMPLETION_LIMIT)
589
- .map((c) => ({ value: `/${c.name}`, label: `/${c.name}`, description: c.description }));
590
- }
591
- const cmd = value.slice(1, spaceIdx).toLowerCase();
592
- if (!CONFIG_COMMANDS.has(cmd))
593
- return null;
594
- const arg = value.slice(spaceIdx + 1);
595
- if (arg.includes(" "))
596
- return null; // past the single argument
597
- const select = status[cmd];
598
- if (!select)
599
- return null;
600
- const query = arg.toLowerCase();
601
- return select.options
602
- .filter((o) => o.value.toLowerCase().startsWith(query) || o.name.toLowerCase().startsWith(query))
603
- .slice(0, COMPLETION_LIMIT)
604
- .map((o) => ({
605
- value: o.value,
606
- label: o.name,
607
- description: o.value !== o.name ? o.value : undefined,
608
- current: o.value === select.current,
609
- }));
610
- }
611
- /**
612
- * Insert a chosen candidate into the line. Command completions keep a
613
- * trailing space so the argument menu opens immediately; option completions
614
- * replace the argument in place (`/model <value>`).
615
- */
616
- export function applyCompletion(value, item) {
617
- const spaceIdx = value.indexOf(" ");
618
- if (spaceIdx < 0)
619
- return `${item.value} `;
620
- return `${value.slice(0, spaceIdx + 1)}${item.value}`;
621
- }
622
- /**
623
- * Parse an `elicitation/create` form payload (the bridge's AskUserQuestion
624
- * mapping — see `buildAskUserElicitationForm`) into renderable questions:
625
- * `q_<i>` string fields with `oneOf` const/title options (single-select) or
626
- * array fields with `items.anyOf` (multi-select).
627
- *
628
- * The `q_<i>_other` free-text companions are skipped (the picker has a
629
- * built-in custom-answer row) and the skip sentinel option is dropped (esc
630
- * covers skip). Returns null when the payload has no renderable question.
631
- */
632
- export function parseQuestionForm(params) {
633
- if (!params || typeof params !== "object")
634
- return null;
635
- const schema = params.requestedSchema;
636
- if (!schema || typeof schema !== "object")
637
- return null;
638
- const props = schema.properties;
639
- if (!props || typeof props !== "object")
640
- return null;
641
- const entries = Object.entries(props)
642
- .filter(([key]) => /^q_\d+$/.test(key))
643
- .sort((a, b) => Number(a[0].slice(2)) - Number(b[0].slice(2)));
644
- const forms = [];
645
- for (const [key, prop] of entries) {
646
- if (!prop || typeof prop !== "object")
647
- continue;
648
- const p = prop;
649
- if (typeof p.title !== "string")
650
- continue;
651
- const raw = Array.isArray(p.oneOf)
652
- ? p.oneOf
653
- : Array.isArray(p.items?.anyOf)
654
- ? p.items.anyOf
655
- : null;
656
- if (raw === null)
657
- continue;
658
- const options = [];
659
- for (const o of raw) {
660
- if (!o || typeof o !== "object")
661
- continue;
662
- const value = o.const;
663
- if (typeof value !== "string" || value === "__skip__")
664
- continue;
665
- const title = o.title;
666
- options.push({ value, label: typeof title === "string" ? title : value });
667
- }
668
- if (options.length === 0)
669
- continue;
670
- forms.push({ key, title: p.title, multiSelect: p.type === "array", options });
671
- }
672
- return forms.length > 0 ? forms : null;
673
- }
674
- //# sourceMappingURL=model.js.map