@kinqs/brainrouter-cli 0.3.5 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +55 -48
- package/bin/cli.cjs +71 -0
- package/dist/agent/agent.d.ts +212 -2
- package/dist/agent/agent.js +428 -38
- package/dist/cli/banner.d.ts +60 -0
- package/dist/cli/banner.js +199 -0
- package/dist/cli/cliPrompt.d.ts +69 -0
- package/dist/cli/cliPrompt.js +287 -0
- package/dist/cli/commands/_helpers.js +6 -6
- package/dist/cli/commands/guard.js +75 -10
- package/dist/cli/commands/mcp.d.ts +17 -0
- package/dist/cli/commands/mcp.js +121 -0
- package/dist/cli/commands/memory.js +2 -2
- package/dist/cli/commands/obs.js +22 -22
- package/dist/cli/commands/session.js +13 -5
- package/dist/cli/commands/ui.js +97 -45
- package/dist/cli/commands/workflow.d.ts +18 -0
- package/dist/cli/commands/workflow.js +314 -43
- package/dist/cli/repl.js +219 -132
- package/dist/cli/spinner.d.ts +34 -0
- package/dist/cli/spinner.js +36 -0
- package/dist/cli/statusline.d.ts +67 -0
- package/dist/cli/statusline.js +204 -0
- package/dist/cli/theme.d.ts +79 -0
- package/dist/cli/theme.js +106 -0
- package/dist/cli/whereView.d.ts +81 -0
- package/dist/cli/whereView.js +245 -0
- package/dist/config/config.d.ts +40 -0
- package/dist/config/config.js +45 -73
- package/dist/index.js +80 -13
- package/dist/memory/briefing.d.ts +10 -0
- package/dist/memory/briefing.js +69 -1
- package/dist/prompt/breadthHint.d.ts +5 -0
- package/dist/prompt/breadthHint.js +44 -0
- package/dist/prompt/systemPrompt.d.ts +34 -0
- package/dist/prompt/systemPrompt.js +124 -108
- package/dist/runtime/dangerousCommand.d.ts +53 -0
- package/dist/runtime/dangerousCommand.js +105 -0
- package/dist/runtime/mcpClient.d.ts +38 -1
- package/dist/runtime/mcpClient.js +90 -2
- package/dist/state/goalStore.d.ts +98 -17
- package/dist/state/goalStore.js +132 -42
- package/dist/state/preferencesStore.d.ts +67 -3
- package/dist/state/preferencesStore.js +84 -1
- package/dist/state/workflowArtifacts.d.ts +63 -2
- package/dist/state/workflowArtifacts.js +120 -8
- package/dist/tests/_helpers.d.ts +31 -0
- package/dist/tests/_helpers.js +91 -0
- package/package.json +5 -4
|
@@ -0,0 +1,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;
|