@kinqs/brainrouter-cli 0.3.6 → 0.3.8

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 (129) 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/changelog/0.2.0.md +15 -0
  8. package/changelog/0.3.0.md +20 -0
  9. package/changelog/0.3.1.md +22 -0
  10. package/changelog/0.3.2.md +15 -0
  11. package/changelog/0.3.3.md +19 -0
  12. package/changelog/0.3.4.md +20 -0
  13. package/changelog/0.3.5.md +9 -0
  14. package/changelog/0.3.6.md +9 -0
  15. package/changelog/0.3.7.md +20 -0
  16. package/changelog/0.3.8.md +30 -0
  17. package/changelog/README.md +41 -0
  18. package/dist/agent/agent.d.ts +34 -1
  19. package/dist/agent/agent.js +372 -79
  20. package/dist/agent/toolCallRecovery.d.ts +57 -0
  21. package/dist/agent/toolCallRecovery.js +130 -0
  22. package/dist/agent/toolSafety.d.ts +17 -0
  23. package/dist/agent/toolSafety.js +102 -0
  24. package/dist/cli/banner.d.ts +20 -0
  25. package/dist/cli/banner.js +47 -14
  26. package/dist/cli/cliPrompt.d.ts +40 -3
  27. package/dist/cli/cliPrompt.js +117 -25
  28. package/dist/cli/commands/_context.d.ts +3 -1
  29. package/dist/cli/commands/_helpers.d.ts +1 -1
  30. package/dist/cli/commands/config.d.ts +46 -0
  31. package/dist/cli/commands/config.js +1042 -0
  32. package/dist/cli/commands/init.d.ts +20 -0
  33. package/dist/cli/commands/init.js +64 -0
  34. package/dist/cli/commands/login.d.ts +13 -0
  35. package/dist/cli/commands/login.js +179 -0
  36. package/dist/cli/commands/mcp.d.ts +13 -11
  37. package/dist/cli/commands/mcp.js +261 -74
  38. package/dist/cli/commands/mcpInstall.d.ts +20 -0
  39. package/dist/cli/commands/mcpInstall.js +87 -0
  40. package/dist/cli/commands/orchestration.js +51 -0
  41. package/dist/cli/commands/releaseNotes.d.ts +24 -0
  42. package/dist/cli/commands/releaseNotes.js +109 -0
  43. package/dist/cli/commands/schedule.d.ts +18 -0
  44. package/dist/cli/commands/schedule.js +189 -0
  45. package/dist/cli/commands/ui.js +119 -60
  46. package/dist/cli/commands/workflow.d.ts +2 -0
  47. package/dist/cli/commands/workflow.js +54 -8
  48. package/dist/cli/ink/ChatApp.d.ts +206 -0
  49. package/dist/cli/ink/ChatApp.js +493 -0
  50. package/dist/cli/ink/Frame.d.ts +26 -0
  51. package/dist/cli/ink/Frame.js +5 -0
  52. package/dist/cli/ink/Picker.d.ts +71 -0
  53. package/dist/cli/ink/Picker.js +168 -0
  54. package/dist/cli/ink/SlashPalette.d.ts +51 -0
  55. package/dist/cli/ink/SlashPalette.js +136 -0
  56. package/dist/cli/ink/TextField.d.ts +34 -0
  57. package/dist/cli/ink/TextField.js +47 -0
  58. package/dist/cli/ink/WizardApp.d.ts +7 -0
  59. package/dist/cli/ink/WizardApp.js +422 -0
  60. package/dist/cli/ink/ambientChat.d.ts +34 -0
  61. package/dist/cli/ink/ambientChat.js +7 -0
  62. package/dist/cli/ink/consoleCapture.d.ts +11 -0
  63. package/dist/cli/ink/consoleCapture.js +33 -0
  64. package/dist/cli/ink/markdownRender.d.ts +41 -0
  65. package/dist/cli/ink/markdownRender.js +278 -0
  66. package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
  67. package/dist/cli/ink/renderWithResizeClear.js +33 -0
  68. package/dist/cli/ink/runChat.d.ts +34 -0
  69. package/dist/cli/ink/runChat.js +682 -0
  70. package/dist/cli/ink/runPicker.d.ts +31 -0
  71. package/dist/cli/ink/runPicker.js +139 -0
  72. package/dist/cli/ink/runSlashPalette.d.ts +23 -0
  73. package/dist/cli/ink/runSlashPalette.js +33 -0
  74. package/dist/cli/ink/runWizard.d.ts +22 -0
  75. package/dist/cli/ink/runWizard.js +133 -0
  76. package/dist/cli/ink/stdinHandoff.d.ts +51 -0
  77. package/dist/cli/ink/stdinHandoff.js +78 -0
  78. package/dist/cli/ink/toolFormat.d.ts +75 -0
  79. package/dist/cli/ink/toolFormat.js +206 -0
  80. package/dist/cli/ink/useTerminalSize.d.ts +35 -0
  81. package/dist/cli/ink/useTerminalSize.js +26 -0
  82. package/dist/cli/repl.d.ts +25 -3
  83. package/dist/cli/repl.js +52 -714
  84. package/dist/cli/slashSuggest.d.ts +32 -0
  85. package/dist/cli/slashSuggest.js +146 -0
  86. package/dist/cli/wizard/modelsApi.d.ts +72 -0
  87. package/dist/cli/wizard/modelsApi.js +166 -0
  88. package/dist/cli/wizard/picker.d.ts +202 -0
  89. package/dist/cli/wizard/picker.js +547 -0
  90. package/dist/cli/wizard/providers.d.ts +86 -0
  91. package/dist/cli/wizard/providers.js +190 -0
  92. package/dist/cli/wizard/runner.d.ts +13 -0
  93. package/dist/cli/wizard/runner.js +488 -0
  94. package/dist/cli/wizard/types.d.ts +122 -0
  95. package/dist/cli/wizard/types.js +109 -0
  96. package/dist/config/config.d.ts +13 -1
  97. package/dist/config/config.js +45 -3
  98. package/dist/index.js +157 -206
  99. package/dist/memory/briefing.d.ts +1 -1
  100. package/dist/memory/briefing.js +4 -4
  101. package/dist/memory/consolidation.d.ts +1 -1
  102. package/dist/orchestration/agentRegistry.d.ts +36 -0
  103. package/dist/orchestration/agentRegistry.js +64 -0
  104. package/dist/orchestration/orchestrator.d.ts +7 -0
  105. package/dist/orchestration/orchestrator.js +2 -0
  106. package/dist/orchestration/tools.d.ts +105 -3
  107. package/dist/orchestration/tools.js +167 -8
  108. package/dist/prompt/skillCatalog.d.ts +11 -0
  109. package/dist/prompt/skillCatalog.js +134 -0
  110. package/dist/prompt/skillRunner.d.ts +2 -2
  111. package/dist/prompt/skillRunner.js +2 -31
  112. package/dist/prompt/systemPrompt.js +7 -2
  113. package/dist/runtime/anthropicAdapter.d.ts +100 -0
  114. package/dist/runtime/anthropicAdapter.js +293 -0
  115. package/dist/runtime/cronParser.d.ts +23 -0
  116. package/dist/runtime/cronParser.js +122 -0
  117. package/dist/runtime/mcpClient.js +14 -11
  118. package/dist/runtime/mcpPool.d.ts +170 -0
  119. package/dist/runtime/mcpPool.js +442 -0
  120. package/dist/runtime/mcpUtils.d.ts +17 -1
  121. package/dist/runtime/mcpUtils.js +23 -0
  122. package/dist/runtime/scheduleTicker.d.ts +33 -0
  123. package/dist/runtime/scheduleTicker.js +99 -0
  124. package/dist/runtime/vendorSnippets.d.ts +45 -0
  125. package/dist/runtime/vendorSnippets.js +153 -0
  126. package/dist/state/scheduleStore.d.ts +37 -0
  127. package/dist/state/scheduleStore.js +64 -0
  128. package/package.json +14 -5
  129. package/.env.example +0 -116
@@ -0,0 +1,57 @@
1
+ export interface ToolCallLike {
2
+ id: string;
3
+ type?: string;
4
+ function: {
5
+ name: string;
6
+ arguments: string | object;
7
+ };
8
+ }
9
+ export interface ToolResultMessage {
10
+ role: 'tool';
11
+ tool_call_id: string;
12
+ name: string;
13
+ content: string;
14
+ isError?: boolean;
15
+ }
16
+ /**
17
+ * Drop duplicate tool_call ids inside a single assistant response. Keeps the
18
+ * LAST occurrence (closest to the model's final intent). Calls without a
19
+ * string id are passed through unchanged — the orphan safety net will catch
20
+ * them later.
21
+ *
22
+ * `onDuplicate` is invoked once per dropped duplicate so callers can log a
23
+ * warning without coupling this module to a logger.
24
+ */
25
+ export declare function dedupeToolCalls<T extends ToolCallLike>(calls: T[] | undefined | null, onDuplicate?: (id: string, droppedIndex: number) => void): T[];
26
+ export interface ParsedArguments {
27
+ args: Record<string, any>;
28
+ /** Defined iff the LLM emitted malformed JSON; ready-to-use error string for a tool_result envelope. */
29
+ error?: string;
30
+ rawArguments: string;
31
+ }
32
+ /**
33
+ * Try-parse `tool_call.function.arguments`. On parse failure return a
34
+ * structured error string instead of throwing, so the caller can attach a
35
+ * synthetic tool_result that the next model turn can read.
36
+ */
37
+ export declare function parseArgumentsOrError(call: ToolCallLike): ParsedArguments;
38
+ /**
39
+ * For every tool_call in `calls` that has no matching tool_result in
40
+ * `results`, build a synthetic tool message so the next LLM request stays
41
+ * well-formed (OpenAI strictly requires tool_call ↔ tool_result pairing).
42
+ *
43
+ * IMPORTANT: the synthetic `content` MUST start with `ERROR:` and be a plain
44
+ * string. The agent runtime's R1 child-drain guardrail tracks spawned
45
+ * children by `parseJsonObject(resultText)` on tool results — if the
46
+ * synthetic envelope parses as JSON with an `id` field, the guardrail would
47
+ * incorrectly think a child agent was spawned and try to wait on it.
48
+ */
49
+ export declare function synthesizeOrphanResults<T extends ToolCallLike>(calls: T[] | undefined | null, results: ToolResultMessage[]): ToolResultMessage[];
50
+ /**
51
+ * Use the caller's existing `normalizeToolName` to surface a "did you mean"
52
+ * suggestion when the LLM emits a tool name that doesn't exist as-is but
53
+ * normalizes to a real registered tool. Tolerates the single-underscore
54
+ * `mcp_<server>_<tool>` prefix (R5 convention) since `normalizeToolName`
55
+ * matches by flattened form.
56
+ */
57
+ export declare function suggestSimilarToolName(raw: string, candidates: string[], normalize: (raw: string, candidates: string[]) => string): string | undefined;
@@ -0,0 +1,130 @@
1
+ // Strict tool-call recovery helpers (0.3.8-I4 / roadmap §8).
2
+ //
3
+ // Adapted from deer-flow/backend/packages/harness/deerflow/agents/middlewares/
4
+ // dangling_tool_call_middleware.py — same pattern: detect tool_calls that
5
+ // never received a paired tool_result and inject synthetic placeholders so
6
+ // strict OpenAI-compatible validators don't reject the next request.
7
+ //
8
+ // These helpers are intentionally pure (no agent.ts imports) so they can be
9
+ // unit-tested in isolation and reused if another runtime grows similar needs.
10
+ /**
11
+ * Drop duplicate tool_call ids inside a single assistant response. Keeps the
12
+ * LAST occurrence (closest to the model's final intent). Calls without a
13
+ * string id are passed through unchanged — the orphan safety net will catch
14
+ * them later.
15
+ *
16
+ * `onDuplicate` is invoked once per dropped duplicate so callers can log a
17
+ * warning without coupling this module to a logger.
18
+ */
19
+ export function dedupeToolCalls(calls, onDuplicate) {
20
+ if (!Array.isArray(calls) || calls.length === 0)
21
+ return [];
22
+ const seen = new Set();
23
+ const out = [];
24
+ for (let i = calls.length - 1; i >= 0; i--) {
25
+ const c = calls[i];
26
+ const id = c?.id;
27
+ if (typeof id !== 'string' || id === '') {
28
+ out.push(c);
29
+ continue;
30
+ }
31
+ if (seen.has(id)) {
32
+ onDuplicate?.(id, i);
33
+ continue;
34
+ }
35
+ seen.add(id);
36
+ out.push(c);
37
+ }
38
+ return out.reverse();
39
+ }
40
+ /**
41
+ * Try-parse `tool_call.function.arguments`. On parse failure return a
42
+ * structured error string instead of throwing, so the caller can attach a
43
+ * synthetic tool_result that the next model turn can read.
44
+ */
45
+ export function parseArgumentsOrError(call) {
46
+ const raw = call?.function?.arguments;
47
+ if (raw == null)
48
+ return { args: {}, rawArguments: '' };
49
+ if (typeof raw !== 'string') {
50
+ // Provider already parsed it for us.
51
+ return {
52
+ args: (raw && typeof raw === 'object') ? raw : {},
53
+ rawArguments: (() => { try {
54
+ return JSON.stringify(raw);
55
+ }
56
+ catch {
57
+ return String(raw);
58
+ } })(),
59
+ };
60
+ }
61
+ if (raw.trim() === '')
62
+ return { args: {}, rawArguments: raw };
63
+ try {
64
+ const parsed = JSON.parse(raw);
65
+ return {
66
+ args: (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {},
67
+ rawArguments: raw,
68
+ };
69
+ }
70
+ catch (e) {
71
+ const msg = e?.message ?? String(e);
72
+ // Keep the raw arguments visible — the model often needs to see exactly
73
+ // what it produced to self-correct (e.g. trailing comma, missing quote).
74
+ const previewedRaw = raw.length > 400 ? `${raw.slice(0, 400)}…[truncated ${raw.length - 400} chars]` : raw;
75
+ return {
76
+ args: {},
77
+ error: `Tool argument JSON was malformed: ${msg}. Raw arguments emitted by the model: ${previewedRaw}. Re-issue the tool call with valid JSON arguments.`,
78
+ rawArguments: raw,
79
+ };
80
+ }
81
+ }
82
+ /**
83
+ * For every tool_call in `calls` that has no matching tool_result in
84
+ * `results`, build a synthetic tool message so the next LLM request stays
85
+ * well-formed (OpenAI strictly requires tool_call ↔ tool_result pairing).
86
+ *
87
+ * IMPORTANT: the synthetic `content` MUST start with `ERROR:` and be a plain
88
+ * string. The agent runtime's R1 child-drain guardrail tracks spawned
89
+ * children by `parseJsonObject(resultText)` on tool results — if the
90
+ * synthetic envelope parses as JSON with an `id` field, the guardrail would
91
+ * incorrectly think a child agent was spawned and try to wait on it.
92
+ */
93
+ export function synthesizeOrphanResults(calls, results) {
94
+ if (!Array.isArray(calls) || calls.length === 0)
95
+ return [];
96
+ const have = new Set(results.map((r) => r.tool_call_id));
97
+ const synthetic = [];
98
+ for (const c of calls) {
99
+ const id = c?.id;
100
+ if (typeof id !== 'string' || id === '')
101
+ continue;
102
+ if (have.has(id))
103
+ continue;
104
+ synthetic.push({
105
+ role: 'tool',
106
+ tool_call_id: id,
107
+ name: c?.function?.name ?? 'unknown',
108
+ content: 'ERROR: tool call orphaned by model; no execution recorded. Re-issue the tool call if you still need this work done.',
109
+ isError: true,
110
+ });
111
+ }
112
+ return synthetic;
113
+ }
114
+ /**
115
+ * Use the caller's existing `normalizeToolName` to surface a "did you mean"
116
+ * suggestion when the LLM emits a tool name that doesn't exist as-is but
117
+ * normalizes to a real registered tool. Tolerates the single-underscore
118
+ * `mcp_<server>_<tool>` prefix (R5 convention) since `normalizeToolName`
119
+ * matches by flattened form.
120
+ */
121
+ export function suggestSimilarToolName(raw, candidates, normalize) {
122
+ const trimmed = (raw ?? '').trim();
123
+ if (!trimmed || candidates.includes(trimmed))
124
+ return undefined;
125
+ const suggestion = normalize(trimmed, candidates);
126
+ if (suggestion && suggestion !== trimmed && candidates.includes(suggestion)) {
127
+ return suggestion;
128
+ }
129
+ return undefined;
130
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * True iff `toolName` is on the conservative parallel-safe whitelist.
3
+ * Accepts both the bare local tool name (`read_file`) and the MCP-prefixed
4
+ * form (`mcp_brainrouter_memory_recall`). Anything else — including any
5
+ * unknown tool name — returns false so the caller falls back to safe
6
+ * serial execution.
7
+ */
8
+ export declare function isParallelSafe(toolName: string): boolean;
9
+ /** Companion to `isParallelSafe` — true iff the name resolves to a known MCP read tool. */
10
+ export declare function isMcpReadTool(toolName: string): boolean;
11
+ /**
12
+ * Kill switch: `BRAINROUTER_PARALLEL_SAFE_TOOL_CALLS=false` (or `0`/`off`/`no`)
13
+ * forces every batch back to strict serial execution — the pre-R4 shape.
14
+ * Useful when debugging an issue and you want to rule out concurrency, or
15
+ * when running against an LLM provider that rate-limits tool dispatch.
16
+ */
17
+ export declare function parallelExecutionEnabled(): boolean;
@@ -0,0 +1,102 @@
1
+ // 0.3.8-R4 — Single source of truth for which tool calls are safe to
2
+ // dispatch concurrently within one LLM response.
3
+ //
4
+ // Pre-R4 the runtime executed every tool call from one assistant message
5
+ // strictly serially. That's safe but it's pure latency loss for the common
6
+ // case of "read 5 files in one turn" — none of those reads share state or
7
+ // depend on each other's results. Writes and shell commands still need to
8
+ // serialize to preserve causality.
9
+ //
10
+ // `isParallelSafe(toolName)` is the conservative whitelist. Anything not on
11
+ // the list is treated as serial — the failure mode is "we ran something
12
+ // sequentially that could have been concurrent," which is the same
13
+ // performance the pre-R4 code shipped with. Adding tools here is the only
14
+ // way to opt them in.
15
+ /**
16
+ * Local read-only tools whose execution is independent and has no
17
+ * observable side effect on the workspace, on child-session state, or on
18
+ * the agent's own bookkeeping. These can run concurrently within a single
19
+ * LLM response.
20
+ *
21
+ * Explicitly EXCLUDED (must stay serial):
22
+ * - write_file / edit_file / apply_patch / run_command — workspace mutation.
23
+ * - spawn_agent / spawn_agents / task_agent / delegate_agent
24
+ * / wait_agent / wait_agents / close_agent / route_agent
25
+ * / read_agent_transcript — orchestration / child-session mutation.
26
+ * (R1's child-drain guardrail tracks every spawn/wait one-by-one;
27
+ * running them in parallel would let bookkeeping diverge.)
28
+ * - update_plan / goal_complete / goal_blocked — session state mutation.
29
+ * - ask_user_choice — interactive picker; must not interleave with other UI.
30
+ * - list_agents — reads orchestration state but classified serial out of
31
+ * caution; cheap and rarely batched.
32
+ */
33
+ const PARALLEL_SAFE_LOCAL_TOOLS = new Set([
34
+ 'read_file',
35
+ 'list_dir',
36
+ 'grep_search',
37
+ 'glob_files',
38
+ 'fetch_url',
39
+ 'web_search',
40
+ ]);
41
+ /**
42
+ * MCP read tools — bare tool names (without the `mcp_<server>_` prefix)
43
+ * that BrainRouter knows to be read-only. The pool normalises any legacy
44
+ * double-underscore emissions to the canonical single-underscore
45
+ * `mcp_<server>_<tool>` form at its boundary (0.3.8-R5), so the matcher
46
+ * here only deals with that one shape.
47
+ */
48
+ const PARALLEL_SAFE_MCP_READ_TOOLS = new Set([
49
+ 'memory_recall',
50
+ 'memory_search',
51
+ 'memory_file_history',
52
+ 'memory_task_state',
53
+ 'memory_contradictions',
54
+ 'memory_inspect',
55
+ 'memory_list_records',
56
+ ]);
57
+ /**
58
+ * True iff `toolName` is on the conservative parallel-safe whitelist.
59
+ * Accepts both the bare local tool name (`read_file`) and the MCP-prefixed
60
+ * form (`mcp_brainrouter_memory_recall`). Anything else — including any
61
+ * unknown tool name — returns false so the caller falls back to safe
62
+ * serial execution.
63
+ */
64
+ export function isParallelSafe(toolName) {
65
+ if (!toolName)
66
+ return false;
67
+ if (PARALLEL_SAFE_LOCAL_TOOLS.has(toolName))
68
+ return true;
69
+ const bare = stripMcpPrefix(toolName);
70
+ if (bare && PARALLEL_SAFE_MCP_READ_TOOLS.has(bare))
71
+ return true;
72
+ return false;
73
+ }
74
+ /** Companion to `isParallelSafe` — true iff the name resolves to a known MCP read tool. */
75
+ export function isMcpReadTool(toolName) {
76
+ const bare = stripMcpPrefix(toolName);
77
+ return !!bare && PARALLEL_SAFE_MCP_READ_TOOLS.has(bare);
78
+ }
79
+ function stripMcpPrefix(name) {
80
+ // Canonical single-underscore shape: mcp_<server>_<tool>. Server names
81
+ // may contain underscores so we suffix-match against known bare tools
82
+ // instead of guessing where the server segment ends.
83
+ if (name.startsWith('mcp_')) {
84
+ for (const known of PARALLEL_SAFE_MCP_READ_TOOLS) {
85
+ if (name.endsWith('_' + known))
86
+ return known;
87
+ }
88
+ }
89
+ return undefined;
90
+ }
91
+ /**
92
+ * Kill switch: `BRAINROUTER_PARALLEL_SAFE_TOOL_CALLS=false` (or `0`/`off`/`no`)
93
+ * forces every batch back to strict serial execution — the pre-R4 shape.
94
+ * Useful when debugging an issue and you want to rule out concurrency, or
95
+ * when running against an LLM provider that rate-limits tool dispatch.
96
+ */
97
+ export function parallelExecutionEnabled() {
98
+ const raw = (process.env.BRAINROUTER_PARALLEL_SAFE_TOOL_CALLS ?? '').trim().toLowerCase();
99
+ if (raw === 'false' || raw === '0' || raw === 'off' || raw === 'no')
100
+ return false;
101
+ return true;
102
+ }
@@ -39,6 +39,12 @@ export interface BannerInputs {
39
39
  /** Version override (test fixture). */
40
40
  version?: string;
41
41
  }
42
+ export interface DisplayedMcpState {
43
+ profile: string;
44
+ transport: string;
45
+ online: boolean;
46
+ identity: 'brainrouter' | 'third-party' | 'unknown';
47
+ }
42
48
  /**
43
49
  * Pure renderer — returns the box as a single newline-joined string with
44
50
  * ANSI sequences from `theme`. Caller appends the trailing newline.
@@ -57,4 +63,18 @@ export declare function buildBannerInputs(config: Config, agent: {
57
63
  }, mcpClient: {
58
64
  isConnected: () => boolean;
59
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;
60
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;
@@ -9,7 +9,7 @@ import { BOX } from './theme.js';
9
9
  * (chalk title + workspace line + connecting-to line) with a single visually
10
10
  * scannable block:
11
11
  *
12
- * ╭─ 🧠 BrainRouter CLI 0.3.5 ──────────────────────────────╮
12
+ * ╭─ 🧠 BrainRouter CLI 0.3.8 ──────────────────────────────╮
13
13
  * │ workspace BrainRouter · c5b8c12d │
14
14
  * │ mcp local-http · http · online │
15
15
  * │ workflow cli-shell-redesign (in-progress) │
@@ -26,10 +26,19 @@ import { BOX } from './theme.js';
26
26
  * The function returns a single string with embedded ANSI; the caller prints
27
27
  * it once. Pure-function so tests can assert against the rendered output.
28
28
  */
29
- const VERSION = '0.3.6';
29
+ const VERSION = '0.3.8';
30
30
  const TITLE = '🧠 BrainRouter CLI';
31
- const MIN_WIDTH = 56;
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;
32
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;
33
42
  function shortHash(absPath) {
34
43
  return crypto.createHash('sha1').update(absPath).digest('hex').slice(0, 8);
35
44
  }
@@ -123,8 +132,14 @@ export function renderBanner(inputs, theme) {
123
132
  // Inner width is the widest "label + 2 spaces + value", clamped.
124
133
  const naturalInner = rows.reduce((w, r) => Math.max(w, labelWidth + 2 + r.value.length), titleText.length + 4);
125
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
+ }
126
141
  // Reserve 2 columns for the side borders.
127
- const innerWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.min(naturalInner, targetCols - 2)));
142
+ const innerWidth = Math.max(MIN_BOX_WIDTH, Math.min(MAX_WIDTH, Math.min(naturalInner, targetCols - 2)));
128
143
  const top = (() => {
129
144
  // ╭─ <title> ──╮ — title sits inline at the top border.
130
145
  const titlePiece = ` ${titleText} `;
@@ -140,6 +155,17 @@ export function renderBanner(inputs, theme) {
140
155
  const bottom = theme.primary(BOX.bottomLeft + BOX.horizontal.repeat(innerWidth) + BOX.bottomRight);
141
156
  return [top, ...bodyLines, bottom].join('\n');
142
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
+ }
143
169
  /**
144
170
  * Convenience: assemble the inputs from live agent + config + workspace
145
171
  * state. Pure read; no side effects. Anything that throws while reading the
@@ -147,12 +173,7 @@ export function renderBanner(inputs, theme) {
147
173
  * workspace doesn't crash the banner.
148
174
  */
149
175
  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');
176
+ const displayedMcp = resolveDisplayedMcpState(config, mcpClient);
156
177
  let workflow;
157
178
  let lastUsedWorkflow;
158
179
  try {
@@ -186,10 +207,10 @@ export function buildBannerInputs(config, agent, mcpClient) {
186
207
  catch { /* ignore — no goal yet */ }
187
208
  return {
188
209
  workspaceRoot: agent.workspaceRoot,
189
- mcpProfile: profile,
190
- mcpTransport: transport,
191
- mcpOnline: mcpClient.isConnected(),
192
- mcpIdentity,
210
+ mcpProfile: displayedMcp.profile,
211
+ mcpTransport: displayedMcp.transport,
212
+ mcpOnline: displayedMcp.online,
213
+ mcpIdentity: displayedMcp.identity,
193
214
  sessionKey: agent.sessionKey,
194
215
  model: agent.getModel(),
195
216
  workflow,
@@ -197,3 +218,15 @@ export function buildBannerInputs(config, agent, mcpClient) {
197
218
  goal,
198
219
  };
199
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
+ }
@@ -55,7 +55,22 @@ export interface PickerKey {
55
55
  /** A single printable character for free-text capture (Other phase). */
56
56
  char?: string;
57
57
  }
58
- export declare function initPickerState(options: ChoiceOption[], multiSelect: boolean): PickerState;
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;
59
74
  export declare function reducePicker(state: PickerState, key: PickerKey): PickerState;
60
75
  export declare function renderPicker(state: PickerState, question: string, header?: string): string;
61
76
  /**
@@ -72,10 +87,32 @@ export declare function renderPicker(state: PickerState, question: string, heade
72
87
  * User cancellation (Esc, q, Ctrl+C) throws `CancelledChoiceError` so the
73
88
  * tool wrapper can surface "user declined to commit" as a tool-call error.
74
89
  */
75
- export declare function askChoice(question: string, options: ChoiceOption[], opts?: {
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 {
76
109
  multiSelect?: boolean;
77
110
  header?: string;
78
- }): Promise<string | 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[]>;
79
116
  /**
80
117
  * Print a line of output while the prompt is showing, then redraw the prompt
81
118
  * with whatever the user was mid-typing. Used by callbacks that fire while the