@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.
- package/README.md +29 -52
- package/agents/architect.json +18 -0
- package/agents/explorer.json +18 -0
- package/agents/reviewer.json +18 -0
- package/agents/verifier.json +18 -0
- package/agents/worker.json +18 -0
- package/bin/cli.cjs +71 -0
- package/dist/agent/agent.d.ts +224 -3
- package/dist/agent/agent.js +561 -55
- package/dist/cli/banner.d.ts +80 -0
- package/dist/cli/banner.js +232 -0
- package/dist/cli/cliPrompt.d.ts +106 -0
- package/dist/cli/cliPrompt.js +314 -0
- package/dist/cli/commands/_context.d.ts +3 -1
- package/dist/cli/commands/_helpers.d.ts +1 -1
- package/dist/cli/commands/_helpers.js +6 -6
- package/dist/cli/commands/config.d.ts +46 -0
- package/dist/cli/commands/config.js +1042 -0
- package/dist/cli/commands/guard.js +75 -10
- package/dist/cli/commands/init.d.ts +20 -0
- package/dist/cli/commands/init.js +64 -0
- package/dist/cli/commands/login.d.ts +13 -0
- package/dist/cli/commands/login.js +179 -0
- package/dist/cli/commands/mcp.d.ts +19 -0
- package/dist/cli/commands/mcp.js +286 -0
- package/dist/cli/commands/memory.js +2 -2
- package/dist/cli/commands/obs.js +22 -22
- package/dist/cli/commands/orchestration.js +18 -0
- package/dist/cli/commands/session.js +13 -5
- package/dist/cli/commands/ui.js +202 -91
- package/dist/cli/commands/workflow.d.ts +20 -0
- package/dist/cli/commands/workflow.js +368 -51
- package/dist/cli/ink/ChatApp.d.ts +206 -0
- package/dist/cli/ink/ChatApp.js +493 -0
- package/dist/cli/ink/Frame.d.ts +26 -0
- package/dist/cli/ink/Frame.js +5 -0
- package/dist/cli/ink/Picker.d.ts +65 -0
- package/dist/cli/ink/Picker.js +133 -0
- package/dist/cli/ink/SlashPalette.d.ts +51 -0
- package/dist/cli/ink/SlashPalette.js +136 -0
- package/dist/cli/ink/TextField.d.ts +34 -0
- package/dist/cli/ink/TextField.js +47 -0
- package/dist/cli/ink/WizardApp.d.ts +7 -0
- package/dist/cli/ink/WizardApp.js +422 -0
- package/dist/cli/ink/ambientChat.d.ts +34 -0
- package/dist/cli/ink/ambientChat.js +7 -0
- package/dist/cli/ink/consoleCapture.d.ts +11 -0
- package/dist/cli/ink/consoleCapture.js +33 -0
- package/dist/cli/ink/markdownRender.d.ts +41 -0
- package/dist/cli/ink/markdownRender.js +278 -0
- package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
- package/dist/cli/ink/renderWithResizeClear.js +33 -0
- package/dist/cli/ink/runChat.d.ts +34 -0
- package/dist/cli/ink/runChat.js +571 -0
- package/dist/cli/ink/runPicker.d.ts +31 -0
- package/dist/cli/ink/runPicker.js +139 -0
- package/dist/cli/ink/runSlashPalette.d.ts +23 -0
- package/dist/cli/ink/runSlashPalette.js +33 -0
- package/dist/cli/ink/runWizard.d.ts +22 -0
- package/dist/cli/ink/runWizard.js +133 -0
- package/dist/cli/ink/stdinHandoff.d.ts +51 -0
- package/dist/cli/ink/stdinHandoff.js +78 -0
- package/dist/cli/ink/toolFormat.d.ts +73 -0
- package/dist/cli/ink/toolFormat.js +180 -0
- package/dist/cli/ink/useTerminalSize.d.ts +35 -0
- package/dist/cli/ink/useTerminalSize.js +26 -0
- package/dist/cli/repl.d.ts +25 -3
- package/dist/cli/repl.js +64 -646
- package/dist/cli/slashSuggest.d.ts +32 -0
- package/dist/cli/slashSuggest.js +146 -0
- package/dist/cli/spinner.d.ts +34 -0
- package/dist/cli/spinner.js +36 -0
- package/dist/cli/statusline.d.ts +67 -0
- package/dist/cli/statusline.js +204 -0
- package/dist/cli/theme.d.ts +79 -0
- package/dist/cli/theme.js +106 -0
- package/dist/cli/whereView.d.ts +81 -0
- package/dist/cli/whereView.js +245 -0
- package/dist/cli/wizard/modelsApi.d.ts +72 -0
- package/dist/cli/wizard/modelsApi.js +166 -0
- package/dist/cli/wizard/picker.d.ts +202 -0
- package/dist/cli/wizard/picker.js +547 -0
- package/dist/cli/wizard/providers.d.ts +86 -0
- package/dist/cli/wizard/providers.js +190 -0
- package/dist/cli/wizard/runner.d.ts +13 -0
- package/dist/cli/wizard/runner.js +488 -0
- package/dist/cli/wizard/types.d.ts +122 -0
- package/dist/cli/wizard/types.js +109 -0
- package/dist/config/config.d.ts +52 -0
- package/dist/config/config.js +89 -75
- package/dist/index.js +215 -206
- package/dist/memory/briefing.d.ts +11 -1
- package/dist/memory/briefing.js +69 -1
- package/dist/memory/consolidation.d.ts +1 -1
- package/dist/orchestration/agentRegistry.d.ts +36 -0
- package/dist/orchestration/agentRegistry.js +64 -0
- package/dist/orchestration/orchestrator.d.ts +7 -0
- package/dist/orchestration/orchestrator.js +2 -0
- package/dist/orchestration/tools.d.ts +10 -1
- package/dist/orchestration/tools.js +48 -4
- package/dist/prompt/breadthHint.d.ts +5 -0
- package/dist/prompt/breadthHint.js +44 -0
- package/dist/prompt/skillCatalog.d.ts +11 -0
- package/dist/prompt/skillCatalog.js +134 -0
- package/dist/prompt/skillRunner.d.ts +2 -2
- package/dist/prompt/skillRunner.js +2 -31
- package/dist/prompt/systemPrompt.d.ts +34 -0
- package/dist/prompt/systemPrompt.js +128 -108
- package/dist/runtime/dangerousCommand.d.ts +53 -0
- package/dist/runtime/dangerousCommand.js +105 -0
- package/dist/runtime/mcpClient.d.ts +38 -1
- package/dist/runtime/mcpClient.js +104 -13
- package/dist/runtime/mcpPool.d.ts +162 -0
- package/dist/runtime/mcpPool.js +423 -0
- package/dist/runtime/mcpUtils.d.ts +3 -1
- package/dist/state/goalStore.d.ts +98 -17
- package/dist/state/goalStore.js +132 -42
- package/dist/state/preferencesStore.d.ts +67 -3
- package/dist/state/preferencesStore.js +84 -1
- package/dist/state/workflowArtifacts.d.ts +63 -2
- package/dist/state/workflowArtifacts.js +120 -8
- package/dist/tests/_helpers.d.ts +31 -0
- package/dist/tests/_helpers.js +91 -0
- package/package.json +12 -5
- 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;
|