@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,80 @@
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
+ export interface DisplayedMcpState {
43
+ profile: string;
44
+ transport: string;
45
+ online: boolean;
46
+ identity: 'brainrouter' | 'third-party' | 'unknown';
47
+ }
48
+ /**
49
+ * Pure renderer — returns the box as a single newline-joined string with
50
+ * ANSI sequences from `theme`. Caller appends the trailing newline.
51
+ */
52
+ export declare function renderBanner(inputs: BannerInputs, theme: Theme): string;
53
+ /**
54
+ * Convenience: assemble the inputs from live agent + config + workspace
55
+ * state. Pure read; no side effects. Anything that throws while reading the
56
+ * goal or current-workflow files is treated as "not set" so a half-set-up
57
+ * workspace doesn't crash the banner.
58
+ */
59
+ export declare function buildBannerInputs(config: Config, agent: {
60
+ sessionKey: string;
61
+ workspaceRoot: string;
62
+ getModel: () => string;
63
+ }, mcpClient: {
64
+ isConnected: () => boolean;
65
+ getIdentity?: () => 'brainrouter' | 'third-party' | 'unknown';
66
+ getStatus?: (serverId: string) => {
67
+ status: string;
68
+ identity: 'brainrouter' | 'third-party' | 'unknown';
69
+ } | undefined;
70
+ getActiveBrainrouterServerId?: () => string | undefined;
71
+ }): BannerInputs;
72
+ export declare function resolveDisplayedMcpState(config: Config, mcpClient: {
73
+ isConnected: () => boolean;
74
+ getIdentity?: () => 'brainrouter' | 'third-party' | 'unknown';
75
+ getStatus?: (serverId: string) => {
76
+ status: string;
77
+ identity: 'brainrouter' | 'third-party' | 'unknown';
78
+ } | undefined;
79
+ getActiveBrainrouterServerId?: () => string | undefined;
80
+ }): DisplayedMcpState;
@@ -0,0 +1,232 @@
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.7 ──────────────────────────────╮
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.7';
30
+ const TITLE = '🧠 BrainRouter CLI';
31
+ // Width floor for the BOXED banner. Below this we fall through to the
32
+ // `renderPlainBanner` plaintext format. Was 56 — that caused the box to
33
+ // overflow on terminals narrower than 58 cols (each row wrapped to
34
+ // multiple terminal rows with broken border alignment). 38 fits a
35
+ // 40-col terminal (the smallest realistic phone / split-pane width).
36
+ const MIN_BOX_WIDTH = 38;
37
+ const MAX_WIDTH = 100;
38
+ // Below this width we skip the box entirely and render the rows as
39
+ // "label: value" lines. The boxed format with horizontal borders +
40
+ // title is meaningless when each border row wraps.
41
+ const PLAIN_TEXT_THRESHOLD = 38;
42
+ function shortHash(absPath) {
43
+ return crypto.createHash('sha1').update(absPath).digest('hex').slice(0, 8);
44
+ }
45
+ function formatGoalSummary(goal) {
46
+ const cap = formatBudget(goal.budget.maxIterations);
47
+ const used = goal.budget.iterationsUsed;
48
+ // Status verbs read naturally inline. "usage_limited" → "limited".
49
+ const statusWord = goal.status === 'usage_limited' ? 'limited' :
50
+ goal.status === 'active' ? 'active' :
51
+ goal.status;
52
+ if (goal.status === 'complete')
53
+ return 'complete';
54
+ if (goal.status === 'blocked')
55
+ return `blocked — ${goal.blockedReason?.slice(0, 40) ?? 'see /goal'}`;
56
+ return `${statusWord} ${used} of ${cap} iterations`;
57
+ }
58
+ /**
59
+ * Workspace label — basename of the workspace root, with a short hash so
60
+ * two repos with the same basename (e.g. two clones of "playground") don't
61
+ * look identical.
62
+ */
63
+ function formatWorkspace(workspaceRoot) {
64
+ const base = path.basename(workspaceRoot) || workspaceRoot;
65
+ return `${base} · ${shortHash(workspaceRoot)}`;
66
+ }
67
+ function formatMcp(profile, transport, online) {
68
+ const dot = online ? 'online' : 'offline';
69
+ return `${profile} · ${transport} · ${dot}`;
70
+ }
71
+ /**
72
+ * 10c: brain row — distinct from the generic MCP row so the user can tell
73
+ * "the BrainRouter cloud brain is down" from "a third-party MCP is down".
74
+ * Renders only when the active MCP is BrainRouter (or unknown). Returns
75
+ * `undefined` when there's nothing meaningful to say (e.g. user only has a
76
+ * third-party MCP connected).
77
+ */
78
+ function formatBrain(identity, online) {
79
+ if (identity === 'third-party')
80
+ return undefined;
81
+ if (identity === 'unknown')
82
+ return undefined; // wait for tool-signature detection
83
+ if (online)
84
+ return '🟢 online';
85
+ return '🔴 offline · cloud unreachable';
86
+ }
87
+ function formatWorkflow(workflow) {
88
+ if (!workflow)
89
+ return undefined;
90
+ return `${workflow.slug} (${workflow.status})`;
91
+ }
92
+ function clipValue(value, width) {
93
+ if (value.length <= width)
94
+ return value;
95
+ if (width <= 1)
96
+ return value.slice(0, width);
97
+ return value.slice(0, width - 1) + '…';
98
+ }
99
+ function padRight(s, width) {
100
+ return s.length >= width ? s : s + ' '.repeat(width - s.length);
101
+ }
102
+ /**
103
+ * Pure renderer — returns the box as a single newline-joined string with
104
+ * ANSI sequences from `theme`. Caller appends the trailing newline.
105
+ */
106
+ export function renderBanner(inputs, theme) {
107
+ const rows = [];
108
+ rows.push({ label: 'workspace', value: formatWorkspace(inputs.workspaceRoot) });
109
+ rows.push({ label: 'mcp', value: formatMcp(inputs.mcpProfile, inputs.mcpTransport, inputs.mcpOnline) });
110
+ // 10c: brain status sits below the mcp row — same level of visibility,
111
+ // but distinct so multi-MCP setups (Item 11) won't be ambiguous.
112
+ const brain = formatBrain(inputs.mcpIdentity, inputs.mcpOnline);
113
+ if (brain)
114
+ rows.push({ label: 'brain', value: brain });
115
+ const wf = formatWorkflow(inputs.workflow);
116
+ if (wf) {
117
+ rows.push({ label: 'workflow', value: wf });
118
+ }
119
+ else if (inputs.lastUsedWorkflow) {
120
+ // Fresh session with no current workflow but a known last-used
121
+ // workflow in this workspace — offer the resume incantation without
122
+ // auto-binding. Quiet so the user notices but isn't pushed into it.
123
+ rows.push({ label: 'last on', value: `${inputs.lastUsedWorkflow} /workflow switch ${inputs.lastUsedWorkflow}` });
124
+ }
125
+ if (inputs.goal)
126
+ rows.push({ label: 'goal', value: formatGoalSummary(inputs.goal) });
127
+ rows.push({ label: 'session', value: inputs.sessionKey.slice(0, 8) });
128
+ rows.push({ label: 'model', value: inputs.model });
129
+ const version = inputs.version ?? VERSION;
130
+ const titleText = `${TITLE} ${version}`;
131
+ const labelWidth = rows.reduce((w, r) => Math.max(w, r.label.length), 0);
132
+ // Inner width is the widest "label + 2 spaces + value", clamped.
133
+ const naturalInner = rows.reduce((w, r) => Math.max(w, labelWidth + 2 + r.value.length), titleText.length + 4);
134
+ const targetCols = process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : MAX_WIDTH;
135
+ // Below the plaintext threshold the boxed layout is hostile (each
136
+ // border row wraps and looks chaotic). Fall back to a label:value
137
+ // text dump that the terminal can wrap naturally.
138
+ if (targetCols < PLAIN_TEXT_THRESHOLD) {
139
+ return renderPlainBanner(titleText, rows, theme);
140
+ }
141
+ // Reserve 2 columns for the side borders.
142
+ const innerWidth = Math.max(MIN_BOX_WIDTH, Math.min(MAX_WIDTH, Math.min(naturalInner, targetCols - 2)));
143
+ const top = (() => {
144
+ // ╭─ <title> ──╮ — title sits inline at the top border.
145
+ const titlePiece = ` ${titleText} `;
146
+ const horizontalFill = Math.max(0, innerWidth - 1 - titlePiece.length);
147
+ return theme.primary(BOX.topLeft + BOX.horizontal + titlePiece + BOX.horizontal.repeat(horizontalFill) + BOX.topRight);
148
+ })();
149
+ const bodyLines = rows.map((row) => {
150
+ const valueWidth = innerWidth - labelWidth - 3; // 1 left pad + 2 gap
151
+ const clipped = clipValue(row.value, valueWidth);
152
+ const inside = ' ' + theme.muted(padRight(row.label, labelWidth)) + ' ' + theme.plain(padRight(clipped, valueWidth));
153
+ return theme.primary(BOX.vertical) + inside + theme.primary(BOX.vertical);
154
+ });
155
+ const bottom = theme.primary(BOX.bottomLeft + BOX.horizontal.repeat(innerWidth) + BOX.bottomRight);
156
+ return [top, ...bodyLines, bottom].join('\n');
157
+ }
158
+ /**
159
+ * Compact label:value text banner — used on terminals narrower than
160
+ * PLAIN_TEXT_THRESHOLD cols where the boxed layout's border rows
161
+ * would wrap and look broken. Same information, no chrome.
162
+ */
163
+ function renderPlainBanner(titleText, rows, theme) {
164
+ const labelWidth = rows.reduce((w, r) => Math.max(w, r.label.length), 0);
165
+ const headerLine = theme.primary(titleText);
166
+ const bodyLines = rows.map((row) => theme.muted(padRight(row.label, labelWidth) + ' ') + theme.plain(row.value));
167
+ return [headerLine, ...bodyLines].join('\n');
168
+ }
169
+ /**
170
+ * Convenience: assemble the inputs from live agent + config + workspace
171
+ * state. Pure read; no side effects. Anything that throws while reading the
172
+ * goal or current-workflow files is treated as "not set" so a half-set-up
173
+ * workspace doesn't crash the banner.
174
+ */
175
+ export function buildBannerInputs(config, agent, mcpClient) {
176
+ const displayedMcp = resolveDisplayedMcpState(config, mcpClient);
177
+ let workflow;
178
+ let lastUsedWorkflow;
179
+ try {
180
+ // 9d-bugfix: read the session-scoped binding so a fresh CLI session
181
+ // shows no workflow row even when another CLI in the same workspace
182
+ // has one bound.
183
+ const slug = getCurrentWorkflow(agent.workspaceRoot, agent.sessionKey);
184
+ if (slug) {
185
+ // We don't crack open workflowArtifacts.listWorkflows here — just the
186
+ // pointer file. Status would require parsing meta.json, which has its
187
+ // own cost on a slow disk; "bound" is enough to communicate state.
188
+ workflow = { slug, status: 'bound' };
189
+ }
190
+ else {
191
+ // Fresh session in a workspace where a previous CLI was on
192
+ // workflow X — surface that as a hint so the user can rebind via
193
+ // `/workflow switch X` if they want continuity. Doesn't auto-bind
194
+ // (per the decoupling design — workflows are storage, goals are
195
+ // runtime, the two have orthogonal lifecycles).
196
+ try {
197
+ lastUsedWorkflow = getLastUsedWorkflow(agent.workspaceRoot);
198
+ }
199
+ catch { /* ignore */ }
200
+ }
201
+ }
202
+ catch { /* ignore — no workflow bound */ }
203
+ let goal;
204
+ try {
205
+ goal = readGoal(agent.workspaceRoot, agent.sessionKey) ?? undefined;
206
+ }
207
+ catch { /* ignore — no goal yet */ }
208
+ return {
209
+ workspaceRoot: agent.workspaceRoot,
210
+ mcpProfile: displayedMcp.profile,
211
+ mcpTransport: displayedMcp.transport,
212
+ mcpOnline: displayedMcp.online,
213
+ mcpIdentity: displayedMcp.identity,
214
+ sessionKey: agent.sessionKey,
215
+ model: agent.getModel(),
216
+ workflow,
217
+ lastUsedWorkflow,
218
+ goal,
219
+ };
220
+ }
221
+ export function resolveDisplayedMcpState(config, mcpClient) {
222
+ const liveBrain = mcpClient.getActiveBrainrouterServerId?.();
223
+ const profile = liveBrain || config.activeServer;
224
+ const server = config.servers[profile];
225
+ const status = profile ? mcpClient.getStatus?.(profile) : undefined;
226
+ return {
227
+ profile,
228
+ transport: server?.type ?? 'unknown',
229
+ online: status ? status.status === 'connected' : mcpClient.isConnected(),
230
+ identity: status?.identity ?? server?.identity ?? mcpClient.getIdentity?.() ?? 'unknown',
231
+ };
232
+ }
@@ -1,12 +1,118 @@
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
+ /**
59
+ * `prefilledOther` drops the picker straight into the free-text "Other"
60
+ * phase with the supplied string already in the buffer. Used by the
61
+ * 0.3.7 wizard / `/config` panel when a value can be derived from an
62
+ * env var — the user sees the env value and presses ENTER to accept or
63
+ * edits to override. Pass an empty string to keep today's behaviour.
64
+ *
65
+ * `initialCursor` lets a picker open with a non-zero highlight so the
66
+ * settings home panel can re-open on the row the user just edited
67
+ * without re-scrolling them to the top.
68
+ */
69
+ export interface InitPickerStateOptions {
70
+ prefilledOther?: string;
71
+ initialCursor?: number;
72
+ }
73
+ export declare function initPickerState(options: ChoiceOption[], multiSelect: boolean, init?: InitPickerStateOptions): PickerState;
74
+ export declare function reducePicker(state: PickerState, key: PickerKey): PickerState;
75
+ export declare function renderPicker(state: PickerState, question: string, header?: string): string;
76
+ /**
77
+ * Mid-turn multi-choice prompt with arrow-key navigation, a checkbox UI
78
+ * for multi-select, and an always-on "Other" option that drops to free-text
79
+ * input. Pause/resume the parent REPL the same way `askYesNo` does, so it
80
+ * composes cleanly with the existing readline bridge.
81
+ *
82
+ * Non-TTY behavior is strict: throws `NoTTYError` instead of defaulting to
83
+ * option 1. The agent calling this is asking the human for judgment; making
84
+ * the call for them in CI / piped / `brainrouter run` would silently commit
85
+ * to a path the user never saw.
86
+ *
87
+ * User cancellation (Esc, q, Ctrl+C) throws `CancelledChoiceError` so the
88
+ * tool wrapper can surface "user declined to commit" as a tool-call error.
89
+ */
90
+ /**
91
+ * 0.3.7 picker opts.
92
+ *
93
+ * `onCursorChange(index)` fires after every arrow-key move that actually
94
+ * moves the cursor (no-op keys, ENTER, SPACE don't fire it). The 0.3.7
95
+ * theme picker uses this to live-preview the selected theme by redrawing
96
+ * the banner accent before the user confirms — pattern lifted from
97
+ * `openSrc/codex/codex-rs/tui/src/bottom_pane/list_selection_view.rs` and
98
+ * `openSrc/codex/codex-rs/tui/src/theme_picker.rs`.
99
+ *
100
+ * `prefilledOther` opens the picker with the synthetic "Other" row
101
+ * already selected AND the free-text input pre-filled. Used when a value
102
+ * is derived from an env var so the user can press ENTER to accept or
103
+ * edit in-place. Pre-fill flips `awaitingOther` true on init.
104
+ *
105
+ * `initialCursor` lets the settings home panel re-open on the row the
106
+ * user just left, avoiding a snap-to-row-0 after every sub-picker.
107
+ */
108
+ export interface AskChoiceOptions {
109
+ multiSelect?: boolean;
110
+ header?: string;
111
+ onCursorChange?: (cursor: number) => void;
112
+ prefilledOther?: string;
113
+ initialCursor?: number;
114
+ }
115
+ export declare function askChoice(question: string, options: ChoiceOption[], opts?: AskChoiceOptions): Promise<string | string[]>;
10
116
  /**
11
117
  * Print a line of output while the prompt is showing, then redraw the prompt
12
118
  * with whatever the user was mid-typing. Used by callbacks that fire while the