@kinqs/brainrouter-cli 0.3.5 → 0.3.6
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/.env.example +55 -48
- package/bin/cli.cjs +71 -0
- package/dist/agent/agent.d.ts +212 -2
- package/dist/agent/agent.js +428 -38
- package/dist/cli/banner.d.ts +60 -0
- package/dist/cli/banner.js +199 -0
- package/dist/cli/cliPrompt.d.ts +69 -0
- package/dist/cli/cliPrompt.js +287 -0
- package/dist/cli/commands/_helpers.js +6 -6
- package/dist/cli/commands/guard.js +75 -10
- package/dist/cli/commands/mcp.d.ts +17 -0
- package/dist/cli/commands/mcp.js +121 -0
- package/dist/cli/commands/memory.js +2 -2
- package/dist/cli/commands/obs.js +22 -22
- package/dist/cli/commands/session.js +13 -5
- package/dist/cli/commands/ui.js +97 -45
- package/dist/cli/commands/workflow.d.ts +18 -0
- package/dist/cli/commands/workflow.js +314 -43
- package/dist/cli/repl.js +219 -132
- 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/config/config.d.ts +40 -0
- package/dist/config/config.js +45 -73
- package/dist/index.js +80 -13
- package/dist/memory/briefing.d.ts +10 -0
- package/dist/memory/briefing.js +69 -1
- package/dist/prompt/breadthHint.d.ts +5 -0
- package/dist/prompt/breadthHint.js +44 -0
- package/dist/prompt/systemPrompt.d.ts +34 -0
- package/dist/prompt/systemPrompt.js +124 -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 +90 -2
- 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 +5 -4
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Config } from '../config/config.js';
|
|
2
|
+
import type { Goal } from '../state/goalStore.js';
|
|
3
|
+
import { type Theme } from './theme.js';
|
|
4
|
+
export interface BannerInputs {
|
|
5
|
+
workspaceRoot: string;
|
|
6
|
+
/** "local-http", "stdio", "custom" — config.activeServer. */
|
|
7
|
+
mcpProfile: string;
|
|
8
|
+
/** "stdio" | "http". */
|
|
9
|
+
mcpTransport: string;
|
|
10
|
+
/** True when the MCP handshake succeeded. */
|
|
11
|
+
mcpOnline: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* 10c: which MCP this profile actually IS — drives the distinct "brain"
|
|
14
|
+
* row when the active MCP is BrainRouter (or unknown, which we treat as
|
|
15
|
+
* "likely brain"). When the active MCP is explicitly third-party, the
|
|
16
|
+
* brain row is omitted entirely so the box stays compact.
|
|
17
|
+
*/
|
|
18
|
+
mcpIdentity?: 'brainrouter' | 'third-party' | 'unknown';
|
|
19
|
+
/** Resolved sessionKey for this CLI process. */
|
|
20
|
+
sessionKey: string;
|
|
21
|
+
/** Chat-LLM model name (e.g. gpt-4o-mini). */
|
|
22
|
+
model: string;
|
|
23
|
+
/** Slug + status of the currently-bound workflow, if any. */
|
|
24
|
+
workflow?: {
|
|
25
|
+
slug: string;
|
|
26
|
+
status: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Slug of the last workflow that was active in this workspace, surfaced
|
|
30
|
+
* only when the current session has NO workflow bound. Rendered as a
|
|
31
|
+
* one-line hint so the user can `/workflow switch <slug>` to resume.
|
|
32
|
+
* Doesn't auto-bind anything — workflows are pure storage now (goals
|
|
33
|
+
* are session-scoped runtime state). Empty / matching the active
|
|
34
|
+
* workflow → no hint row.
|
|
35
|
+
*/
|
|
36
|
+
lastUsedWorkflow?: string;
|
|
37
|
+
/** Goal-store snapshot, if any. */
|
|
38
|
+
goal?: Goal;
|
|
39
|
+
/** Version override (test fixture). */
|
|
40
|
+
version?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Pure renderer — returns the box as a single newline-joined string with
|
|
44
|
+
* ANSI sequences from `theme`. Caller appends the trailing newline.
|
|
45
|
+
*/
|
|
46
|
+
export declare function renderBanner(inputs: BannerInputs, theme: Theme): string;
|
|
47
|
+
/**
|
|
48
|
+
* Convenience: assemble the inputs from live agent + config + workspace
|
|
49
|
+
* state. Pure read; no side effects. Anything that throws while reading the
|
|
50
|
+
* goal or current-workflow files is treated as "not set" so a half-set-up
|
|
51
|
+
* workspace doesn't crash the banner.
|
|
52
|
+
*/
|
|
53
|
+
export declare function buildBannerInputs(config: Config, agent: {
|
|
54
|
+
sessionKey: string;
|
|
55
|
+
workspaceRoot: string;
|
|
56
|
+
getModel: () => string;
|
|
57
|
+
}, mcpClient: {
|
|
58
|
+
isConnected: () => boolean;
|
|
59
|
+
getIdentity?: () => 'brainrouter' | 'third-party' | 'unknown';
|
|
60
|
+
}): BannerInputs;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { formatBudget } from '../state/goalStore.js';
|
|
4
|
+
import { getCurrentWorkflow, getLastUsedWorkflow } from '../state/workflowArtifacts.js';
|
|
5
|
+
import { readGoal } from '../state/goalStore.js';
|
|
6
|
+
import { BOX } from './theme.js';
|
|
7
|
+
/**
|
|
8
|
+
* Compose the boxed startup banner. Replaces the prior three-line text dump
|
|
9
|
+
* (chalk title + workspace line + connecting-to line) with a single visually
|
|
10
|
+
* scannable block:
|
|
11
|
+
*
|
|
12
|
+
* ╭─ 🧠 BrainRouter CLI 0.3.5 ──────────────────────────────╮
|
|
13
|
+
* │ workspace BrainRouter · c5b8c12d │
|
|
14
|
+
* │ mcp local-http · http · online │
|
|
15
|
+
* │ workflow cli-shell-redesign (in-progress) │
|
|
16
|
+
* │ goal active 3 of unlimited iterations │
|
|
17
|
+
* │ session 7f3a1e0c │
|
|
18
|
+
* │ model gpt-4o-mini │
|
|
19
|
+
* ╰─────────────────────────────────────────────────────────╯
|
|
20
|
+
*
|
|
21
|
+
* Designed so a glance tells the user where they are: which repo, which MCP
|
|
22
|
+
* profile, what's the in-flight goal, which model is running. Anything not
|
|
23
|
+
* applicable (e.g. no goal set, no current workflow) is silently omitted —
|
|
24
|
+
* the box shrinks to fit instead of showing "—" placeholders.
|
|
25
|
+
*
|
|
26
|
+
* The function returns a single string with embedded ANSI; the caller prints
|
|
27
|
+
* it once. Pure-function so tests can assert against the rendered output.
|
|
28
|
+
*/
|
|
29
|
+
const VERSION = '0.3.6';
|
|
30
|
+
const TITLE = '🧠 BrainRouter CLI';
|
|
31
|
+
const MIN_WIDTH = 56;
|
|
32
|
+
const MAX_WIDTH = 100;
|
|
33
|
+
function shortHash(absPath) {
|
|
34
|
+
return crypto.createHash('sha1').update(absPath).digest('hex').slice(0, 8);
|
|
35
|
+
}
|
|
36
|
+
function formatGoalSummary(goal) {
|
|
37
|
+
const cap = formatBudget(goal.budget.maxIterations);
|
|
38
|
+
const used = goal.budget.iterationsUsed;
|
|
39
|
+
// Status verbs read naturally inline. "usage_limited" → "limited".
|
|
40
|
+
const statusWord = goal.status === 'usage_limited' ? 'limited' :
|
|
41
|
+
goal.status === 'active' ? 'active' :
|
|
42
|
+
goal.status;
|
|
43
|
+
if (goal.status === 'complete')
|
|
44
|
+
return 'complete';
|
|
45
|
+
if (goal.status === 'blocked')
|
|
46
|
+
return `blocked — ${goal.blockedReason?.slice(0, 40) ?? 'see /goal'}`;
|
|
47
|
+
return `${statusWord} ${used} of ${cap} iterations`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Workspace label — basename of the workspace root, with a short hash so
|
|
51
|
+
* two repos with the same basename (e.g. two clones of "playground") don't
|
|
52
|
+
* look identical.
|
|
53
|
+
*/
|
|
54
|
+
function formatWorkspace(workspaceRoot) {
|
|
55
|
+
const base = path.basename(workspaceRoot) || workspaceRoot;
|
|
56
|
+
return `${base} · ${shortHash(workspaceRoot)}`;
|
|
57
|
+
}
|
|
58
|
+
function formatMcp(profile, transport, online) {
|
|
59
|
+
const dot = online ? 'online' : 'offline';
|
|
60
|
+
return `${profile} · ${transport} · ${dot}`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 10c: brain row — distinct from the generic MCP row so the user can tell
|
|
64
|
+
* "the BrainRouter cloud brain is down" from "a third-party MCP is down".
|
|
65
|
+
* Renders only when the active MCP is BrainRouter (or unknown). Returns
|
|
66
|
+
* `undefined` when there's nothing meaningful to say (e.g. user only has a
|
|
67
|
+
* third-party MCP connected).
|
|
68
|
+
*/
|
|
69
|
+
function formatBrain(identity, online) {
|
|
70
|
+
if (identity === 'third-party')
|
|
71
|
+
return undefined;
|
|
72
|
+
if (identity === 'unknown')
|
|
73
|
+
return undefined; // wait for tool-signature detection
|
|
74
|
+
if (online)
|
|
75
|
+
return '🟢 online';
|
|
76
|
+
return '🔴 offline · cloud unreachable';
|
|
77
|
+
}
|
|
78
|
+
function formatWorkflow(workflow) {
|
|
79
|
+
if (!workflow)
|
|
80
|
+
return undefined;
|
|
81
|
+
return `${workflow.slug} (${workflow.status})`;
|
|
82
|
+
}
|
|
83
|
+
function clipValue(value, width) {
|
|
84
|
+
if (value.length <= width)
|
|
85
|
+
return value;
|
|
86
|
+
if (width <= 1)
|
|
87
|
+
return value.slice(0, width);
|
|
88
|
+
return value.slice(0, width - 1) + '…';
|
|
89
|
+
}
|
|
90
|
+
function padRight(s, width) {
|
|
91
|
+
return s.length >= width ? s : s + ' '.repeat(width - s.length);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Pure renderer — returns the box as a single newline-joined string with
|
|
95
|
+
* ANSI sequences from `theme`. Caller appends the trailing newline.
|
|
96
|
+
*/
|
|
97
|
+
export function renderBanner(inputs, theme) {
|
|
98
|
+
const rows = [];
|
|
99
|
+
rows.push({ label: 'workspace', value: formatWorkspace(inputs.workspaceRoot) });
|
|
100
|
+
rows.push({ label: 'mcp', value: formatMcp(inputs.mcpProfile, inputs.mcpTransport, inputs.mcpOnline) });
|
|
101
|
+
// 10c: brain status sits below the mcp row — same level of visibility,
|
|
102
|
+
// but distinct so multi-MCP setups (Item 11) won't be ambiguous.
|
|
103
|
+
const brain = formatBrain(inputs.mcpIdentity, inputs.mcpOnline);
|
|
104
|
+
if (brain)
|
|
105
|
+
rows.push({ label: 'brain', value: brain });
|
|
106
|
+
const wf = formatWorkflow(inputs.workflow);
|
|
107
|
+
if (wf) {
|
|
108
|
+
rows.push({ label: 'workflow', value: wf });
|
|
109
|
+
}
|
|
110
|
+
else if (inputs.lastUsedWorkflow) {
|
|
111
|
+
// Fresh session with no current workflow but a known last-used
|
|
112
|
+
// workflow in this workspace — offer the resume incantation without
|
|
113
|
+
// auto-binding. Quiet so the user notices but isn't pushed into it.
|
|
114
|
+
rows.push({ label: 'last on', value: `${inputs.lastUsedWorkflow} /workflow switch ${inputs.lastUsedWorkflow}` });
|
|
115
|
+
}
|
|
116
|
+
if (inputs.goal)
|
|
117
|
+
rows.push({ label: 'goal', value: formatGoalSummary(inputs.goal) });
|
|
118
|
+
rows.push({ label: 'session', value: inputs.sessionKey.slice(0, 8) });
|
|
119
|
+
rows.push({ label: 'model', value: inputs.model });
|
|
120
|
+
const version = inputs.version ?? VERSION;
|
|
121
|
+
const titleText = `${TITLE} ${version}`;
|
|
122
|
+
const labelWidth = rows.reduce((w, r) => Math.max(w, r.label.length), 0);
|
|
123
|
+
// Inner width is the widest "label + 2 spaces + value", clamped.
|
|
124
|
+
const naturalInner = rows.reduce((w, r) => Math.max(w, labelWidth + 2 + r.value.length), titleText.length + 4);
|
|
125
|
+
const targetCols = process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : MAX_WIDTH;
|
|
126
|
+
// Reserve 2 columns for the side borders.
|
|
127
|
+
const innerWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.min(naturalInner, targetCols - 2)));
|
|
128
|
+
const top = (() => {
|
|
129
|
+
// ╭─ <title> ──╮ — title sits inline at the top border.
|
|
130
|
+
const titlePiece = ` ${titleText} `;
|
|
131
|
+
const horizontalFill = Math.max(0, innerWidth - 1 - titlePiece.length);
|
|
132
|
+
return theme.primary(BOX.topLeft + BOX.horizontal + titlePiece + BOX.horizontal.repeat(horizontalFill) + BOX.topRight);
|
|
133
|
+
})();
|
|
134
|
+
const bodyLines = rows.map((row) => {
|
|
135
|
+
const valueWidth = innerWidth - labelWidth - 3; // 1 left pad + 2 gap
|
|
136
|
+
const clipped = clipValue(row.value, valueWidth);
|
|
137
|
+
const inside = ' ' + theme.muted(padRight(row.label, labelWidth)) + ' ' + theme.plain(padRight(clipped, valueWidth));
|
|
138
|
+
return theme.primary(BOX.vertical) + inside + theme.primary(BOX.vertical);
|
|
139
|
+
});
|
|
140
|
+
const bottom = theme.primary(BOX.bottomLeft + BOX.horizontal.repeat(innerWidth) + BOX.bottomRight);
|
|
141
|
+
return [top, ...bodyLines, bottom].join('\n');
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Convenience: assemble the inputs from live agent + config + workspace
|
|
145
|
+
* state. Pure read; no side effects. Anything that throws while reading the
|
|
146
|
+
* goal or current-workflow files is treated as "not set" so a half-set-up
|
|
147
|
+
* workspace doesn't crash the banner.
|
|
148
|
+
*/
|
|
149
|
+
export function buildBannerInputs(config, agent, mcpClient) {
|
|
150
|
+
const profile = config.activeServer;
|
|
151
|
+
const server = config.servers[profile];
|
|
152
|
+
const transport = server?.type ?? 'unknown';
|
|
153
|
+
// 10c: identity comes from the live wrapper when present; fall back to
|
|
154
|
+
// the config field for callers that pass a thin stub.
|
|
155
|
+
const mcpIdentity = mcpClient.getIdentity ? mcpClient.getIdentity() : (server?.identity ?? 'unknown');
|
|
156
|
+
let workflow;
|
|
157
|
+
let lastUsedWorkflow;
|
|
158
|
+
try {
|
|
159
|
+
// 9d-bugfix: read the session-scoped binding so a fresh CLI session
|
|
160
|
+
// shows no workflow row even when another CLI in the same workspace
|
|
161
|
+
// has one bound.
|
|
162
|
+
const slug = getCurrentWorkflow(agent.workspaceRoot, agent.sessionKey);
|
|
163
|
+
if (slug) {
|
|
164
|
+
// We don't crack open workflowArtifacts.listWorkflows here — just the
|
|
165
|
+
// pointer file. Status would require parsing meta.json, which has its
|
|
166
|
+
// own cost on a slow disk; "bound" is enough to communicate state.
|
|
167
|
+
workflow = { slug, status: 'bound' };
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
// Fresh session in a workspace where a previous CLI was on
|
|
171
|
+
// workflow X — surface that as a hint so the user can rebind via
|
|
172
|
+
// `/workflow switch X` if they want continuity. Doesn't auto-bind
|
|
173
|
+
// (per the decoupling design — workflows are storage, goals are
|
|
174
|
+
// runtime, the two have orthogonal lifecycles).
|
|
175
|
+
try {
|
|
176
|
+
lastUsedWorkflow = getLastUsedWorkflow(agent.workspaceRoot);
|
|
177
|
+
}
|
|
178
|
+
catch { /* ignore */ }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch { /* ignore — no workflow bound */ }
|
|
182
|
+
let goal;
|
|
183
|
+
try {
|
|
184
|
+
goal = readGoal(agent.workspaceRoot, agent.sessionKey) ?? undefined;
|
|
185
|
+
}
|
|
186
|
+
catch { /* ignore — no goal yet */ }
|
|
187
|
+
return {
|
|
188
|
+
workspaceRoot: agent.workspaceRoot,
|
|
189
|
+
mcpProfile: profile,
|
|
190
|
+
mcpTransport: transport,
|
|
191
|
+
mcpOnline: mcpClient.isConnected(),
|
|
192
|
+
mcpIdentity,
|
|
193
|
+
sessionKey: agent.sessionKey,
|
|
194
|
+
model: agent.getModel(),
|
|
195
|
+
workflow,
|
|
196
|
+
lastUsedWorkflow,
|
|
197
|
+
goal,
|
|
198
|
+
};
|
|
199
|
+
}
|
package/dist/cli/cliPrompt.d.ts
CHANGED
|
@@ -1,12 +1,81 @@
|
|
|
1
1
|
import readline from 'node:readline';
|
|
2
2
|
export declare function setActiveReadline(rl: readline.Interface | undefined): void;
|
|
3
3
|
export declare function getActiveReadline(): readline.Interface | undefined;
|
|
4
|
+
export declare function isPickerActive(): boolean;
|
|
4
5
|
/**
|
|
5
6
|
* One-shot yes/no question. Returns true only when the user types y/yes
|
|
6
7
|
* (case-insensitive). Returns the supplied default when stdin isn't a TTY
|
|
7
8
|
* (e.g. piped non-interactive runs).
|
|
8
9
|
*/
|
|
9
10
|
export declare function askYesNo(question: string, defaultValue?: boolean): Promise<boolean>;
|
|
11
|
+
/**
|
|
12
|
+
* Surfaced when `askChoice` is called outside an interactive TTY. The tool
|
|
13
|
+
* wrapper turns this into a tool-call error so the LLM falls back to deciding
|
|
14
|
+
* itself — silently picking option 1 for the agent in CI / piped runs would
|
|
15
|
+
* make a load-bearing decision the user never saw.
|
|
16
|
+
*/
|
|
17
|
+
export declare class NoTTYError extends Error {
|
|
18
|
+
constructor(message: string);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Surfaced when the user pressed Esc / q / Ctrl+C inside the picker.
|
|
22
|
+
* The tool wrapper converts this into a tool-call error so the LLM knows
|
|
23
|
+
* the user declined to commit and can re-plan instead of guessing.
|
|
24
|
+
*/
|
|
25
|
+
export declare class CancelledChoiceError extends Error {
|
|
26
|
+
constructor(message?: string);
|
|
27
|
+
}
|
|
28
|
+
export interface ChoiceOption {
|
|
29
|
+
label: string;
|
|
30
|
+
description: string;
|
|
31
|
+
}
|
|
32
|
+
export interface PickerState {
|
|
33
|
+
/** Includes the synthetic Other entry at the last index. */
|
|
34
|
+
options: ChoiceOption[];
|
|
35
|
+
cursor: number;
|
|
36
|
+
multiSelect: boolean;
|
|
37
|
+
/** Indices of options the user has toggled on (multi-select only). */
|
|
38
|
+
selected: Set<number>;
|
|
39
|
+
/** True once the user confirmed Other and we're collecting free text. */
|
|
40
|
+
awaitingOther: boolean;
|
|
41
|
+
/** Accumulated free-text for the Other prompt. */
|
|
42
|
+
otherText: string;
|
|
43
|
+
done: boolean;
|
|
44
|
+
cancelled: boolean;
|
|
45
|
+
/** Final resolved value when `done && !cancelled`. */
|
|
46
|
+
result: string | string[] | null;
|
|
47
|
+
}
|
|
48
|
+
/** Normalized keystroke shape the reducer consumes. Decoupled from Node's
|
|
49
|
+
* raw `keypress` event so the reducer can be driven by test inputs that
|
|
50
|
+
* don't go through `emitKeypressEvents`. */
|
|
51
|
+
export interface PickerKey {
|
|
52
|
+
name?: string;
|
|
53
|
+
ctrl?: boolean;
|
|
54
|
+
sequence?: string;
|
|
55
|
+
/** A single printable character for free-text capture (Other phase). */
|
|
56
|
+
char?: string;
|
|
57
|
+
}
|
|
58
|
+
export declare function initPickerState(options: ChoiceOption[], multiSelect: boolean): PickerState;
|
|
59
|
+
export declare function reducePicker(state: PickerState, key: PickerKey): PickerState;
|
|
60
|
+
export declare function renderPicker(state: PickerState, question: string, header?: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* Mid-turn multi-choice prompt with arrow-key navigation, a checkbox UI
|
|
63
|
+
* for multi-select, and an always-on "Other" option that drops to free-text
|
|
64
|
+
* input. Pause/resume the parent REPL the same way `askYesNo` does, so it
|
|
65
|
+
* composes cleanly with the existing readline bridge.
|
|
66
|
+
*
|
|
67
|
+
* Non-TTY behavior is strict: throws `NoTTYError` instead of defaulting to
|
|
68
|
+
* option 1. The agent calling this is asking the human for judgment; making
|
|
69
|
+
* the call for them in CI / piped / `brainrouter run` would silently commit
|
|
70
|
+
* to a path the user never saw.
|
|
71
|
+
*
|
|
72
|
+
* User cancellation (Esc, q, Ctrl+C) throws `CancelledChoiceError` so the
|
|
73
|
+
* tool wrapper can surface "user declined to commit" as a tool-call error.
|
|
74
|
+
*/
|
|
75
|
+
export declare function askChoice(question: string, options: ChoiceOption[], opts?: {
|
|
76
|
+
multiSelect?: boolean;
|
|
77
|
+
header?: string;
|
|
78
|
+
}): Promise<string | string[]>;
|
|
10
79
|
/**
|
|
11
80
|
* Print a line of output while the prompt is showing, then redraw the prompt
|
|
12
81
|
* with whatever the user was mid-typing. Used by callbacks that fire while the
|
package/dist/cli/cliPrompt.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
1
2
|
/**
|
|
2
3
|
* Shared bridge between the REPL's readline interface and modules outside
|
|
3
4
|
* repl.ts that need to (a) write above the prompt without scrambling input,
|
|
@@ -19,6 +20,13 @@ export function setActiveReadline(rl) {
|
|
|
19
20
|
export function getActiveReadline() {
|
|
20
21
|
return activeReadline;
|
|
21
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* True while `askChoice` is rendering its raw-mode picker. The REPL's own
|
|
25
|
+
* keypress handler (shift+tab access-mode cycle) checks this and yields,
|
|
26
|
+
* so the picker has uncontested control of stdin while it's active.
|
|
27
|
+
*/
|
|
28
|
+
let pickerActive = false;
|
|
29
|
+
export function isPickerActive() { return pickerActive; }
|
|
22
30
|
/**
|
|
23
31
|
* One-shot yes/no question. Returns true only when the user types y/yes
|
|
24
32
|
* (case-insensitive). Returns the supplied default when stdin isn't a TTY
|
|
@@ -41,6 +49,285 @@ export function askYesNo(question, defaultValue = false) {
|
|
|
41
49
|
});
|
|
42
50
|
});
|
|
43
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Surfaced when `askChoice` is called outside an interactive TTY. The tool
|
|
54
|
+
* wrapper turns this into a tool-call error so the LLM falls back to deciding
|
|
55
|
+
* itself — silently picking option 1 for the agent in CI / piped runs would
|
|
56
|
+
* make a load-bearing decision the user never saw.
|
|
57
|
+
*/
|
|
58
|
+
export class NoTTYError extends Error {
|
|
59
|
+
constructor(message) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = 'NoTTYError';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Surfaced when the user pressed Esc / q / Ctrl+C inside the picker.
|
|
66
|
+
* The tool wrapper converts this into a tool-call error so the LLM knows
|
|
67
|
+
* the user declined to commit and can re-plan instead of guessing.
|
|
68
|
+
*/
|
|
69
|
+
export class CancelledChoiceError extends Error {
|
|
70
|
+
constructor(message = 'ask_user_choice was cancelled by the user before they picked an option.') {
|
|
71
|
+
super(message);
|
|
72
|
+
this.name = 'CancelledChoiceError';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// --- Pure picker state machine -------------------------------------------
|
|
76
|
+
// Split out as exported pure functions so they're trivial to unit-test
|
|
77
|
+
// without faking a TTY or piping through keypress events. The orchestrator
|
|
78
|
+
// (`askChoice`) only owns the side-effecting bits: wiring stdin keypress
|
|
79
|
+
// events into the reducer and re-rendering the screen.
|
|
80
|
+
/** Synthetic always-on "Other" option appended to every picker. */
|
|
81
|
+
const OTHER_LABEL = 'Other';
|
|
82
|
+
const OTHER_DESCRIPTION = 'Type a free-form answer not listed above';
|
|
83
|
+
export function initPickerState(options, multiSelect) {
|
|
84
|
+
const augmented = [...options, { label: OTHER_LABEL, description: OTHER_DESCRIPTION }];
|
|
85
|
+
return {
|
|
86
|
+
options: augmented,
|
|
87
|
+
cursor: 0,
|
|
88
|
+
multiSelect,
|
|
89
|
+
selected: new Set(),
|
|
90
|
+
awaitingOther: false,
|
|
91
|
+
otherText: '',
|
|
92
|
+
done: false,
|
|
93
|
+
cancelled: false,
|
|
94
|
+
result: null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function finalizeWithOther(state, text) {
|
|
98
|
+
if (state.multiSelect) {
|
|
99
|
+
const otherIdx = state.options.length - 1;
|
|
100
|
+
const indices = Array.from(state.selected).sort((a, b) => a - b);
|
|
101
|
+
const labels = indices.map((i) => (i === otherIdx ? text : state.options[i].label));
|
|
102
|
+
return { ...state, done: true, result: labels, otherText: text };
|
|
103
|
+
}
|
|
104
|
+
return { ...state, done: true, result: text, otherText: text };
|
|
105
|
+
}
|
|
106
|
+
export function reducePicker(state, key) {
|
|
107
|
+
if (state.done)
|
|
108
|
+
return state;
|
|
109
|
+
// Ctrl+C always cancels, in any phase. Don't gate on `key.name === 'c'`
|
|
110
|
+
// alone — some terminals send the sequence without a named binding.
|
|
111
|
+
if (key.ctrl && (key.name === 'c' || key.sequence === '')) {
|
|
112
|
+
return { ...state, done: true, cancelled: true };
|
|
113
|
+
}
|
|
114
|
+
// --- Free-text "Other" phase ------------------------------------------
|
|
115
|
+
if (state.awaitingOther) {
|
|
116
|
+
if (key.name === 'return' || key.sequence === '\r' || key.sequence === '\n') {
|
|
117
|
+
const text = state.otherText.trim();
|
|
118
|
+
if (!text)
|
|
119
|
+
return state; // empty ENTER is a no-op so the user can retry
|
|
120
|
+
return finalizeWithOther(state, text);
|
|
121
|
+
}
|
|
122
|
+
if (key.name === 'backspace') {
|
|
123
|
+
return { ...state, otherText: state.otherText.slice(0, -1) };
|
|
124
|
+
}
|
|
125
|
+
if (key.name === 'escape') {
|
|
126
|
+
// Bail back to the picker so a stray ENTER on Other isn't a one-way trip.
|
|
127
|
+
return { ...state, awaitingOther: false, otherText: '' };
|
|
128
|
+
}
|
|
129
|
+
if (key.char && key.char.length === 1) {
|
|
130
|
+
return { ...state, otherText: state.otherText + key.char };
|
|
131
|
+
}
|
|
132
|
+
return state;
|
|
133
|
+
}
|
|
134
|
+
// --- Picker phase -----------------------------------------------------
|
|
135
|
+
switch (key.name) {
|
|
136
|
+
case 'up':
|
|
137
|
+
return { ...state, cursor: (state.cursor - 1 + state.options.length) % state.options.length };
|
|
138
|
+
case 'down':
|
|
139
|
+
return { ...state, cursor: (state.cursor + 1) % state.options.length };
|
|
140
|
+
case 'space': {
|
|
141
|
+
if (!state.multiSelect)
|
|
142
|
+
return state;
|
|
143
|
+
const next = new Set(state.selected);
|
|
144
|
+
if (next.has(state.cursor))
|
|
145
|
+
next.delete(state.cursor);
|
|
146
|
+
else
|
|
147
|
+
next.add(state.cursor);
|
|
148
|
+
return { ...state, selected: next };
|
|
149
|
+
}
|
|
150
|
+
case 'return': {
|
|
151
|
+
const otherIdx = state.options.length - 1;
|
|
152
|
+
if (state.multiSelect) {
|
|
153
|
+
// Confirming with nothing selected is a no-op — the user must SPACE
|
|
154
|
+
// at least one row first. Bailing here keeps "I pressed ENTER too
|
|
155
|
+
// soon" from silently committing to an empty array.
|
|
156
|
+
if (state.selected.size === 0)
|
|
157
|
+
return state;
|
|
158
|
+
if (state.selected.has(otherIdx)) {
|
|
159
|
+
return { ...state, awaitingOther: true };
|
|
160
|
+
}
|
|
161
|
+
const indices = Array.from(state.selected).sort((a, b) => a - b);
|
|
162
|
+
return { ...state, done: true, result: indices.map((i) => state.options[i].label) };
|
|
163
|
+
}
|
|
164
|
+
if (state.cursor === otherIdx) {
|
|
165
|
+
return { ...state, awaitingOther: true };
|
|
166
|
+
}
|
|
167
|
+
return { ...state, done: true, result: state.options[state.cursor].label };
|
|
168
|
+
}
|
|
169
|
+
case 'escape':
|
|
170
|
+
case 'q':
|
|
171
|
+
return { ...state, done: true, cancelled: true };
|
|
172
|
+
}
|
|
173
|
+
return state;
|
|
174
|
+
}
|
|
175
|
+
export function renderPicker(state, question, header) {
|
|
176
|
+
const lines = [];
|
|
177
|
+
if (header)
|
|
178
|
+
lines.push(`[${header}]`);
|
|
179
|
+
lines.push(question);
|
|
180
|
+
lines.push('');
|
|
181
|
+
for (let i = 0; i < state.options.length; i++) {
|
|
182
|
+
const opt = state.options[i];
|
|
183
|
+
const cursor = i === state.cursor ? '▶' : ' ';
|
|
184
|
+
const mark = state.multiSelect ? (state.selected.has(i) ? '☑ ' : '☐ ') : '';
|
|
185
|
+
lines.push(` ${cursor} ${mark}${opt.label} — ${opt.description}`);
|
|
186
|
+
}
|
|
187
|
+
lines.push('');
|
|
188
|
+
if (state.awaitingOther) {
|
|
189
|
+
lines.push('[Other] Type your answer and press ENTER · Backspace to edit · Esc to go back');
|
|
190
|
+
lines.push(`> ${state.otherText}_`);
|
|
191
|
+
}
|
|
192
|
+
else if (state.multiSelect) {
|
|
193
|
+
lines.push('↑/↓ navigate · SPACE toggle · ENTER confirm · q to cancel');
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
lines.push('↑/↓ navigate · ENTER confirm · q to cancel');
|
|
197
|
+
}
|
|
198
|
+
return lines.join('\n');
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Mid-turn multi-choice prompt with arrow-key navigation, a checkbox UI
|
|
202
|
+
* for multi-select, and an always-on "Other" option that drops to free-text
|
|
203
|
+
* input. Pause/resume the parent REPL the same way `askYesNo` does, so it
|
|
204
|
+
* composes cleanly with the existing readline bridge.
|
|
205
|
+
*
|
|
206
|
+
* Non-TTY behavior is strict: throws `NoTTYError` instead of defaulting to
|
|
207
|
+
* option 1. The agent calling this is asking the human for judgment; making
|
|
208
|
+
* the call for them in CI / piped / `brainrouter run` would silently commit
|
|
209
|
+
* to a path the user never saw.
|
|
210
|
+
*
|
|
211
|
+
* User cancellation (Esc, q, Ctrl+C) throws `CancelledChoiceError` so the
|
|
212
|
+
* tool wrapper can surface "user declined to commit" as a tool-call error.
|
|
213
|
+
*/
|
|
214
|
+
export function askChoice(question, options, opts = {}) {
|
|
215
|
+
// Input-shape validation first — bad shape is a caller bug regardless of
|
|
216
|
+
// TTY availability, and surfacing it as "no TTY" would misdirect the agent
|
|
217
|
+
// toward "decide yourself" when the real fix is "re-emit the call with a
|
|
218
|
+
// valid options array".
|
|
219
|
+
if (!Array.isArray(options) || options.length < 2 || options.length > 4) {
|
|
220
|
+
const count = Array.isArray(options) ? options.length : 'invalid';
|
|
221
|
+
return Promise.reject(new Error(`ask_user_choice requires 2–4 options; received ${count}.`));
|
|
222
|
+
}
|
|
223
|
+
// Reject duplicate labels (case-insensitive). The picker shows labels as
|
|
224
|
+
// the human-readable identifier and returns them as the result, so two
|
|
225
|
+
// options with the same label make the return value ambiguous and downstream
|
|
226
|
+
// branching unreliable. Catch it here, not after the picker is half-drawn.
|
|
227
|
+
// The synthetic "Other" option also collides with a user-supplied "other",
|
|
228
|
+
// so reject that too.
|
|
229
|
+
const seen = new Set();
|
|
230
|
+
for (const o of options) {
|
|
231
|
+
const key = (o?.label ?? '').toLowerCase();
|
|
232
|
+
if (key === OTHER_LABEL.toLowerCase()) {
|
|
233
|
+
return Promise.reject(new Error(`ask_user_choice cannot use "${o.label}" as a label — "${OTHER_LABEL}" is reserved for the always-on free-text fallback.`));
|
|
234
|
+
}
|
|
235
|
+
if (seen.has(key)) {
|
|
236
|
+
return Promise.reject(new Error(`ask_user_choice options must have unique labels; "${o.label}" appears more than once (case-insensitive).`));
|
|
237
|
+
}
|
|
238
|
+
seen.add(key);
|
|
239
|
+
}
|
|
240
|
+
if (!activeReadline || !process.stdin.isTTY) {
|
|
241
|
+
return Promise.reject(new NoTTYError('ask_user_choice requires an interactive TTY (no readline interface is active or stdin is not a TTY). ' +
|
|
242
|
+
'Fall back to deciding yourself based on the available context, and state which option you picked and why in your reply.'));
|
|
243
|
+
}
|
|
244
|
+
return runPicker(question, options, opts);
|
|
245
|
+
}
|
|
246
|
+
function runPicker(question, options, opts) {
|
|
247
|
+
return new Promise((resolve, reject) => {
|
|
248
|
+
const rl = activeReadline;
|
|
249
|
+
const stdout = process.stdout;
|
|
250
|
+
let state = initPickerState(options, !!opts.multiSelect);
|
|
251
|
+
let renderedLines = 0;
|
|
252
|
+
// Pause the parent rl so its `line` handler doesn't fire on our ENTER
|
|
253
|
+
// press. We restore on cleanup.
|
|
254
|
+
rl.pause();
|
|
255
|
+
// readline.createInterface already calls emitKeypressEvents and sets raw
|
|
256
|
+
// mode for a TTY input; this is belt-and-suspenders for cases where the
|
|
257
|
+
// parent code disabled raw mode somewhere along the way.
|
|
258
|
+
readline.emitKeypressEvents(process.stdin);
|
|
259
|
+
try {
|
|
260
|
+
process.stdin.setRawMode?.(true);
|
|
261
|
+
}
|
|
262
|
+
catch { /* not a real TTY */ }
|
|
263
|
+
process.stdin.resume();
|
|
264
|
+
// Hide cursor while the picker is on screen — keeps the rendering tight.
|
|
265
|
+
stdout.write('\x1b[?25l');
|
|
266
|
+
pickerActive = true;
|
|
267
|
+
const clear = () => {
|
|
268
|
+
if (renderedLines > 0) {
|
|
269
|
+
// Move cursor up `renderedLines` then clear to end of screen.
|
|
270
|
+
stdout.write(`\x1b[${renderedLines}A\r\x1b[J`);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
const render = () => {
|
|
274
|
+
clear();
|
|
275
|
+
const text = renderPicker(state, question, opts.header);
|
|
276
|
+
stdout.write(text + '\n');
|
|
277
|
+
renderedLines = text.split('\n').length;
|
|
278
|
+
};
|
|
279
|
+
const cleanup = () => {
|
|
280
|
+
process.stdin.removeListener('keypress', onKeypress);
|
|
281
|
+
// Restore cursor visibility. Leave raw mode TRUE — the REPL expects it
|
|
282
|
+
// on (Backspace + arrow keys + readline's editing all rely on raw mode)
|
|
283
|
+
// and a previous version that restored a captured `wasRaw` flipped raw
|
|
284
|
+
// mode back to false in terminals where readline's auto-init never
|
|
285
|
+
// fully engaged, which manifested as Backspace echoing `^?` after the
|
|
286
|
+
// picker exited. Picker is the one component that's GUARANTEED to know
|
|
287
|
+
// raw mode is needed, so it's the right place to assert the invariant.
|
|
288
|
+
stdout.write('\x1b[?25h');
|
|
289
|
+
try {
|
|
290
|
+
process.stdin.setRawMode?.(true);
|
|
291
|
+
}
|
|
292
|
+
catch { /* noop */ }
|
|
293
|
+
pickerActive = false;
|
|
294
|
+
// Don't auto-resume the parent rl — runAgentTurn paused it intentionally
|
|
295
|
+
// and will resume on its own schedule.
|
|
296
|
+
};
|
|
297
|
+
const onKeypress = (str, key) => {
|
|
298
|
+
const named = key?.name;
|
|
299
|
+
const isPrintable = typeof str === 'string'
|
|
300
|
+
&& str.length === 1
|
|
301
|
+
&& !key?.ctrl
|
|
302
|
+
&& named !== 'return'
|
|
303
|
+
&& named !== 'escape'
|
|
304
|
+
&& named !== 'backspace'
|
|
305
|
+
&& named !== 'tab';
|
|
306
|
+
const pk = {
|
|
307
|
+
name: named,
|
|
308
|
+
ctrl: !!key?.ctrl,
|
|
309
|
+
sequence: key?.sequence,
|
|
310
|
+
char: isPrintable ? str : undefined,
|
|
311
|
+
};
|
|
312
|
+
const nextState = reducePicker(state, pk);
|
|
313
|
+
if (nextState === state)
|
|
314
|
+
return;
|
|
315
|
+
state = nextState;
|
|
316
|
+
render();
|
|
317
|
+
if (state.done) {
|
|
318
|
+
cleanup();
|
|
319
|
+
if (state.cancelled) {
|
|
320
|
+
reject(new CancelledChoiceError());
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
resolve(state.result);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
process.stdin.on('keypress', onKeypress);
|
|
328
|
+
render();
|
|
329
|
+
});
|
|
330
|
+
}
|
|
44
331
|
/**
|
|
45
332
|
* Print a line of output while the prompt is showing, then redraw the prompt
|
|
46
333
|
* with whatever the user was mid-typing. Used by callbacks that fire while the
|