@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,32 @@
1
+ import type readline from 'node:readline';
2
+ import { type Theme } from './theme.js';
3
+ export interface SlashCommand {
4
+ /** "/help", "/config", etc. — the literal token the user types. */
5
+ cmd: string;
6
+ /** One-line description shown after the em-dash. */
7
+ description: string;
8
+ }
9
+ export interface SlashSuggestController {
10
+ /** Call after every readline keypress to refresh the popup. */
11
+ onKey(): void;
12
+ /** Force-hide the popup (called on submit / cancel). */
13
+ hide(): void;
14
+ /** Returns true while the popup is visible. */
15
+ isVisible(): boolean;
16
+ }
17
+ export interface SlashSuggestOpts {
18
+ rl: readline.Interface;
19
+ commands: SlashCommand[];
20
+ theme?: Theme;
21
+ }
22
+ /**
23
+ * Two-tier rank for a single command against a query. Lower wins.
24
+ *
25
+ * 0 command starts with query (after the leading /)
26
+ * 1 command contains query
27
+ * 2 description contains query
28
+ * 3 no match
29
+ */
30
+ export declare function scoreSlashCommand(cmd: SlashCommand, query: string): number;
31
+ export declare function filterAndSort(commands: SlashCommand[], query: string): SlashCommand[];
32
+ export declare function createSlashSuggest(opts: SlashSuggestOpts): SlashSuggestController;
@@ -0,0 +1,146 @@
1
+ import { buildTheme } from './theme.js';
2
+ /**
3
+ * 0.3.7 — slash-command autosuggest popup.
4
+ *
5
+ * Renders a filtered list of slash commands BELOW the prompt as the
6
+ * user types. Hides automatically when the input no longer starts
7
+ * with `/`. Updates on every keystroke.
8
+ *
9
+ * Pattern lineage:
10
+ * - Two-tier ranking (exact → prefix → includes, lower wins, stable
11
+ * secondary sort by original index) lifted from
12
+ * `openSrc/grok-cli/src/ui/slash-menu.ts:44-64`.
13
+ * - Popup height cap (max ~6 visible) from
14
+ * `openSrc/codex/codex-rs/tui/src/bottom_pane/popup_consts.rs`
15
+ * and the Claude Code CHANGELOG note (line 378) explicitly
16
+ * capping the popup at "3-5 visible commands instead of scaling
17
+ * with terminal height."
18
+ *
19
+ * Render strategy (kept simple — no scroll-region tricks needed):
20
+ *
21
+ * - On every keystroke we check `rl.line`. If it starts with `/`,
22
+ * compute the filtered list and (re)render the popup BELOW the
23
+ * current prompt line. The cursor is saved before the popup
24
+ * write and restored after — readline's prompt position stays
25
+ * untouched.
26
+ * - If the popup was previously visible AND should now be hidden
27
+ * (input no longer starts with `/`, or no matches), erase the
28
+ * popup region with `\x1b[J` from the position one line below
29
+ * the prompt.
30
+ *
31
+ * The function returns a controller you can wire into the REPL —
32
+ * call `controller.onKey()` after each readline keypress to refresh.
33
+ * Call `controller.hide()` on submit / cancel.
34
+ */
35
+ const MAX_VISIBLE = 6;
36
+ /**
37
+ * Two-tier rank for a single command against a query. Lower wins.
38
+ *
39
+ * 0 command starts with query (after the leading /)
40
+ * 1 command contains query
41
+ * 2 description contains query
42
+ * 3 no match
43
+ */
44
+ export function scoreSlashCommand(cmd, query) {
45
+ if (!query)
46
+ return 0;
47
+ const q = query.toLowerCase();
48
+ const cmdBody = cmd.cmd.slice(1).toLowerCase(); // skip the leading /
49
+ if (cmdBody.startsWith(q))
50
+ return 0;
51
+ if (cmdBody.includes(q))
52
+ return 1;
53
+ if (cmd.description.toLowerCase().includes(q))
54
+ return 2;
55
+ return 3;
56
+ }
57
+ export function filterAndSort(commands, query) {
58
+ if (!query)
59
+ return commands.slice(0, MAX_VISIBLE);
60
+ const scored = commands
61
+ .map((c, i) => ({ c, i, s: scoreSlashCommand(c, query) }))
62
+ .filter((x) => x.s < 3);
63
+ scored.sort((a, b) => (a.s - b.s) || (a.i - b.i)); // stable by original index
64
+ return scored.slice(0, MAX_VISIBLE).map((x) => x.c);
65
+ }
66
+ export function createSlashSuggest(opts) {
67
+ const theme = opts.theme ?? buildTheme('dark');
68
+ const stdout = process.stdout;
69
+ let lastVisible = false;
70
+ let lastHeight = 0;
71
+ let lastQuery = '';
72
+ const readLine = () => {
73
+ const rl = opts.rl;
74
+ return rl.line ?? '';
75
+ };
76
+ const erase = () => {
77
+ if (!lastVisible)
78
+ return;
79
+ // Save cursor → move to col 0 of next line → erase from cursor to
80
+ // end of screen → restore cursor. `\x1b[s` and `\x1b[u` are the
81
+ // SCO sequences; widely supported.
82
+ stdout.write('\x1b7'); // DECSC — save cursor + attrs (more reliable than \x1b[s in xterm)
83
+ stdout.write('\n'); // move down one
84
+ stdout.write('\r'); // col 0
85
+ stdout.write('\x1b[J'); // erase from here to end of screen
86
+ stdout.write('\x1b8'); // DECRC — restore cursor
87
+ lastVisible = false;
88
+ lastHeight = 0;
89
+ };
90
+ const render = (matches) => {
91
+ // Pad command column for alignment.
92
+ const cmdWidth = Math.max(...matches.map((m) => m.cmd.length));
93
+ const lines = matches.map((m, idx) => {
94
+ const cmdPart = theme.heading(m.cmd.padEnd(cmdWidth, ' '));
95
+ const arrow = theme.dim('—');
96
+ const desc = theme.muted(m.description);
97
+ // First match gets a `›` marker; others a space.
98
+ const marker = idx === 0 ? theme.primary('›') : ' ';
99
+ return ` ${marker} ${cmdPart} ${arrow} ${desc}`;
100
+ });
101
+ // Hint line under the suggestions.
102
+ lines.push(theme.dim(' Tab to autocomplete · Enter to submit · type to filter'));
103
+ // First, erase any previous popup.
104
+ if (lastVisible) {
105
+ stdout.write('\x1b7');
106
+ stdout.write('\n\r\x1b[J');
107
+ stdout.write('\x1b8');
108
+ }
109
+ // Now draw the new popup below the prompt:
110
+ stdout.write('\x1b7');
111
+ stdout.write('\n'); // step down to a new line
112
+ stdout.write('\r');
113
+ for (let i = 0; i < lines.length; i++) {
114
+ stdout.write(lines[i]);
115
+ if (i < lines.length - 1)
116
+ stdout.write('\n\r');
117
+ }
118
+ stdout.write('\x1b8'); // restore cursor to the prompt input position
119
+ lastVisible = true;
120
+ lastHeight = lines.length;
121
+ };
122
+ return {
123
+ onKey: () => {
124
+ const line = readLine();
125
+ if (!line.startsWith('/')) {
126
+ if (lastVisible)
127
+ erase();
128
+ lastQuery = '';
129
+ return;
130
+ }
131
+ const query = line.slice(1);
132
+ if (query === lastQuery && lastVisible)
133
+ return; // no-op
134
+ lastQuery = query;
135
+ const matches = filterAndSort(opts.commands, query);
136
+ if (matches.length === 0) {
137
+ if (lastVisible)
138
+ erase();
139
+ return;
140
+ }
141
+ render(matches);
142
+ },
143
+ hide: () => { erase(); lastQuery = ''; },
144
+ isVisible: () => lastVisible,
145
+ };
146
+ }
@@ -0,0 +1,34 @@
1
+ import { type Options, type Ora } from 'ora';
2
+ /**
3
+ * Project-wide spinner factory. **Never** use `ora()` directly — always go
4
+ * through this.
5
+ *
6
+ * `ora`'s default is `discardStdin: true`, which on every `.start()` invokes
7
+ * the `stdin-discarder` dep (`process.stdin.setRawMode(true)` + add a noop
8
+ * `data` listener + `process.stdin.resume()`), and on every `.stop()` /
9
+ * `.succeed()` / `.fail()` does the inverse (`off` listener +
10
+ * `process.stdin.pause()` + `process.stdin.setRawMode(false)`).
11
+ *
12
+ * The pause + raw-mode-false on stop is the load-bearing problem: the
13
+ * brainrouter REPL's readline interface inherits that state, so after a
14
+ * slash command that used a spinner (`/working`, `/handover`, `/explain`,
15
+ * `/diagnostics`, `/forget`, `/persona`, `/skill-hints`, etc.) the prompt
16
+ * looks alive but stdin is paused + cooked. Symptoms: Backspace echoes
17
+ * `^?`, arrow keys echo `^[[A`, ENTER doesn't submit. Same class of bug
18
+ * as the latent setRawMode(false) PR #30 removed at REPL startup, just
19
+ * triggered per-spinner-stop instead of per-process-start.
20
+ *
21
+ * The agent turn (`runAgentTurn`) hides the symptom for most paths because
22
+ * it brackets the whole turn in `rl.pause()` / `rl.resume()` — `rl.resume()`
23
+ * re-engages raw mode via readline's internal `input._setRawMode(true)`.
24
+ * Slash commands run outside that bracket, so the breakage surfaces there.
25
+ * `ask_user_choice` pickers also show it after the picker cleanup hands
26
+ * back to subsequent ora events that pause stdin again before the parent
27
+ * turn's resume runs.
28
+ *
29
+ * `discardStdin: false` skips the entire stdin-discarder dance. The spinner
30
+ * still renders identically; only the side effects are gone. No readline
31
+ * plumbing changes, no `rl.pause()` / `rl.resume()` bracket needed at the
32
+ * call sites — this is the right place to fix it.
33
+ */
34
+ export declare function spinner(text: string, options?: Omit<Options, 'text' | 'discardStdin'>): Ora;
@@ -0,0 +1,36 @@
1
+ import ora from 'ora';
2
+ /**
3
+ * Project-wide spinner factory. **Never** use `ora()` directly — always go
4
+ * through this.
5
+ *
6
+ * `ora`'s default is `discardStdin: true`, which on every `.start()` invokes
7
+ * the `stdin-discarder` dep (`process.stdin.setRawMode(true)` + add a noop
8
+ * `data` listener + `process.stdin.resume()`), and on every `.stop()` /
9
+ * `.succeed()` / `.fail()` does the inverse (`off` listener +
10
+ * `process.stdin.pause()` + `process.stdin.setRawMode(false)`).
11
+ *
12
+ * The pause + raw-mode-false on stop is the load-bearing problem: the
13
+ * brainrouter REPL's readline interface inherits that state, so after a
14
+ * slash command that used a spinner (`/working`, `/handover`, `/explain`,
15
+ * `/diagnostics`, `/forget`, `/persona`, `/skill-hints`, etc.) the prompt
16
+ * looks alive but stdin is paused + cooked. Symptoms: Backspace echoes
17
+ * `^?`, arrow keys echo `^[[A`, ENTER doesn't submit. Same class of bug
18
+ * as the latent setRawMode(false) PR #30 removed at REPL startup, just
19
+ * triggered per-spinner-stop instead of per-process-start.
20
+ *
21
+ * The agent turn (`runAgentTurn`) hides the symptom for most paths because
22
+ * it brackets the whole turn in `rl.pause()` / `rl.resume()` — `rl.resume()`
23
+ * re-engages raw mode via readline's internal `input._setRawMode(true)`.
24
+ * Slash commands run outside that bracket, so the breakage surfaces there.
25
+ * `ask_user_choice` pickers also show it after the picker cleanup hands
26
+ * back to subsequent ora events that pause stdin again before the parent
27
+ * turn's resume runs.
28
+ *
29
+ * `discardStdin: false` skips the entire stdin-discarder dance. The spinner
30
+ * still renders identically; only the side effects are gone. No readline
31
+ * plumbing changes, no `rl.pause()` / `rl.resume()` bracket needed at the
32
+ * call sites — this is the right place to fix it.
33
+ */
34
+ export function spinner(text, options = {}) {
35
+ return ora({ ...options, text, discardStdin: false });
36
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Status-line segment renderers. Each segment is a pure-ish function from
3
+ * (Inputs) → string|undefined; the prompt builder joins the non-empty
4
+ * results with " · " and wraps them in the access-mode color.
5
+ *
6
+ * Why split this out from repl.ts? The 0.3.6 redesign adds workflow / goal /
7
+ * plan / pr segments, so the inline switch in repl.ts was about to grow
8
+ * past readable. Putting one function per segment keeps each rule small
9
+ * AND makes the segment set unit-testable without booting a REPL.
10
+ *
11
+ * Segments deliberately stay narrow:
12
+ * - `mode` — access mode (read/write/shell)
13
+ * - `exec` — execution mode (fast); hidden when planning (the default)
14
+ * - `effort` — reasoning depth (low / high); hidden when medium (the default)
15
+ * - `model` — chat-LLM model name
16
+ * - `tokens` — last turn's input/output tokens, only when calls > 0
17
+ * - `session` — first ~22 chars of the sessionKey
18
+ * - `branch` — git branch, only when in a git repo
19
+ * - `dirty` — `*` when the working tree has uncommitted changes
20
+ * - `pr` — github PR identifier (cached upstream of this helper)
21
+ * - `workflow` — current workflow slug if any
22
+ * - `goal` — goal status + budget usage if any
23
+ * - `plan` — completed/total plan items if a plan exists
24
+ *
25
+ * Note on segment naming: `mode` is the existing access-mode segment
26
+ * (read/write/shell), kept under that name so user preference files like
27
+ * `statusline: "mode,model"` keep working. The new execution-mode segment
28
+ * is `exec` (fast / hidden-when-planning) to avoid colliding with `mode`
29
+ * — `/mode` the command and `mode` the segment are deliberately decoupled.
30
+ */
31
+ export declare const SEGMENT_NAMES: readonly ["mode", "exec", "effort", "model", "tokens", "session", "branch", "dirty", "pr", "workflow", "goal", "plan", "brain"];
32
+ export type SegmentName = typeof SEGMENT_NAMES[number];
33
+ export interface SegmentInputs {
34
+ workspaceRoot: string;
35
+ sessionKey: string;
36
+ accessMode: string;
37
+ model: string;
38
+ lastTurnUsage: {
39
+ calls: number;
40
+ promptTokens: number;
41
+ completionTokens: number;
42
+ };
43
+ /** Optional GitHub PR identifier (e.g. "#42"). REPL caches the gh shell-out, so this is precomputed. */
44
+ prDetector?: () => string | null;
45
+ /**
46
+ * 10c: brain-status detector (renders `brain` segment). REPL wires this
47
+ * up by closing over the live `mcpClient.isConnected()` +
48
+ * `mcpClient.getIdentity()` calls. Returns `'online'` / `'offline'` /
49
+ * `'degraded'` when the active MCP is the BrainRouter brain, and
50
+ * `undefined` otherwise so the segment hides for third-party MCPs.
51
+ */
52
+ brainStatus?: () => 'online' | 'offline' | 'degraded' | undefined;
53
+ }
54
+ export declare function isKnownSegment(name: string): name is SegmentName;
55
+ /**
56
+ * Render a single segment. Returns undefined when the segment has nothing
57
+ * worth showing (e.g. `tokens` before the first turn, `branch` outside a
58
+ * git repo, `goal` with no goal set). Callers should filter undefined out
59
+ * before joining with separators.
60
+ */
61
+ export declare function renderSegment(name: SegmentName, inputs: SegmentInputs): string | undefined;
62
+ /**
63
+ * Render an ordered list of segments, dropping the ones that have nothing
64
+ * to show. Returns a flat array of strings the caller joins with its own
65
+ * separator + color treatment.
66
+ */
67
+ export declare function renderSegments(names: readonly SegmentName[], inputs: SegmentInputs): string[];
@@ -0,0 +1,204 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { formatBudget, readGoal } from '../state/goalStore.js';
3
+ import { readPlan } from '../state/taskStore.js';
4
+ import { getCurrentWorkflow } from '../state/workflowArtifacts.js';
5
+ import { readPreferences, resolveEffort } from '../state/preferencesStore.js';
6
+ /**
7
+ * Status-line segment renderers. Each segment is a pure-ish function from
8
+ * (Inputs) → string|undefined; the prompt builder joins the non-empty
9
+ * results with " · " and wraps them in the access-mode color.
10
+ *
11
+ * Why split this out from repl.ts? The 0.3.6 redesign adds workflow / goal /
12
+ * plan / pr segments, so the inline switch in repl.ts was about to grow
13
+ * past readable. Putting one function per segment keeps each rule small
14
+ * AND makes the segment set unit-testable without booting a REPL.
15
+ *
16
+ * Segments deliberately stay narrow:
17
+ * - `mode` — access mode (read/write/shell)
18
+ * - `exec` — execution mode (fast); hidden when planning (the default)
19
+ * - `effort` — reasoning depth (low / high); hidden when medium (the default)
20
+ * - `model` — chat-LLM model name
21
+ * - `tokens` — last turn's input/output tokens, only when calls > 0
22
+ * - `session` — first ~22 chars of the sessionKey
23
+ * - `branch` — git branch, only when in a git repo
24
+ * - `dirty` — `*` when the working tree has uncommitted changes
25
+ * - `pr` — github PR identifier (cached upstream of this helper)
26
+ * - `workflow` — current workflow slug if any
27
+ * - `goal` — goal status + budget usage if any
28
+ * - `plan` — completed/total plan items if a plan exists
29
+ *
30
+ * Note on segment naming: `mode` is the existing access-mode segment
31
+ * (read/write/shell), kept under that name so user preference files like
32
+ * `statusline: "mode,model"` keep working. The new execution-mode segment
33
+ * is `exec` (fast / hidden-when-planning) to avoid colliding with `mode`
34
+ * — `/mode` the command and `mode` the segment are deliberately decoupled.
35
+ */
36
+ export const SEGMENT_NAMES = [
37
+ 'mode',
38
+ 'exec',
39
+ 'effort',
40
+ 'model',
41
+ 'tokens',
42
+ 'session',
43
+ 'branch',
44
+ 'dirty',
45
+ 'pr',
46
+ 'workflow',
47
+ 'goal',
48
+ 'plan',
49
+ 'brain',
50
+ ];
51
+ export function isKnownSegment(name) {
52
+ return SEGMENT_NAMES.includes(name);
53
+ }
54
+ /**
55
+ * Render a single segment. Returns undefined when the segment has nothing
56
+ * worth showing (e.g. `tokens` before the first turn, `branch` outside a
57
+ * git repo, `goal` with no goal set). Callers should filter undefined out
58
+ * before joining with separators.
59
+ */
60
+ export function renderSegment(name, inputs) {
61
+ switch (name) {
62
+ case 'mode':
63
+ return inputs.accessMode;
64
+ case 'exec': {
65
+ // Show `fast` only — `planning` is the default, and surfacing it would
66
+ // add chrome on every prompt for users who never touched /mode.
67
+ try {
68
+ const { executionMode } = readPreferences(inputs.workspaceRoot);
69
+ return executionMode === 'fast' ? 'fast' : undefined;
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ case 'effort': {
76
+ // Mirror the `exec` "show only when non-default" rule. `medium` is the
77
+ // default and would just add chrome on every prompt for users who never
78
+ // touched /effort.
79
+ try {
80
+ const resolved = resolveEffort(inputs.workspaceRoot);
81
+ if (resolved.effort === 'medium')
82
+ return undefined;
83
+ return `effort:${resolved.effort}`;
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ case 'model':
90
+ return inputs.model;
91
+ case 'tokens': {
92
+ const u = inputs.lastTurnUsage;
93
+ if (u.calls <= 0)
94
+ return undefined;
95
+ return `${u.promptTokens}↑${u.completionTokens}↓`;
96
+ }
97
+ case 'session': {
98
+ const k = inputs.sessionKey;
99
+ return k.length > 22 ? `${k.slice(0, 22)}…` : k;
100
+ }
101
+ case 'branch':
102
+ case 'dirty': {
103
+ try {
104
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', {
105
+ cwd: inputs.workspaceRoot,
106
+ stdio: ['ignore', 'pipe', 'ignore'],
107
+ }).toString().trim();
108
+ if (name === 'branch')
109
+ return branch;
110
+ // dirty: only emit "*" when changes are present; quiet otherwise.
111
+ const dirty = execSync('git status --porcelain', {
112
+ cwd: inputs.workspaceRoot,
113
+ stdio: ['ignore', 'pipe', 'ignore'],
114
+ }).toString().trim() !== '';
115
+ return dirty ? '*' : undefined;
116
+ }
117
+ catch {
118
+ return undefined;
119
+ }
120
+ }
121
+ case 'pr':
122
+ return inputs.prDetector?.() ?? undefined;
123
+ case 'workflow': {
124
+ // The workflow segment is a pure navigation indicator: "which
125
+ // workflow folder is this session writing artifacts to right now?"
126
+ // Post-goal/workflow-decoupling (0.3.6) it does NOT carry a goal
127
+ // status suffix — goals live at session scope (the `goal` segment),
128
+ // workflows live at folder scope. Two orthogonal concerns.
129
+ try {
130
+ const slug = getCurrentWorkflow(inputs.workspaceRoot, inputs.sessionKey);
131
+ if (!slug)
132
+ return undefined;
133
+ return `wf:${slug}`;
134
+ }
135
+ catch {
136
+ return undefined;
137
+ }
138
+ }
139
+ case 'goal': {
140
+ try {
141
+ const goal = readGoal(inputs.workspaceRoot, inputs.sessionKey);
142
+ if (!goal)
143
+ return undefined;
144
+ const cap = formatBudget(goal.budget.maxIterations);
145
+ const used = goal.budget.iterationsUsed;
146
+ const statusLabel = goal.status === 'usage_limited' ? 'limited' : goal.status;
147
+ // Active goals get the iteration ratio; terminal states stay terse.
148
+ if (goal.status === 'active')
149
+ return `goal:${statusLabel} ${used}/${cap}`;
150
+ return `goal:${statusLabel}`;
151
+ }
152
+ catch {
153
+ return undefined;
154
+ }
155
+ }
156
+ case 'plan': {
157
+ try {
158
+ const plan = readPlan(inputs.workspaceRoot, inputs.sessionKey);
159
+ if (!plan.items.length)
160
+ return undefined;
161
+ const done = plan.items.filter((i) => i.status === 'completed').length;
162
+ return `plan:${done}/${plan.items.length}`;
163
+ }
164
+ catch {
165
+ return undefined;
166
+ }
167
+ }
168
+ case 'brain': {
169
+ // 10c: only render when the active MCP is identified as BrainRouter
170
+ // AND its state is non-default. The `brainStatus` detector returns
171
+ // `undefined` for third-party MCPs (no brain to surface) and for
172
+ // BrainRouter-online (default state — hide-when-default mirrors the
173
+ // `exec` + `effort` pattern). Visible states: `offline` (red signal),
174
+ // `degraded` (yellow signal — 10d local-only fallback).
175
+ try {
176
+ const state = inputs.brainStatus?.();
177
+ if (!state || state === 'online')
178
+ return undefined;
179
+ if (state === 'offline')
180
+ return 'brain:🔴';
181
+ if (state === 'degraded')
182
+ return 'brain:🟡';
183
+ return undefined;
184
+ }
185
+ catch {
186
+ return undefined;
187
+ }
188
+ }
189
+ }
190
+ }
191
+ /**
192
+ * Render an ordered list of segments, dropping the ones that have nothing
193
+ * to show. Returns a flat array of strings the caller joins with its own
194
+ * separator + color treatment.
195
+ */
196
+ export function renderSegments(names, inputs) {
197
+ const out = [];
198
+ for (const name of names) {
199
+ const rendered = renderSegment(name, inputs);
200
+ if (rendered)
201
+ out.push(rendered);
202
+ }
203
+ return out;
204
+ }
@@ -0,0 +1,79 @@
1
+ import { type ChalkInstance } from 'chalk';
2
+ /**
3
+ * Consolidated terminal theme tokens.
4
+ *
5
+ * Before this module, chalk hex/named colors were sprinkled across every
6
+ * command file — `chalk.hex('#CC9166')` here, `chalk.green` there. Two
7
+ * problems with that: (1) the orange that worked beautifully on a black
8
+ * terminal washed out on a light terminal so users on solarized-light
9
+ * couldn't read the prompt at all; (2) any "let's tone down the chrome"
10
+ * pass required grepping the entire CLI for chalk calls.
11
+ *
12
+ * The fix is a single source of truth. Every visible surface that needs
13
+ * color reaches for a SEMANTIC token (primary, success, danger, …) instead
14
+ * of a raw chalk call. Three palettes ship in-tree:
15
+ *
16
+ * - `dark` — original Midnight Ledger / Obsidian Surface (matches what
17
+ * the CLI has rendered since 0.3.x). Default.
18
+ * - `light` — darker accents + bolder weights so the palette stays
19
+ * legible on white terminals (solarized-light, GitHub light,
20
+ * Apple Terminal "Basic").
21
+ * - `mono` — pure identity functions; no ANSI color, just text. For
22
+ * screenshot grabs, CI logs, and pipe-to-less.
23
+ *
24
+ * Selection order: `BRAINROUTER_THEME` env var > workspace preferences
25
+ * (`preferences.theme`) > `dark`. `auto` falls back to `dark` for now —
26
+ * autodetecting light terminals from TTY hints is unreliable enough that
27
+ * we leave it to the user to be explicit.
28
+ *
29
+ * Inspired by DeepSeek-TUI's `palette.rs` (see openSrc/DeepSeek-TUI/crates/tui/src/palette.rs),
30
+ * which centralizes its terminal color tokens for the same reason.
31
+ */
32
+ export type ThemeMode = 'dark' | 'light' | 'mono';
33
+ export interface Theme {
34
+ readonly mode: ThemeMode;
35
+ /** Brand accent — used for the banner header, "brainrouter>" prompt, key callouts. */
36
+ readonly primary: ChalkInstance;
37
+ /** Secondary accent — supporting brand color (e.g. agent role tags). */
38
+ readonly secondary: ChalkInstance;
39
+ /** Successful operation (✓ tool completed, ✔ saved). */
40
+ readonly success: ChalkInstance;
41
+ /** Recoverable warning (offline mode, missing config). */
42
+ readonly warning: ChalkInstance;
43
+ /** Failure or destructive action (✗ tool failed, dangerous shell). */
44
+ readonly danger: ChalkInstance;
45
+ /** Neutral informational hint (cyan-ish in the dark palette). */
46
+ readonly info: ChalkInstance;
47
+ /** De-emphasized body text — most chrome lives here. */
48
+ readonly muted: ChalkInstance;
49
+ /** Maximally de-emphasized — borders, separators, "less important than muted". */
50
+ readonly dim: ChalkInstance;
51
+ /** Bold heading text (banner title, /help category headers). */
52
+ readonly heading: ChalkInstance;
53
+ /** Identity — no styling. Used for verbatim payloads where ANSI would corrupt copy/paste. */
54
+ readonly plain: ChalkInstance;
55
+ }
56
+ export declare function buildTheme(mode: ThemeMode): Theme;
57
+ /**
58
+ * Resolve the active theme using env-var > preference > default precedence.
59
+ * Pass `workspaceRoot` to honor a per-workspace `/theme` setting; omit to
60
+ * resolve from env only (useful in test helpers where preferences storage
61
+ * might not be initialized).
62
+ */
63
+ export declare function resolveTheme(workspaceRoot?: string): Theme;
64
+ /**
65
+ * Box-drawing characters for the startup banner and /where view. Centralized
66
+ * so a future ASCII-only fallback (for terminals that mangle UTF-8 box chars)
67
+ * is one switch instead of a sweep through render code.
68
+ */
69
+ export declare const BOX: {
70
+ readonly topLeft: "╭";
71
+ readonly topRight: "╮";
72
+ readonly bottomLeft: "╰";
73
+ readonly bottomRight: "╯";
74
+ readonly horizontal: "─";
75
+ readonly vertical: "│";
76
+ readonly midLeft: "├";
77
+ readonly midRight: "┤";
78
+ readonly cross: "┼";
79
+ };