@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.
Files changed (49) hide show
  1. package/.env.example +55 -48
  2. package/bin/cli.cjs +71 -0
  3. package/dist/agent/agent.d.ts +212 -2
  4. package/dist/agent/agent.js +428 -38
  5. package/dist/cli/banner.d.ts +60 -0
  6. package/dist/cli/banner.js +199 -0
  7. package/dist/cli/cliPrompt.d.ts +69 -0
  8. package/dist/cli/cliPrompt.js +287 -0
  9. package/dist/cli/commands/_helpers.js +6 -6
  10. package/dist/cli/commands/guard.js +75 -10
  11. package/dist/cli/commands/mcp.d.ts +17 -0
  12. package/dist/cli/commands/mcp.js +121 -0
  13. package/dist/cli/commands/memory.js +2 -2
  14. package/dist/cli/commands/obs.js +22 -22
  15. package/dist/cli/commands/session.js +13 -5
  16. package/dist/cli/commands/ui.js +97 -45
  17. package/dist/cli/commands/workflow.d.ts +18 -0
  18. package/dist/cli/commands/workflow.js +314 -43
  19. package/dist/cli/repl.js +219 -132
  20. package/dist/cli/spinner.d.ts +34 -0
  21. package/dist/cli/spinner.js +36 -0
  22. package/dist/cli/statusline.d.ts +67 -0
  23. package/dist/cli/statusline.js +204 -0
  24. package/dist/cli/theme.d.ts +79 -0
  25. package/dist/cli/theme.js +106 -0
  26. package/dist/cli/whereView.d.ts +81 -0
  27. package/dist/cli/whereView.js +245 -0
  28. package/dist/config/config.d.ts +40 -0
  29. package/dist/config/config.js +45 -73
  30. package/dist/index.js +80 -13
  31. package/dist/memory/briefing.d.ts +10 -0
  32. package/dist/memory/briefing.js +69 -1
  33. package/dist/prompt/breadthHint.d.ts +5 -0
  34. package/dist/prompt/breadthHint.js +44 -0
  35. package/dist/prompt/systemPrompt.d.ts +34 -0
  36. package/dist/prompt/systemPrompt.js +124 -108
  37. package/dist/runtime/dangerousCommand.d.ts +53 -0
  38. package/dist/runtime/dangerousCommand.js +105 -0
  39. package/dist/runtime/mcpClient.d.ts +38 -1
  40. package/dist/runtime/mcpClient.js +90 -2
  41. package/dist/state/goalStore.d.ts +98 -17
  42. package/dist/state/goalStore.js +132 -42
  43. package/dist/state/preferencesStore.d.ts +67 -3
  44. package/dist/state/preferencesStore.js +84 -1
  45. package/dist/state/workflowArtifacts.d.ts +63 -2
  46. package/dist/state/workflowArtifacts.js +120 -8
  47. package/dist/tests/_helpers.d.ts +31 -0
  48. package/dist/tests/_helpers.js +91 -0
  49. package/package.json +5 -4
@@ -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
+ };
@@ -0,0 +1,106 @@
1
+ import chalk from 'chalk';
2
+ import { readPreferences } from '../state/preferencesStore.js';
3
+ const identity = ((s) => s);
4
+ function buildDark() {
5
+ return {
6
+ mode: 'dark',
7
+ primary: chalk.hex('#CC9166'),
8
+ secondary: chalk.magenta,
9
+ success: chalk.green,
10
+ warning: chalk.yellow,
11
+ danger: chalk.red,
12
+ info: chalk.cyan,
13
+ muted: chalk.gray,
14
+ dim: chalk.hex('#666666'),
15
+ heading: chalk.bold.hex('#CC9166'),
16
+ plain: identity,
17
+ };
18
+ }
19
+ function buildLight() {
20
+ return {
21
+ mode: 'light',
22
+ // Saturated orange-brown — still readable on white because chalk emits
23
+ // a TrueColor sequence the terminal renders as-is.
24
+ primary: chalk.hex('#A24E1F'),
25
+ secondary: chalk.hex('#7B2CBF'),
26
+ success: chalk.hex('#0F7B3E'),
27
+ warning: chalk.hex('#8A6300'),
28
+ danger: chalk.hex('#A4161A'),
29
+ info: chalk.hex('#005F8C'),
30
+ muted: chalk.hex('#4A4A4A'),
31
+ dim: chalk.hex('#7A7A7A'),
32
+ heading: chalk.bold.hex('#A24E1F'),
33
+ plain: identity,
34
+ };
35
+ }
36
+ function buildMono() {
37
+ return {
38
+ mode: 'mono',
39
+ primary: identity,
40
+ secondary: identity,
41
+ success: identity,
42
+ warning: identity,
43
+ danger: identity,
44
+ info: identity,
45
+ muted: identity,
46
+ dim: identity,
47
+ heading: chalk.bold,
48
+ plain: identity,
49
+ };
50
+ }
51
+ function normalizeMode(raw) {
52
+ if (!raw)
53
+ return undefined;
54
+ const v = raw.trim().toLowerCase();
55
+ if (v === 'dark' || v === 'light' || v === 'mono')
56
+ return v;
57
+ if (v === 'auto')
58
+ return 'dark';
59
+ return undefined;
60
+ }
61
+ export function buildTheme(mode) {
62
+ if (mode === 'light')
63
+ return buildLight();
64
+ if (mode === 'mono')
65
+ return buildMono();
66
+ return buildDark();
67
+ }
68
+ /**
69
+ * Resolve the active theme using env-var > preference > default precedence.
70
+ * Pass `workspaceRoot` to honor a per-workspace `/theme` setting; omit to
71
+ * resolve from env only (useful in test helpers where preferences storage
72
+ * might not be initialized).
73
+ */
74
+ export function resolveTheme(workspaceRoot) {
75
+ const envMode = normalizeMode(process.env.BRAINROUTER_THEME);
76
+ if (envMode)
77
+ return buildTheme(envMode);
78
+ if (workspaceRoot) {
79
+ try {
80
+ const prefs = readPreferences(workspaceRoot);
81
+ const prefMode = normalizeMode(prefs.theme);
82
+ if (prefMode)
83
+ return buildTheme(prefMode);
84
+ }
85
+ catch {
86
+ // preferences file unreadable — fall through to default.
87
+ }
88
+ }
89
+ return buildTheme('dark');
90
+ }
91
+ /**
92
+ * Box-drawing characters for the startup banner and /where view. Centralized
93
+ * so a future ASCII-only fallback (for terminals that mangle UTF-8 box chars)
94
+ * is one switch instead of a sweep through render code.
95
+ */
96
+ export const BOX = {
97
+ topLeft: '╭',
98
+ topRight: '╮',
99
+ bottomLeft: '╰',
100
+ bottomRight: '╯',
101
+ horizontal: '─',
102
+ vertical: '│',
103
+ midLeft: '├',
104
+ midRight: '┤',
105
+ cross: '┼',
106
+ };
@@ -0,0 +1,81 @@
1
+ import type { Goal } from '../state/goalStore.js';
2
+ import { type PlanState } from '../state/taskStore.js';
3
+ import { type WorkflowMeta } from '../state/workflowArtifacts.js';
4
+ import { type ChildSessionRecord } from '../orchestration/orchestrator.js';
5
+ import type { RecalledRecord } from '../memory/briefing.js';
6
+ import { type EffortLevel, type ExecutionMode, type ReviewPolicy } from '../state/preferencesStore.js';
7
+ import { type Theme } from './theme.js';
8
+ /**
9
+ * `/where` — single-screen "where am I right now" answer.
10
+ *
11
+ * Pre-0.3.6 the user had to chain `/workspace`, `/goal status`, `/plan`,
12
+ * `/workflows`, `/agents`, `/briefing` to reconstruct the same picture. That
13
+ * was four screen-fuls of output, half of which restated info already in
14
+ * the others. The `/where` view collapses it into one block, ordered by
15
+ * "what's most likely to be in your head as a question right now":
16
+ *
17
+ * 1. WORKSPACE — where am I writing files?
18
+ * 2. WORKFLOW — which durable folder is bound?
19
+ * 3. GOAL — what's the agent contractually trying to do?
20
+ * 4. PLAN — what are the in-flight steps?
21
+ * 5. RECALL — what memory rows did the briefing surface last turn?
22
+ * 6. AGENTS — any spawned children still alive?
23
+ *
24
+ * Sections are individually optional — a fresh workspace with no goal, no
25
+ * plan, no children renders only the WORKSPACE block instead of five empty
26
+ * boxes. That keeps the view useful at every stage of a session, not just
27
+ * after you've built up state.
28
+ *
29
+ * Pure renderer: input is a snapshot, output is a string. The wrapper in
30
+ * commands/ui.ts gathers the snapshot at call time. Tests assert against
31
+ * the rendered output directly.
32
+ */
33
+ export interface WhereInputs {
34
+ workspaceRoot: string;
35
+ sessionKey: string;
36
+ model: string;
37
+ mcpProfile: string;
38
+ mcpTransport: string;
39
+ mcpOnline: boolean;
40
+ /**
41
+ * 10c: identity of the currently-active MCP. When `'brainrouter'`, /where
42
+ * adds a distinct `brain` line ("brain 🟢 online · cloud") under the
43
+ * mcp row. When `'third-party'` the line is omitted. `'unknown'` (pre-
44
+ * detection) is also omitted so we don't show stale state.
45
+ */
46
+ mcpIdentity?: 'brainrouter' | 'third-party' | 'unknown';
47
+ accessMode: string;
48
+ executionMode: ExecutionMode;
49
+ reviewPolicy: ReviewPolicy;
50
+ effort: EffortLevel;
51
+ effortSource: 'env' | 'preference' | 'default';
52
+ workflowSlug?: string;
53
+ workflowMeta?: WorkflowMeta;
54
+ goal?: Goal;
55
+ plan: PlanState;
56
+ recalledRecords: RecalledRecord[];
57
+ briefingSources: string[];
58
+ childSessions: ChildSessionRecord[];
59
+ }
60
+ /**
61
+ * Render the full /where block. Sections are dropped when empty; a fresh
62
+ * workspace with no goal / plan / children renders just WORKSPACE.
63
+ */
64
+ export declare function renderWhere(inputs: WhereInputs, theme: Theme): string;
65
+ /**
66
+ * Gather the snapshot the renderer needs. Single function so the command
67
+ * handler is two lines (gather + render + print).
68
+ */
69
+ export declare function gatherWhereInputs(args: {
70
+ workspaceRoot: string;
71
+ sessionKey: string;
72
+ model: string;
73
+ mcpProfile: string;
74
+ mcpTransport: string;
75
+ mcpOnline: boolean;
76
+ /** 10c: pass through from `mcpClient.getIdentity()` when available. */
77
+ mcpIdentity?: 'brainrouter' | 'third-party' | 'unknown';
78
+ accessMode: string;
79
+ recalledRecords: RecalledRecord[];
80
+ briefingSources: string[];
81
+ }): WhereInputs;