@kinqs/brainrouter-cli 0.3.5 → 0.3.7

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 (125) hide show
  1. package/README.md +29 -52
  2. package/agents/architect.json +18 -0
  3. package/agents/explorer.json +18 -0
  4. package/agents/reviewer.json +18 -0
  5. package/agents/verifier.json +18 -0
  6. package/agents/worker.json +18 -0
  7. package/bin/cli.cjs +71 -0
  8. package/dist/agent/agent.d.ts +224 -3
  9. package/dist/agent/agent.js +561 -55
  10. package/dist/cli/banner.d.ts +80 -0
  11. package/dist/cli/banner.js +232 -0
  12. package/dist/cli/cliPrompt.d.ts +106 -0
  13. package/dist/cli/cliPrompt.js +314 -0
  14. package/dist/cli/commands/_context.d.ts +3 -1
  15. package/dist/cli/commands/_helpers.d.ts +1 -1
  16. package/dist/cli/commands/_helpers.js +6 -6
  17. package/dist/cli/commands/config.d.ts +46 -0
  18. package/dist/cli/commands/config.js +1042 -0
  19. package/dist/cli/commands/guard.js +75 -10
  20. package/dist/cli/commands/init.d.ts +20 -0
  21. package/dist/cli/commands/init.js +64 -0
  22. package/dist/cli/commands/login.d.ts +13 -0
  23. package/dist/cli/commands/login.js +179 -0
  24. package/dist/cli/commands/mcp.d.ts +19 -0
  25. package/dist/cli/commands/mcp.js +286 -0
  26. package/dist/cli/commands/memory.js +2 -2
  27. package/dist/cli/commands/obs.js +22 -22
  28. package/dist/cli/commands/orchestration.js +18 -0
  29. package/dist/cli/commands/session.js +13 -5
  30. package/dist/cli/commands/ui.js +202 -91
  31. package/dist/cli/commands/workflow.d.ts +20 -0
  32. package/dist/cli/commands/workflow.js +368 -51
  33. package/dist/cli/ink/ChatApp.d.ts +206 -0
  34. package/dist/cli/ink/ChatApp.js +493 -0
  35. package/dist/cli/ink/Frame.d.ts +26 -0
  36. package/dist/cli/ink/Frame.js +5 -0
  37. package/dist/cli/ink/Picker.d.ts +65 -0
  38. package/dist/cli/ink/Picker.js +133 -0
  39. package/dist/cli/ink/SlashPalette.d.ts +51 -0
  40. package/dist/cli/ink/SlashPalette.js +136 -0
  41. package/dist/cli/ink/TextField.d.ts +34 -0
  42. package/dist/cli/ink/TextField.js +47 -0
  43. package/dist/cli/ink/WizardApp.d.ts +7 -0
  44. package/dist/cli/ink/WizardApp.js +422 -0
  45. package/dist/cli/ink/ambientChat.d.ts +34 -0
  46. package/dist/cli/ink/ambientChat.js +7 -0
  47. package/dist/cli/ink/consoleCapture.d.ts +11 -0
  48. package/dist/cli/ink/consoleCapture.js +33 -0
  49. package/dist/cli/ink/markdownRender.d.ts +41 -0
  50. package/dist/cli/ink/markdownRender.js +278 -0
  51. package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
  52. package/dist/cli/ink/renderWithResizeClear.js +33 -0
  53. package/dist/cli/ink/runChat.d.ts +34 -0
  54. package/dist/cli/ink/runChat.js +571 -0
  55. package/dist/cli/ink/runPicker.d.ts +31 -0
  56. package/dist/cli/ink/runPicker.js +139 -0
  57. package/dist/cli/ink/runSlashPalette.d.ts +23 -0
  58. package/dist/cli/ink/runSlashPalette.js +33 -0
  59. package/dist/cli/ink/runWizard.d.ts +22 -0
  60. package/dist/cli/ink/runWizard.js +133 -0
  61. package/dist/cli/ink/stdinHandoff.d.ts +51 -0
  62. package/dist/cli/ink/stdinHandoff.js +78 -0
  63. package/dist/cli/ink/toolFormat.d.ts +73 -0
  64. package/dist/cli/ink/toolFormat.js +180 -0
  65. package/dist/cli/ink/useTerminalSize.d.ts +35 -0
  66. package/dist/cli/ink/useTerminalSize.js +26 -0
  67. package/dist/cli/repl.d.ts +25 -3
  68. package/dist/cli/repl.js +64 -646
  69. package/dist/cli/slashSuggest.d.ts +32 -0
  70. package/dist/cli/slashSuggest.js +146 -0
  71. package/dist/cli/spinner.d.ts +34 -0
  72. package/dist/cli/spinner.js +36 -0
  73. package/dist/cli/statusline.d.ts +67 -0
  74. package/dist/cli/statusline.js +204 -0
  75. package/dist/cli/theme.d.ts +79 -0
  76. package/dist/cli/theme.js +106 -0
  77. package/dist/cli/whereView.d.ts +81 -0
  78. package/dist/cli/whereView.js +245 -0
  79. package/dist/cli/wizard/modelsApi.d.ts +72 -0
  80. package/dist/cli/wizard/modelsApi.js +166 -0
  81. package/dist/cli/wizard/picker.d.ts +202 -0
  82. package/dist/cli/wizard/picker.js +547 -0
  83. package/dist/cli/wizard/providers.d.ts +86 -0
  84. package/dist/cli/wizard/providers.js +190 -0
  85. package/dist/cli/wizard/runner.d.ts +13 -0
  86. package/dist/cli/wizard/runner.js +488 -0
  87. package/dist/cli/wizard/types.d.ts +122 -0
  88. package/dist/cli/wizard/types.js +109 -0
  89. package/dist/config/config.d.ts +52 -0
  90. package/dist/config/config.js +89 -75
  91. package/dist/index.js +215 -206
  92. package/dist/memory/briefing.d.ts +11 -1
  93. package/dist/memory/briefing.js +69 -1
  94. package/dist/memory/consolidation.d.ts +1 -1
  95. package/dist/orchestration/agentRegistry.d.ts +36 -0
  96. package/dist/orchestration/agentRegistry.js +64 -0
  97. package/dist/orchestration/orchestrator.d.ts +7 -0
  98. package/dist/orchestration/orchestrator.js +2 -0
  99. package/dist/orchestration/tools.d.ts +10 -1
  100. package/dist/orchestration/tools.js +48 -4
  101. package/dist/prompt/breadthHint.d.ts +5 -0
  102. package/dist/prompt/breadthHint.js +44 -0
  103. package/dist/prompt/skillCatalog.d.ts +11 -0
  104. package/dist/prompt/skillCatalog.js +134 -0
  105. package/dist/prompt/skillRunner.d.ts +2 -2
  106. package/dist/prompt/skillRunner.js +2 -31
  107. package/dist/prompt/systemPrompt.d.ts +34 -0
  108. package/dist/prompt/systemPrompt.js +128 -108
  109. package/dist/runtime/dangerousCommand.d.ts +53 -0
  110. package/dist/runtime/dangerousCommand.js +105 -0
  111. package/dist/runtime/mcpClient.d.ts +38 -1
  112. package/dist/runtime/mcpClient.js +104 -13
  113. package/dist/runtime/mcpPool.d.ts +162 -0
  114. package/dist/runtime/mcpPool.js +423 -0
  115. package/dist/runtime/mcpUtils.d.ts +3 -1
  116. package/dist/state/goalStore.d.ts +98 -17
  117. package/dist/state/goalStore.js +132 -42
  118. package/dist/state/preferencesStore.d.ts +67 -3
  119. package/dist/state/preferencesStore.js +84 -1
  120. package/dist/state/workflowArtifacts.d.ts +63 -2
  121. package/dist/state/workflowArtifacts.js +120 -8
  122. package/dist/tests/_helpers.d.ts +31 -0
  123. package/dist/tests/_helpers.js +91 -0
  124. package/package.json +12 -5
  125. package/.env.example +0 -109
@@ -0,0 +1,547 @@
1
+ import readline from 'node:readline';
2
+ import { getActiveReadline, setActiveReadline } from '../cliPrompt.js';
3
+ import { buildTheme } from '../theme.js';
4
+ // --- Module-level shared state ----------------------------------------
5
+ let internalPickerActive = false;
6
+ export function isInternalPickerActive() { return internalPickerActive; }
7
+ /**
8
+ * Compute the full picker frame as a single string. Pure function so
9
+ * tests can assert on the exact output without driving a TTY.
10
+ *
11
+ * Layout (single column for now — wide-terminal two-column comes in a
12
+ * follow-up):
13
+ *
14
+ * ┌─ <title> ─────────────────────── <badge> ─┐
15
+ * │ <subtitle> │
16
+ * │ │
17
+ * │ <body line 1> │
18
+ * │ <body line 2> │
19
+ * │ ... │
20
+ * │ │ (preview block if present)
21
+ * │ <preview line 1> │
22
+ * │ <preview line 2> │
23
+ * │ │
24
+ * │ <footer> │
25
+ * └───────────────────────────────────────────┘
26
+ */
27
+ export function renderFrame(f) {
28
+ const t = f.theme;
29
+ const W = f.width;
30
+ // Inner content width: W minus 2 border cols minus 2 padding cols.
31
+ const inner = Math.max(20, W - 4);
32
+ const top = renderTopBorder(t, f.title, f.badge, W);
33
+ const lines = [top];
34
+ if (f.subtitle) {
35
+ for (const wrapped of wrap(f.subtitle, inner)) {
36
+ lines.push(t.primary('│') + ' ' + t.muted(padRight(wrapped, inner)) + ' ' + t.primary('│'));
37
+ }
38
+ lines.push(blank(t, W));
39
+ }
40
+ for (const raw of f.bodyLines) {
41
+ // Wrap is opt-out for body — the picker pre-formats option rows with
42
+ // exact widths, so let those pass through verbatim.
43
+ lines.push(t.primary('│') + ' ' + padRightVisible(raw, inner) + ' ' + t.primary('│'));
44
+ }
45
+ if (f.previewLines && f.previewLines.length > 0) {
46
+ lines.push(divider(t, W));
47
+ for (const raw of f.previewLines) {
48
+ lines.push(t.primary('│') + ' ' + padRightVisible(raw, inner) + ' ' + t.primary('│'));
49
+ }
50
+ }
51
+ lines.push(blank(t, W));
52
+ lines.push(t.primary('│') + ' ' + padRightVisible(t.muted(f.footer), inner) + ' ' + t.primary('│'));
53
+ lines.push(t.primary('└' + '─'.repeat(W - 2) + '┘'));
54
+ return lines.join('\n');
55
+ }
56
+ function renderTopBorder(t, title, badge, W) {
57
+ const titleText = ` ${t.heading(title)} `;
58
+ const badgeText = badge ? ` ${t.muted(badge)} ` : '';
59
+ const titleWidth = visibleLength(titleText);
60
+ const badgeWidth = visibleLength(badgeText);
61
+ const dashWidth = Math.max(2, W - 2 - titleWidth - badgeWidth);
62
+ return (t.primary('┌─') + titleText
63
+ + t.primary('─'.repeat(dashWidth))
64
+ + badgeText
65
+ + t.primary('┐'));
66
+ }
67
+ function blank(t, W) {
68
+ return t.primary('│') + ' '.repeat(W - 2) + t.primary('│');
69
+ }
70
+ function divider(t, W) {
71
+ // Subtle in-frame separator — single dim line, no chars.
72
+ return t.primary('├') + t.dim('─'.repeat(W - 2)) + t.primary('┤');
73
+ }
74
+ function padRight(s, w) {
75
+ if (s.length >= w)
76
+ return s.slice(0, w);
77
+ return s + ' '.repeat(w - s.length);
78
+ }
79
+ /** ANSI-aware right-pad. Strips ANSI sequences when counting width. */
80
+ function padRightVisible(s, w) {
81
+ const v = visibleLength(s);
82
+ if (v >= w)
83
+ return clipVisible(s, w);
84
+ return s + ' '.repeat(w - v);
85
+ }
86
+ function visibleLength(s) {
87
+ return stripAnsi(s).length;
88
+ }
89
+ function stripAnsi(s) {
90
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
91
+ }
92
+ function clipVisible(s, w) {
93
+ // Naive ANSI-aware clip — used only for badge / overflow protection.
94
+ let out = '';
95
+ let visible = 0;
96
+ let i = 0;
97
+ while (i < s.length && visible < w) {
98
+ if (s[i] === '\x1b') {
99
+ const end = s.indexOf('m', i);
100
+ if (end < 0)
101
+ break;
102
+ out += s.slice(i, end + 1);
103
+ i = end + 1;
104
+ continue;
105
+ }
106
+ out += s[i];
107
+ i++;
108
+ visible++;
109
+ }
110
+ return out;
111
+ }
112
+ /** Simple word-wrap; doesn't try to be ANSI-aware (subtitle takes plain text). */
113
+ function wrap(s, w) {
114
+ const words = s.split(/\s+/);
115
+ const lines = [];
116
+ let line = '';
117
+ for (const word of words) {
118
+ if (!line) {
119
+ line = word;
120
+ continue;
121
+ }
122
+ if (line.length + 1 + word.length <= w) {
123
+ line += ' ' + word;
124
+ }
125
+ else {
126
+ lines.push(line);
127
+ line = word;
128
+ }
129
+ }
130
+ if (line)
131
+ lines.push(line);
132
+ return lines.length > 0 ? lines : [''];
133
+ }
134
+ function formatBodyRow(t, row, isSelected, valueColWidth, inner) {
135
+ // Selected glyph: `›` lifted from openSrc/grok-cli/src/ui/components/SuggestionOverlay.tsx
136
+ // (we use ▶ in the LLM-tool picker; switch to › for the internal picker
137
+ // because it reads cleaner against the chalk gray + bold combo).
138
+ const marker = isSelected ? t.primary('›') : ' ';
139
+ const labelFg = row.disabled ? t.dim : isSelected ? t.heading : t.plain;
140
+ const valueFg = isSelected ? t.muted : t.dim;
141
+ const label = labelFg(row.label);
142
+ const value = row.value ? valueFg(row.value) : '';
143
+ // Layout: " › LABEL ...VALUE" with value right-aligned.
144
+ const leftPart = ' ' + marker + ' ' + label;
145
+ const leftVisible = visibleLength(leftPart);
146
+ const valueVisible = visibleLength(value);
147
+ const gapWidth = Math.max(2, inner - leftVisible - valueVisible);
148
+ const line = leftPart + ' '.repeat(gapWidth) + value;
149
+ const lines = [line];
150
+ if (row.description) {
151
+ const INDENT = ' '; // 5 spaces — aligns under "› LABEL"
152
+ // Wrap the bare description (no indent) to the inner width MINUS
153
+ // the indent so the indented line stays inside the frame. Then
154
+ // re-indent each wrapped line and apply the dim color uniformly.
155
+ const wrapped = wrap(row.description, Math.max(8, inner - INDENT.length));
156
+ for (const w of wrapped) {
157
+ lines.push(INDENT + t.dim(w));
158
+ }
159
+ }
160
+ return lines;
161
+ }
162
+ // --- pickFromList ------------------------------------------------------
163
+ export async function pickFromList(opts) {
164
+ return runFramedInput(async (frame) => {
165
+ const theme = opts.theme ?? buildTheme('dark');
166
+ const augmentedRows = opts.allowOther
167
+ ? [
168
+ ...opts.rows,
169
+ {
170
+ id: '__other__',
171
+ label: opts.otherLabel ?? 'Other',
172
+ description: opts.otherDescription ?? 'Type a free-form answer',
173
+ },
174
+ ]
175
+ : [...opts.rows];
176
+ let cursor = clamp(opts.initialCursor ?? 0, 0, augmentedRows.length - 1);
177
+ let phase = opts.prefilledOther !== undefined ? 'other' : 'pick';
178
+ let otherText = opts.prefilledOther ?? '';
179
+ let previewLines;
180
+ // Initial preview if a row is selected on entry.
181
+ const fireCursorChange = () => {
182
+ if (opts.onCursorChange && phase === 'pick') {
183
+ const row = augmentedRows[cursor];
184
+ if (row && row.id !== '__other__') {
185
+ try {
186
+ previewLines = opts.onCursorChange(row.id, cursor);
187
+ }
188
+ catch {
189
+ previewLines = undefined;
190
+ }
191
+ }
192
+ else {
193
+ previewLines = undefined;
194
+ }
195
+ }
196
+ };
197
+ fireCursorChange();
198
+ const computeFrame = () => {
199
+ const W = computeWidth(opts.title, augmentedRows, theme);
200
+ const inner = Math.max(20, W - 4);
201
+ const bodyLines = [];
202
+ if (phase === 'pick') {
203
+ const valueColWidth = computeValueColumn(augmentedRows);
204
+ for (let i = 0; i < augmentedRows.length; i++) {
205
+ const row = augmentedRows[i];
206
+ const formatted = formatBodyRow(theme, row, i === cursor, valueColWidth, inner);
207
+ bodyLines.push(...formatted);
208
+ }
209
+ }
210
+ else {
211
+ // Free-text "Other" phase.
212
+ bodyLines.push(' ' + theme.muted('›') + ' ' + theme.heading('Type your answer'));
213
+ bodyLines.push(' ' + theme.dim(opts.otherDescription ?? 'Press ENTER to accept · Esc to go back'));
214
+ bodyLines.push('');
215
+ const display = otherText.length > 0 ? otherText : theme.dim('(empty)');
216
+ bodyLines.push(' ' + theme.info('›') + ' ' + display + theme.muted('_'));
217
+ }
218
+ const footer = opts.footer ?? defaultFooter(phase, !!opts.allowOther);
219
+ return renderFrame({
220
+ theme,
221
+ title: opts.title,
222
+ subtitle: opts.subtitle,
223
+ badge: opts.badge,
224
+ bodyLines,
225
+ previewLines,
226
+ footer,
227
+ width: W,
228
+ });
229
+ };
230
+ return new Promise((resolve) => {
231
+ frame.draw(computeFrame());
232
+ frame.onKey((key, str) => {
233
+ if (key.ctrl && (key.name === 'c' || key.sequence === '')) {
234
+ frame.close();
235
+ resolve({ kind: 'cancelled' });
236
+ return;
237
+ }
238
+ if (phase === 'other') {
239
+ if (key.name === 'return') {
240
+ const trimmed = otherText.trim();
241
+ if (!trimmed)
242
+ return; // require non-empty
243
+ frame.close();
244
+ resolve({ kind: 'other', text: trimmed });
245
+ return;
246
+ }
247
+ if (key.name === 'escape') {
248
+ phase = 'pick';
249
+ otherText = '';
250
+ fireCursorChange();
251
+ frame.draw(computeFrame());
252
+ return;
253
+ }
254
+ if (key.name === 'backspace') {
255
+ if (otherText.length > 0) {
256
+ otherText = otherText.slice(0, -1);
257
+ frame.draw(computeFrame());
258
+ }
259
+ return;
260
+ }
261
+ if (typeof str === 'string' && str.length === 1 && !key.ctrl && key.name !== 'tab') {
262
+ otherText += str;
263
+ frame.draw(computeFrame());
264
+ return;
265
+ }
266
+ return;
267
+ }
268
+ // pick phase
269
+ if (key.name === 'up' || (key.name === 'k' && !key.ctrl && !key.meta)) {
270
+ cursor = (cursor - 1 + augmentedRows.length) % augmentedRows.length;
271
+ while (augmentedRows[cursor].disabled)
272
+ cursor = (cursor - 1 + augmentedRows.length) % augmentedRows.length;
273
+ fireCursorChange();
274
+ frame.draw(computeFrame());
275
+ return;
276
+ }
277
+ if (key.name === 'down' || (key.name === 'j' && !key.ctrl && !key.meta)) {
278
+ cursor = (cursor + 1) % augmentedRows.length;
279
+ while (augmentedRows[cursor].disabled)
280
+ cursor = (cursor + 1) % augmentedRows.length;
281
+ fireCursorChange();
282
+ frame.draw(computeFrame());
283
+ return;
284
+ }
285
+ if (key.name === 'return') {
286
+ const row = augmentedRows[cursor];
287
+ if (row.disabled)
288
+ return;
289
+ if (row.id === '__other__') {
290
+ phase = 'other';
291
+ previewLines = undefined;
292
+ frame.draw(computeFrame());
293
+ return;
294
+ }
295
+ frame.close();
296
+ resolve({ kind: 'pick', id: row.id });
297
+ return;
298
+ }
299
+ if (key.name === 'escape' || key.name === 'q') {
300
+ frame.close();
301
+ resolve({ kind: 'cancelled' });
302
+ return;
303
+ }
304
+ });
305
+ });
306
+ }, { eraseOnClose: opts.eraseOnClose });
307
+ }
308
+ function clamp(n, lo, hi) {
309
+ if (hi < lo)
310
+ return lo;
311
+ return Math.max(lo, Math.min(hi, n));
312
+ }
313
+ function computeValueColumn(rows) {
314
+ let max = 0;
315
+ for (const row of rows)
316
+ if (row.value)
317
+ max = Math.max(max, visibleLength(row.value));
318
+ return max;
319
+ }
320
+ function computeWidth(title, rows, _theme) {
321
+ const terminal = (process.stdout.columns ?? 80);
322
+ const target = 76;
323
+ const min = 56;
324
+ const max = Math.max(min, Math.min(terminal - 4, 100));
325
+ let widest = visibleLength(title) + 12; // title + badge slack
326
+ for (const row of rows) {
327
+ const valueW = row.value ? visibleLength(row.value) : 0;
328
+ const labelW = visibleLength(row.label);
329
+ widest = Math.max(widest, labelW + 6 + valueW); // gap + glyph
330
+ if (row.description)
331
+ widest = Math.max(widest, visibleLength(row.description) + 6);
332
+ }
333
+ return clamp(Math.max(widest + 4, target), min, max);
334
+ }
335
+ function defaultFooter(phase, allowOther) {
336
+ if (phase === 'other') {
337
+ return '↵ accept · esc back · ⌫ erase';
338
+ }
339
+ return allowOther
340
+ ? '↑/↓ navigate · ↵ confirm · esc / q cancel'
341
+ : '↑/↓ navigate · ↵ confirm · esc / q cancel';
342
+ }
343
+ // --- promptText --------------------------------------------------------
344
+ export async function promptText(opts) {
345
+ return runFramedInput(async (frame) => {
346
+ const theme = opts.theme ?? buildTheme('dark');
347
+ let text = opts.prefilled ?? '';
348
+ let error;
349
+ const computeFrame = () => {
350
+ const W = Math.max(60, Math.min((process.stdout.columns ?? 80) - 4, 90));
351
+ const inner = Math.max(20, W - 4);
352
+ const bodyLines = [];
353
+ const visibleText = text.length === 0
354
+ ? theme.dim(opts.placeholder ?? '(type here)')
355
+ : opts.mask ? maskInput(text) : text;
356
+ bodyLines.push(' ' + theme.info('›') + ' ' + visibleText + theme.muted('_'));
357
+ if (error) {
358
+ bodyLines.push('');
359
+ bodyLines.push(' ' + theme.danger('✗ ' + error));
360
+ }
361
+ return renderFrame({
362
+ theme,
363
+ title: opts.title,
364
+ subtitle: opts.subtitle,
365
+ badge: opts.badge,
366
+ bodyLines,
367
+ footer: opts.footer ?? '↵ accept · esc cancel · ⌫ erase',
368
+ width: W,
369
+ });
370
+ };
371
+ return new Promise((resolve) => {
372
+ frame.draw(computeFrame());
373
+ frame.onKey((key, str) => {
374
+ if (key.ctrl && (key.name === 'c' || key.sequence === '')) {
375
+ frame.close();
376
+ resolve({ kind: 'cancelled' });
377
+ return;
378
+ }
379
+ if (key.name === 'escape') {
380
+ frame.close();
381
+ resolve({ kind: 'cancelled' });
382
+ return;
383
+ }
384
+ if (key.name === 'return') {
385
+ const validate = opts.validate;
386
+ if (validate) {
387
+ const verdict = validate(text);
388
+ if (verdict !== undefined) {
389
+ error = verdict;
390
+ frame.draw(computeFrame());
391
+ return;
392
+ }
393
+ }
394
+ frame.close();
395
+ resolve({ kind: 'accept', text });
396
+ return;
397
+ }
398
+ if (key.name === 'backspace') {
399
+ if (text.length > 0) {
400
+ text = text.slice(0, -1);
401
+ error = undefined;
402
+ frame.draw(computeFrame());
403
+ }
404
+ return;
405
+ }
406
+ if (typeof str === 'string' && str.length === 1 && !key.ctrl && key.name !== 'tab') {
407
+ text += str;
408
+ error = undefined;
409
+ frame.draw(computeFrame());
410
+ return;
411
+ }
412
+ });
413
+ });
414
+ }, { eraseOnClose: opts.eraseOnClose });
415
+ }
416
+ function maskInput(s) {
417
+ if (s.length <= 4)
418
+ return '·'.repeat(s.length);
419
+ return '·'.repeat(Math.max(4, s.length - 4)) + s.slice(-4);
420
+ }
421
+ /**
422
+ * Owns stdin / cursor visibility / atomic redraw for the lifetime of a
423
+ * single picker or prompt. The caller passes a function that returns a
424
+ * Promise; we manage everything else.
425
+ *
426
+ * **Redraw math** — the source of the earlier "frame creeps upward
427
+ * on every arrow key" bug.
428
+ *
429
+ * After writing a frame of M lines separated by M-1 newlines, the
430
+ * cursor sits at the END of line M (NOT one line below). So to land
431
+ * back at the START of line 1, we need to move up `M-1` lines, not
432
+ * `M`. Earlier code used `text.split('\n').length` which is M, off by
433
+ * one. We now track the newline count directly and use
434
+ * `\x1b[<newlines>F` (move up + col 1, atomic). When newlines is 0
435
+ * (single-line frame, edge case), we use `\r\x1b[K` to clear the
436
+ * single line instead.
437
+ */
438
+ async function runFramedInput(body, opts = {}) {
439
+ const stdout = process.stdout;
440
+ const ownsReadline = !getActiveReadline();
441
+ let rl;
442
+ if (ownsReadline) {
443
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
444
+ setActiveReadline(rl);
445
+ }
446
+ else {
447
+ rl = getActiveReadline();
448
+ rl.pause();
449
+ }
450
+ readline.emitKeypressEvents(process.stdin);
451
+ try {
452
+ process.stdin.setRawMode?.(true);
453
+ }
454
+ catch { /* not a real TTY */ }
455
+ process.stdin.resume();
456
+ stdout.write('\x1b[?25l');
457
+ internalPickerActive = true;
458
+ // Number of `\n` chars in the LAST frame we wrote. For an M-line
459
+ // frame the count is M-1; that's exactly how many lines we need to
460
+ // move the cursor up to land on the top row.
461
+ let lastFrameNewlines = 0;
462
+ let hasDrawn = false;
463
+ let keyHandler;
464
+ const eraseLastFrame = () => {
465
+ if (!hasDrawn)
466
+ return;
467
+ if (lastFrameNewlines > 0) {
468
+ // `\x1b[<n>F` = cursor up n lines AND col 1 (atomic). Then
469
+ // `\x1b[J` erases from cursor to end of screen. After this the
470
+ // cursor sits at the top-left of where the previous frame was.
471
+ stdout.write(`\x1b[${lastFrameNewlines}F\x1b[J`);
472
+ }
473
+ else {
474
+ // Single-line previous frame — just clear the current line in
475
+ // place. `\r` to col 0, `\x1b[K` erase to end of line.
476
+ stdout.write('\r\x1b[K');
477
+ }
478
+ };
479
+ const draw = (text) => {
480
+ eraseLastFrame();
481
+ if (!hasDrawn) {
482
+ // First draw — make sure we're at column 0 so the frame top
483
+ // border doesn't sit mid-line.
484
+ stdout.write('\r');
485
+ }
486
+ stdout.write(text);
487
+ // Count newlines (NOT lines). `"a\nb\nc".match(/\n/g) → ['\n', '\n']`
488
+ // → length 2; that's the correct cursor-up count.
489
+ lastFrameNewlines = (text.match(/\n/g) ?? []).length;
490
+ hasDrawn = true;
491
+ };
492
+ const onKeyInternal = (str, key) => {
493
+ if (keyHandler)
494
+ keyHandler(key ?? {}, str);
495
+ };
496
+ process.stdin.on('keypress', onKeyInternal);
497
+ const cleanup = () => {
498
+ process.stdin.removeListener('keypress', onKeyInternal);
499
+ stdout.write('\x1b[?25h');
500
+ if (opts.eraseOnClose !== false) {
501
+ // Default — erase the last frame entirely so the next step's
502
+ // frame (or post-picker print) starts at the same screen
503
+ // position and visually replaces this one. Without this, each
504
+ // step's cleanup would write `\n` and the next picker would
505
+ // draw BELOW the previous one, accumulating frames down the
506
+ // screen on every navigation.
507
+ eraseLastFrame();
508
+ }
509
+ else {
510
+ // Opt-out: leave the frame on screen as scrollback.
511
+ stdout.write('\n');
512
+ }
513
+ internalPickerActive = false;
514
+ if (ownsReadline && rl) {
515
+ setActiveReadline(undefined);
516
+ try {
517
+ rl.close();
518
+ }
519
+ catch { /* ignore */ }
520
+ }
521
+ };
522
+ const handle = {
523
+ draw,
524
+ onKey: (h) => { keyHandler = h; },
525
+ close: cleanup,
526
+ };
527
+ try {
528
+ const result = await body(handle);
529
+ return result;
530
+ }
531
+ catch (err) {
532
+ cleanup();
533
+ throw err;
534
+ }
535
+ }
536
+ // --- Surface re-exports for tests + callers ---------------------------
537
+ /** Pure helpers exposed for unit tests. */
538
+ export const __test = {
539
+ renderFrame,
540
+ formatBodyRow,
541
+ visibleLength,
542
+ stripAnsi,
543
+ wrap,
544
+ padRightVisible,
545
+ computeValueColumn,
546
+ defaultFooter,
547
+ };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * 0.3.7 wizard — curated provider catalogue.
3
+ *
4
+ * One source of truth for "which LLM providers do we present in the
5
+ * onboarding picker?" — keeps the wizard step and the `/config` provider
6
+ * picker in sync. Each entry carries the canonical endpoint, the env-var
7
+ * name the user is most likely to have set (so we can pre-detect), a
8
+ * short hint line for the picker, and a curated model short-list.
9
+ *
10
+ * Lineage:
11
+ * - The "env-var-name as a hint" pattern is borrowed from
12
+ * `openSrc/codex/codex-rs/tui/src/onboarding/auth.rs`
13
+ * (`ApiKeyInputState.prepopulated_from_env`).
14
+ * - The "configured / needs-key / optional-key" row tag is from
15
+ * `openSrc/DeepSeek-TUI/crates/tui/src/tui/provider_picker.rs`.
16
+ *
17
+ * Adding a provider here makes it appear in the wizard AND the
18
+ * `/config` panel — no other registration needed.
19
+ */
20
+ export interface ProviderEntry {
21
+ /** Stable id used in config.json + tests. */
22
+ id: string;
23
+ /** Human-readable picker label. */
24
+ label: string;
25
+ /** One-line picker hint shown after the em-dash. */
26
+ hint: string;
27
+ /** OpenAI-compatible /v1/chat/completions endpoint. */
28
+ endpoint: string;
29
+ /** Env var the wizard checks to pre-detect a usable key. */
30
+ envKey: string;
31
+ /** True when the provider runs locally and a blank API key is fine. */
32
+ local: boolean;
33
+ /** Curated short-list of model names for the picker (plus "Other"). */
34
+ models: string[];
35
+ /** Default model selected by the wizard when none was previously set. */
36
+ defaultModel: string;
37
+ }
38
+ export declare const PROVIDER_CATALOG: ProviderEntry[];
39
+ /**
40
+ * Look up a provider entry by stable id. Returns undefined when the id
41
+ * isn't in the catalog — caller decides whether that's an error
42
+ * (`/config` reject) or a fallback (custom-endpoint flow).
43
+ */
44
+ export declare function findProvider(id: string): ProviderEntry | undefined;
45
+ /**
46
+ * Pre-detect which providers already have a usable key in the shell
47
+ * environment. Used by the wizard's Provider step to pre-select the
48
+ * row most likely to "just work". Returns the FIRST hit so first-time
49
+ * users with multiple keys set don't get a random pick — order in
50
+ * PROVIDER_CATALOG is the precedence.
51
+ */
52
+ export declare function detectProviderFromEnv(env?: NodeJS.ProcessEnv): ProviderEntry | undefined;
53
+ /**
54
+ * 0.3.7 wizard API-key validation tier.
55
+ *
56
+ * `Accept` — the key is plausibly fine; persist it as-is.
57
+ * - `warning` carries a non-blocking hint (e.g. "unusual prefix —
58
+ * check your provider's dashboard if calls fail").
59
+ * `Reject` — the input is structurally invalid; refuse to persist and
60
+ * ask the user to re-enter.
61
+ *
62
+ * Pattern lifted from
63
+ * `openSrc/DeepSeek-TUI/crates/tui/src/tui/onboarding/mod.rs:172`
64
+ * (`enum ApiKeyValidation { Accept{warning}, Reject(String) }`). The
65
+ * idea is to warn-not-block on unrecognised key shapes because every
66
+ * vendor invents new prefixes (`sk-`, `sk-or-v1-`, `dsk-`, `pk-`, …)
67
+ * and rejecting on shape alone locks users out of legitimate setups.
68
+ */
69
+ export type ApiKeyValidation = {
70
+ kind: 'accept';
71
+ warning?: string;
72
+ } | {
73
+ kind: 'reject';
74
+ reason: string;
75
+ };
76
+ export declare function validateApiKey(raw: string, provider: ProviderEntry): ApiKeyValidation;
77
+ /**
78
+ * Last-four masking for API keys. Visible everywhere the key is
79
+ * displayed (Done step summary, `/config` panel, `/where` workspace
80
+ * block). Always keeps a fixed-width tail so two keys with different
81
+ * lengths align in the panel.
82
+ *
83
+ * Borrowed from `openSrc/DeepSeek-TUI/crates/tui/src/tui/onboarding/api_key.rs:77`
84
+ * (`mask_key()`).
85
+ */
86
+ export declare function maskApiKey(raw: string): string;