@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,493 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
3
+ import { Box, Text, useApp, useInput } from 'ink';
4
+ import TextInput from 'ink-text-input';
5
+ import Spinner from 'ink-spinner';
6
+ import { classifyDiffLine, looksLikeDiff } from './toolFormat.js';
7
+ import { renderMarkdown } from './markdownRender.js';
8
+ import { useTerminalSize } from './useTerminalSize.js';
9
+ // --- Main app ---------------------------------------------------------
10
+ export function ChatApp({ initialBanner, initialOfflineWarning, initialHint, slashCommands, promptLabel, accentColor = '#CC9166', onSubmit, onReady, onAccessModeCycle, initialAccessMode = 'read', initialFooter = {}, }) {
11
+ const { exit } = useApp();
12
+ // useTerminalSize subscribes to stdout 'resize' and pushes the new
13
+ // width into React state, forcing a re-render. Reading
14
+ // useStdout().stdout.columns inline LOOKS like it would work (it's a
15
+ // live getter and Ink claims to re-render on resize), but in practice
16
+ // the dividers + footer + slash palette were left at the OLD width
17
+ // until the next unrelated state change — which is what causes the
18
+ // duplicated/growing dash residue when dragging the window. See
19
+ // useTerminalSize.ts for the full rationale.
20
+ const { columns: cols } = useTerminalSize();
21
+ const [scrollback, setScrollback] = useState(() => seedScrollback(initialBanner, initialOfflineWarning, initialHint));
22
+ const nextIdRef = useRef(scrollback.length);
23
+ const [composerValue, setComposerValue] = useState('');
24
+ const [phase, setPhase] = useState('idle');
25
+ const [spinnerLabel, setSpinnerLabel] = useState('');
26
+ const [accessMode, setAccessMode] = useState(initialAccessMode);
27
+ const [footer, setFooter] = useState(initialFooter);
28
+ /**
29
+ * Per-turn elapsed time, ticked once a second while phase === 'turn-running'.
30
+ * Drives the amber spinner-color transition at 10s — claude-code's
31
+ * "Claude is still working" cue (CHANGELOG v2.1.130 entry 154).
32
+ */
33
+ const [turnElapsedMs, setTurnElapsedMs] = useState(0);
34
+ /**
35
+ * Overlay slot — when set, hides the composer + palette and renders
36
+ * the overlay node instead. Set via controller.showOverlay; cleared
37
+ * via controller.clearOverlay. Used by runPicker/runTextField so
38
+ * /config /login /init render inside the chat Ink (not as a second
39
+ * Ink mount that would fight for stdin).
40
+ */
41
+ const [overlay, setOverlay] = useState(null);
42
+ const overlayResolveRef = useRef(null);
43
+ /**
44
+ * Slash palette cursor — lifted out of SlashPalettePanel so this
45
+ * component owns both the highlight + the keystroke handlers.
46
+ * (useInput at the panel level would race with TextInput for arrow
47
+ * keys; centralizing here makes the precedence explicit.)
48
+ */
49
+ const [paletteCursor, setPaletteCursor] = useState(0);
50
+ const pushFns = useMemo(() => {
51
+ const push = (entry) => {
52
+ setScrollback((s) => {
53
+ const id = ++nextIdRef.current;
54
+ return [...s, { id, ...entry }];
55
+ });
56
+ };
57
+ return {
58
+ raw: (text, opts) => push({ kind: 'raw', text, noWrap: opts?.noWrap }),
59
+ user: (text) => push({ kind: 'user', text }),
60
+ assistant: (text, meta) => push({ kind: 'assistant', text, ...meta }),
61
+ tool: (header, ok, opts) => push({ kind: 'tool', header, ok, ...opts }),
62
+ memory: (level, text) => push({ kind: 'memory', level, text }),
63
+ plan: (items, explanation) => push({ kind: 'plan', items, explanation }),
64
+ notice: (text, level) => push({ kind: 'notice', text, level: level ?? 'info' }),
65
+ setStatus: (label) => setSpinnerLabel(label),
66
+ setPhase: (p) => setPhase(p),
67
+ };
68
+ }, []);
69
+ // Tick the per-turn elapsed time while a turn is running. Resets to 0
70
+ // on each phase change. Spinner color blends from green → amber when
71
+ // this crosses 10s, matching claude-code's "still working" cue
72
+ // (CHANGELOG v2.1.130 entry 154).
73
+ useEffect(() => {
74
+ if (phase !== 'turn-running') {
75
+ setTurnElapsedMs(0);
76
+ return;
77
+ }
78
+ const startedAt = Date.now();
79
+ setTurnElapsedMs(0);
80
+ const interval = setInterval(() => {
81
+ setTurnElapsedMs(Date.now() - startedAt);
82
+ }, 1000);
83
+ return () => clearInterval(interval);
84
+ }, [phase]);
85
+ // Imperative controller — exposed once on mount via onReady so the
86
+ // orchestrator can push from outside the React tree (child agent
87
+ // callbacks fire long after `await agent.runTurn()` resolves and need
88
+ // a way to inject into scrollback without re-entering React state).
89
+ useEffect(() => {
90
+ if (!onReady)
91
+ return;
92
+ onReady({
93
+ push: pushFns,
94
+ replaceBanner: (text) => {
95
+ setScrollback((rows) => {
96
+ const idx = rows.findIndex((entry) => entry.kind === 'raw');
97
+ if (idx < 0)
98
+ return [{ id: ++nextIdRef.current, kind: 'raw', text, noWrap: true }, ...rows];
99
+ return rows.map((entry, i) => i === idx ? { ...entry, text } : entry);
100
+ });
101
+ },
102
+ setFooter: (patch) => {
103
+ if (patch.accessMode)
104
+ setAccessMode(patch.accessMode);
105
+ setFooter((prev) => ({ ...prev, ...patch }));
106
+ },
107
+ setComposer: (text) => setComposerValue(text),
108
+ showOverlay: (node) => new Promise((resolve) => {
109
+ // Save the resolver; clearOverlay() will fire it. Setting the
110
+ // overlay React state hides the composer next render so the
111
+ // overlay's own useInput hooks own keystrokes uncontested.
112
+ overlayResolveRef.current = resolve;
113
+ setOverlay(node);
114
+ }),
115
+ clearOverlay: () => {
116
+ setOverlay(null);
117
+ const r = overlayResolveRef.current;
118
+ overlayResolveRef.current = null;
119
+ if (r)
120
+ r();
121
+ },
122
+ exit,
123
+ });
124
+ // Run exactly once — the controller's identity is stable across renders.
125
+ // eslint-disable-next-line react-hooks/exhaustive-deps
126
+ }, []);
127
+ // Slash palette visibility — open when input is just `/<query>`
128
+ // with no whitespace yet (so the user is still composing the
129
+ // command name, not args).
130
+ const slashQuery = useMemo(() => {
131
+ if (!composerValue.startsWith('/'))
132
+ return null;
133
+ const tail = composerValue.slice(1);
134
+ if (tail.includes(' '))
135
+ return null;
136
+ return tail;
137
+ }, [composerValue]);
138
+ // All matches for the current query, in filter rank order. Computed
139
+ // once per keystroke so the panel and the Enter/Tab handlers all
140
+ // share the same view of "what's highlighted".
141
+ const paletteMatches = useMemo(() => (slashQuery !== null ? filterPaletteCommands(slashCommands, slashQuery) : []), [slashCommands, slashQuery]);
142
+ // Reset the cursor whenever the filter changes (matches array shrinks
143
+ // or shifts), and snap to 0 when the palette closes so a fresh `/`
144
+ // doesn't land on a stale row index.
145
+ useEffect(() => {
146
+ if (slashQuery === null) {
147
+ setPaletteCursor(0);
148
+ return;
149
+ }
150
+ setPaletteCursor((c) => (paletteMatches.length === 0 ? 0 : Math.min(c, paletteMatches.length - 1)));
151
+ }, [slashQuery, paletteMatches.length]);
152
+ const onComposerSubmit = useCallback(async (text) => {
153
+ let trimmed = text.trim();
154
+ // Palette substitution: if the user pressed Enter while a slash
155
+ // palette match is highlighted AND the buffer is still in palette
156
+ // mode (just `/<query>`, no args yet), submit the highlighted
157
+ // command instead of the literal typed text. Matches the standalone
158
+ // SlashPalette in cli/ink/SlashPalette.tsx:onSubmit.
159
+ if (trimmed.startsWith('/') && !trimmed.includes(' ') && paletteMatches.length > 0) {
160
+ const picked = paletteMatches[paletteCursor] ?? paletteMatches[0];
161
+ if (picked.cmd !== trimmed) {
162
+ trimmed = picked.cmd;
163
+ }
164
+ }
165
+ if (!trimmed)
166
+ return;
167
+ pushFns.user(trimmed);
168
+ setComposerValue('');
169
+ setPhase('turn-running');
170
+ setSpinnerLabel('thinking');
171
+ try {
172
+ await onSubmit(trimmed, pushFns);
173
+ }
174
+ catch (err) {
175
+ pushFns.notice(`✗ ${err?.message ?? err}`);
176
+ }
177
+ finally {
178
+ setPhase('idle');
179
+ setSpinnerLabel('');
180
+ }
181
+ }, [onSubmit, pushFns, paletteMatches, paletteCursor]);
182
+ // Ctrl+D / Ctrl+C exit; Shift+Tab cycles access mode; while the
183
+ // slash palette is open, arrow keys navigate it and Tab autocompletes
184
+ // the highlighted command into the composer.
185
+ //
186
+ // CRITICAL: when an overlay is active (e.g. /config picker), this
187
+ // handler returns immediately so the overlay's useInput owns every
188
+ // keystroke uncontested. Without this, Ctrl+C / Shift+Tab would
189
+ // still fire from chat-level and cause double-handling — the kind
190
+ // of bug that makes "/config exits to bash" symptoms.
191
+ useInput((input, key) => {
192
+ if (overlay !== null)
193
+ return;
194
+ if (key.ctrl && (input === 'c' || input === 'd')) {
195
+ exit();
196
+ return;
197
+ }
198
+ if (key.shift && key.tab && onAccessModeCycle) {
199
+ const next = onAccessModeCycle();
200
+ if (next === 'read' || next === 'write' || next === 'shell') {
201
+ setAccessMode(next);
202
+ pushFns.notice(`Access mode → ${next}`);
203
+ }
204
+ return;
205
+ }
206
+ // Palette navigation — only when palette is open AND there's at
207
+ // least one match. We DON'T use `key.return` here because Enter is
208
+ // handled by TextInput's onSubmit (which calls onComposerSubmit
209
+ // above, which performs the highlight-substitution).
210
+ if (slashQuery !== null && paletteMatches.length > 0) {
211
+ if (key.upArrow) {
212
+ setPaletteCursor((c) => (c - 1 + paletteMatches.length) % paletteMatches.length);
213
+ return;
214
+ }
215
+ if (key.downArrow) {
216
+ setPaletteCursor((c) => (c + 1) % paletteMatches.length);
217
+ return;
218
+ }
219
+ if (key.tab && !key.shift) {
220
+ // Tab autocompletes the highlighted command into the composer
221
+ // (with a trailing space so the user can keep typing args).
222
+ const picked = paletteMatches[paletteCursor] ?? paletteMatches[0];
223
+ setComposerValue(picked.cmd + ' ');
224
+ setPaletteCursor(0);
225
+ return;
226
+ }
227
+ }
228
+ });
229
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { flexDirection: "column", children: scrollback.map((entry) => (_jsx(ScrollbackRow, { entry: entry, accentColor: accentColor }, entry.id))) }), overlay !== null ? (_jsxs(_Fragment, { children: [overlay, _jsx(FooterStatus, { promptLabel: promptLabel, phase: phase, accentColor: accentColor, accessMode: accessMode, footer: footer, cols: cols })] })) : (_jsxs(_Fragment, { children: [phase === 'turn-running' ? (_jsxs(Box, { children: [_jsx(Text, { color: turnElapsedMs >= 10_000 ? 'yellow' : 'green', children: React.createElement(Spinner, { type: 'dots' }) }), _jsxs(Text, { color: "gray", wrap: "truncate", children: [" ", spinnerLabel] })] })) : null, _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: accentColor, dimColor: true, children: '─'.repeat(Math.max(10, cols - 2)) }), _jsxs(Box, { children: [_jsx(Text, { color: accentColor, children: ' ❯ ' }), _jsx(TextInput, { value: composerValue, onChange: setComposerValue, onSubmit: onComposerSubmit, placeholder: phase === 'turn-running' ? '' : 'type a prompt or / for commands' })] }), _jsx(Text, { color: accentColor, dimColor: true, children: '─'.repeat(Math.max(10, cols - 2)) })] }), slashQuery !== null ? (_jsx(SlashPalettePanel, { matches: paletteMatches, cursor: paletteCursor, accentColor: accentColor, cols: cols })) : null, _jsx(FooterStatus, { promptLabel: promptLabel, phase: phase, accentColor: accentColor, accessMode: accessMode, footer: footer, cols: cols })] }))] }));
230
+ }
231
+ // --- Sub-components ---------------------------------------------------
232
+ /**
233
+ * Per-entry renderer — every scrollback kind has its own claude-code-style
234
+ * layout. Glyph conventions:
235
+ *
236
+ * ⏺ assistant turn (first line) — green dot
237
+ * ❯ user prompt (left margin) — accent (orange)
238
+ * ⏺ tool call header — green dot when ok, red when failed
239
+ * ⎿ tool result connector — first line of preview
240
+ * ✓ / ✗ status mark — final status line of tool block
241
+ * ↳ plan / memory dim-italic explanation
242
+ */
243
+ function ScrollbackRow({ entry, accentColor }) {
244
+ switch (entry.kind) {
245
+ case 'raw':
246
+ return _jsx(Text, { wrap: entry.noWrap ? 'truncate' : 'wrap', children: entry.text });
247
+ case 'user':
248
+ // Flex layout: ❯ on the left, prompt body in an inner column that
249
+ // takes the remaining width. Continuation lines (when the user
250
+ // pastes a multi-line prompt) align under the body column, not
251
+ // under the caret.
252
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: accentColor, children: "\u276F " }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(Text, { children: entry.text }) })] }));
253
+ case 'assistant': {
254
+ // Pass the WHOLE rendered markdown to a single <Text> instead of
255
+ // splitting on \n and re-rendering each line. The old line-split
256
+ // approach broke ANSI styling that spans newlines — e.g. a
257
+ // multi-line blockquote whose `gray italic` open code sat on line
258
+ // 1 but whose close code sat on line 3 lost its style on lines
259
+ // 2-3. `renderMarkdown` re-scopes the styling per line so the
260
+ // single <Text> reads cleanly.
261
+ //
262
+ // The `⏺` lives in its own Text to the left of the body. The body
263
+ // Box has flexGrow=1 so it takes the remaining terminal width and
264
+ // Ink's wrap-ansi handles reflow inside it. Continuation lines
265
+ // (both from wrap and from explicit \n in the rendered output)
266
+ // align under the body column.
267
+ //
268
+ // `entry.raw === true` (user's rawScrollback preference) skips
269
+ // marked entirely — useful when the user wants to see the LLM's
270
+ // literal markdown source.
271
+ const rendered = (entry.raw ? entry.text : renderMarkdown(entry.text)).trimEnd();
272
+ const meta = entry.durationMs !== undefined
273
+ ? ` ${Math.floor(entry.durationMs / 1000)}s${entry.tokensIn !== undefined ? ` · ${entry.tokensIn.toLocaleString()} in / ${entry.tokensOut?.toLocaleString() ?? 0} out` : ''}`
274
+ : '';
275
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(Text, { children: rendered }) })] }), meta ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: "gray", dimColor: true, children: meta }) })) : null] }));
276
+ }
277
+ case 'tool': {
278
+ // Claude-code layout:
279
+ // ⏺ Read(src/foo.ts)
280
+ // ⎿ <line 1 of preview>
281
+ // <line 2 of preview>
282
+ // (+N more lines hidden)
283
+ // The header DOT is green on success and red on failure so the user
284
+ // can scan a long turn at a glance. Duration appended in dim if set.
285
+ const dotColor = entry.ok ? 'green' : 'red';
286
+ const previewLines = entry.preview ? splitForPreview(entry.preview) : null;
287
+ const isDiff = entry.preview ? looksLikeDiff(entry.preview) : false;
288
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: dotColor, children: "\u23FA " }), _jsx(Text, { wrap: "truncate", children: entry.header }), entry.durationMs !== undefined ? (_jsx(Text, { color: "gray", dimColor: true, children: ` · ${formatDuration(entry.durationMs)}` })) : null, !entry.ok ? (_jsx(Text, { color: "red", dimColor: true, children: ' · failed' })) : null] }), previewLines ? previewLines.visible.map((line, i) => (_jsx(ToolPreviewLine, { line: line, isFirst: i === 0, isDiff: isDiff }, i))) : null, previewLines && previewLines.hidden > 0 ? (_jsx(Box, { children: _jsx(Text, { color: "gray", dimColor: true, children: ` (+${previewLines.hidden} more line${previewLines.hidden === 1 ? '' : 's'} hidden)` }) })) : null] }));
289
+ }
290
+ case 'memory':
291
+ // Memory pipeline events — briefing / capture / citation / contradiction.
292
+ // Warnings (contradictions, extraction failures) stand out; info events
293
+ // stay dim so the chat doesn't drown in capture chatter.
294
+ return (_jsx(Box, { children: _jsxs(Text, { color: entry.level === 'warn' ? 'yellow' : 'gray', bold: entry.level === 'warn', dimColor: entry.level === 'info', wrap: "truncate", children: [entry.level === 'warn' ? '⚠ ' : '· ', entry.text] }) }));
295
+ case 'plan':
296
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "gray", bold: true, children: "\uD83D\uDCCB Plan" }), entry.explanation ? (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "gray", dimColor: true, italic: true, children: [" \u21B3 ", entry.explanation] }) })) : null, entry.items.map((item, i) => {
297
+ const mark = item.status === 'completed' ? '✓' : item.status === 'in_progress' ? '⏳' : '☐';
298
+ const color = item.status === 'completed' ? 'green' : item.status === 'in_progress' ? 'yellow' : 'gray';
299
+ // Multi-line steps indent under the first line so the checkbox
300
+ // anchor stays visually attached to the whole step.
301
+ const stepLines = String(item.step).split('\n');
302
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { color: color, children: [" ", mark, " "] }), _jsx(Text, { color: item.status === 'completed' ? 'gray' : undefined, children: stepLines[0] })] }), stepLines.slice(1).map((line, j) => (_jsxs(Box, { children: [_jsx(Text, { children: ' ' }), _jsx(Text, { color: item.status === 'completed' ? 'gray' : undefined, dimColor: true, children: line })] }, j)))] }, i));
303
+ })] }));
304
+ case 'notice': {
305
+ // info → gray dim
306
+ // warn → yellow
307
+ // error → red bold
308
+ const level = entry.level ?? 'info';
309
+ const color = level === 'error' ? 'red' : level === 'warn' ? 'yellow' : 'gray';
310
+ return (_jsx(Box, { children: _jsx(Text, { color: color, bold: level === 'error', dimColor: level === 'info', wrap: "truncate", children: entry.text }) }));
311
+ }
312
+ }
313
+ }
314
+ /**
315
+ * Render one line of a tool-result preview. Diff lines get red/green
316
+ * coloring (see classifyDiffLine). The first line of the preview is
317
+ * prefixed with `⎿` connector under the tool header; continuation lines
318
+ * just indent to align with the connector body.
319
+ */
320
+ function ToolPreviewLine({ line, isFirst, isDiff }) {
321
+ const indent = isFirst ? ' ⎿ ' : ' ';
322
+ let textColor = 'gray';
323
+ let dim = true;
324
+ if (isDiff) {
325
+ const kind = classifyDiffLine(line);
326
+ if (kind === 'add') {
327
+ textColor = 'green';
328
+ dim = false;
329
+ }
330
+ else if (kind === 'del') {
331
+ textColor = 'red';
332
+ dim = false;
333
+ }
334
+ else if (kind === 'hunk') {
335
+ textColor = 'cyan';
336
+ dim = true;
337
+ }
338
+ }
339
+ return (_jsxs(Box, { children: [_jsx(Text, { color: "gray", dimColor: true, children: indent }), _jsx(Text, { color: textColor, dimColor: dim, wrap: "truncate", children: line })] }));
340
+ }
341
+ const TOOL_PREVIEW_MAX_LINES = 8;
342
+ /** Split preview into the visible head + the count of hidden tail lines. */
343
+ function splitForPreview(preview) {
344
+ const lines = preview.split('\n');
345
+ if (lines.length <= TOOL_PREVIEW_MAX_LINES)
346
+ return { visible: lines, hidden: 0 };
347
+ return { visible: lines.slice(0, TOOL_PREVIEW_MAX_LINES), hidden: lines.length - TOOL_PREVIEW_MAX_LINES };
348
+ }
349
+ /** Human-readable duration: 950ms, 1.2s, 12s, 1m 23s. */
350
+ function formatDuration(ms) {
351
+ if (ms < 1000)
352
+ return `${ms}ms`;
353
+ if (ms < 10_000)
354
+ return `${(ms / 1000).toFixed(1)}s`;
355
+ if (ms < 60_000)
356
+ return `${Math.round(ms / 1000)}s`;
357
+ const m = Math.floor(ms / 60_000);
358
+ const s = Math.round((ms % 60_000) / 1000);
359
+ return `${m}m ${s}s`;
360
+ }
361
+ /**
362
+ * Slash command palette — scrollable, navigable, full-list view.
363
+ *
364
+ * Sized to a fixed `MAX_VISIBLE` window; when the match count exceeds
365
+ * the window, the viewport scrolls to keep the highlighted cursor in
366
+ * range. "↑ N more" / "↓ N more" hints render at the edges so the user
367
+ * knows there's more list to see.
368
+ *
369
+ * The command column has a fixed width so descriptions align across
370
+ * rows; descriptions use Ink's `wrap="truncate"` so a long line is
371
+ * cut with an ellipsis at the terminal edge instead of wrapping to
372
+ * the next row (which would break the per-row layout).
373
+ */
374
+ const PALETTE_MAX_VISIBLE = 10;
375
+ const PALETTE_CMD_COL_WIDTH = 24;
376
+ function SlashPalettePanel({ matches, cursor, accentColor, cols, }) {
377
+ if (matches.length === 0) {
378
+ return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "gray", dimColor: true, children: "(no matching commands)" }) }));
379
+ }
380
+ // Compute a sliding viewport so the cursor stays comfortably inside.
381
+ // Prefer centering when possible; clamp at the ends so we never show
382
+ // an empty row at top or bottom.
383
+ const total = matches.length;
384
+ const safeCursor = Math.max(0, Math.min(cursor, total - 1));
385
+ const windowSize = Math.min(PALETTE_MAX_VISIBLE, total);
386
+ let viewportStart = safeCursor - Math.floor(windowSize / 2);
387
+ if (viewportStart < 0)
388
+ viewportStart = 0;
389
+ if (viewportStart + windowSize > total)
390
+ viewportStart = Math.max(0, total - windowSize);
391
+ const visible = matches.slice(viewportStart, viewportStart + windowSize);
392
+ const hiddenAbove = viewportStart;
393
+ const hiddenBelow = total - (viewportStart + windowSize);
394
+ // Progressive collapse for narrow terminals:
395
+ // - Below 50 cols: drop the description column entirely; the cmd
396
+ // column expands to fill the remaining width.
397
+ // - At normal widths: fixed 24-col cmd column, description takes
398
+ // the rest with `wrap="truncate"`.
399
+ const showDescription = cols >= 50;
400
+ const cmdColWidth = showDescription
401
+ ? PALETTE_CMD_COL_WIDTH
402
+ : Math.max(12, cols - 6);
403
+ const descBudget = showDescription ? Math.max(12, cols - cmdColWidth - 5) : 0;
404
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [hiddenAbove > 0 ? (_jsx(Box, { children: _jsx(Text, { color: "gray", dimColor: true, children: ` ↑ ${hiddenAbove} more above` }) })) : null, visible.map((cmd, i) => {
405
+ const actualIdx = viewportStart + i;
406
+ const isSelected = actualIdx === safeCursor;
407
+ return (_jsxs(Box, { children: [_jsx(Text, { color: accentColor, children: isSelected ? ' › ' : ' ' }), _jsx(Box, { width: cmdColWidth, children: _jsx(Text, { bold: isSelected, color: isSelected ? accentColor : undefined, wrap: "truncate", children: cmd.cmd }) }), showDescription ? (_jsx(Box, { width: descBudget, children: _jsx(Text, { color: "gray", wrap: "truncate", children: cmd.description }) })) : null] }, cmd.cmd));
408
+ }), hiddenBelow > 0 ? (_jsx(Box, { children: _jsx(Text, { color: "gray", dimColor: true, children: ` ↓ ${hiddenBelow} more below` }) })) : null, _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", dimColor: true, wrap: "truncate", children: cols >= 90
409
+ ? '↑/↓ navigate · tab autocomplete · ↵ submit · type to filter · esc / backspace past / to cancel'
410
+ : cols >= 60
411
+ ? '↑/↓ · tab autocomplete · ↵ submit · esc to cancel'
412
+ : '↑/↓ · tab · ↵ · esc' }) })] }));
413
+ }
414
+ function FooterStatus({ promptLabel, phase, accentColor, accessMode, footer, cols, }) {
415
+ // Pill background mirrors the readline REPL's mode-to-token mapping:
416
+ // read → green (safe)
417
+ // write → accent (default brand)
418
+ // shell → red (escalated)
419
+ // See cli/repl.ts:refreshPromptForMode for the rationale.
420
+ const pillBg = accessMode === 'shell' ? 'red' : accessMode === 'write' ? accentColor : 'green';
421
+ const pillFg = 'black';
422
+ // Effort glyphs — claude-code v2.1.147 convention:
423
+ // low → ○ (open circle, light)
424
+ // medium → ◐ (half circle)
425
+ // high → ● (filled circle, heavy)
426
+ // Rendered inline next to the pill, not as a separate boxed pill, so
427
+ // the footer stays compact on narrow terminals.
428
+ const effortGlyph = footer.effort === 'high' ? '●' : footer.effort === 'medium' ? '◐' : footer.effort === 'low' ? '○' : '';
429
+ const effortColor = footer.effort === 'high' ? 'magenta' : footer.effort === 'medium' ? 'yellow' : 'gray';
430
+ // Left side: model · session · branch. Right side: ? for shortcuts.
431
+ // Spreads out so the footer feels like claude-code's bottom bar.
432
+ const leftSegs = [];
433
+ if (footer.model)
434
+ leftSegs.push(footer.model);
435
+ if (footer.session)
436
+ leftSegs.push(footer.session.slice(0, 16));
437
+ if (footer.branch)
438
+ leftSegs.push(footer.branch);
439
+ if (footer.rightExtra)
440
+ leftSegs.push(footer.rightExtra);
441
+ // Progressive collapse — borrowed from codex's footer.rs:58–86 pattern.
442
+ // Below 80 cols, drop the auxiliary left segments (model · session ·
443
+ // branch). Below 60 cols, drop the right-side hint. Below 40 cols,
444
+ // even the effort glyph collapses to just the access pill — that's
445
+ // the smallest viable status row.
446
+ const showLeftSegs = cols >= 80 && leftSegs.length > 0;
447
+ const showEffortLabel = cols >= 50;
448
+ const showEffortGlyph = !!effortGlyph && cols >= 40;
449
+ const showRightHint = cols >= 60;
450
+ const rightText = showRightHint
451
+ ? (cols >= 80 ? '? for shortcuts · / for commands' : '? · /')
452
+ : '';
453
+ // Render the WHOLE footer as a single Text with nested Text children
454
+ // for the colored pill and glyphs. wrap="truncate" on the outer Text
455
+ // ensures it NEVER visually wraps (which is what causes Ink's diff
456
+ // to leave residue on resize — each wrap-overflow row that Ink
457
+ // thinks is 1 logical row but is 2+ visual rows accumulates as
458
+ // duplicated composer/footer blocks on every resize).
459
+ //
460
+ // Ink supports nested Text — children inherit parent props (like
461
+ // wrap) but their own color/backgroundColor/bold etc. apply locally.
462
+ return (_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { backgroundColor: pillBg, color: pillFg, children: ` ◉ ${accessMode} ` }), showEffortGlyph ? (_jsxs(Text, { children: [' ', _jsx(Text, { color: effortColor, children: effortGlyph }), showEffortLabel ? _jsx(Text, { color: "gray", dimColor: true, children: ` ${footer.effort}` }) : null] })) : null, showLeftSegs ? (_jsx(Text, { color: "gray", dimColor: true, children: ' ' + leftSegs.join(' · ') })) : null, phase === 'turn-running' && cols >= 50 ? (_jsx(Text, { color: "gray", dimColor: true, children: ' · running' })) : null] }), showRightHint ? (_jsx(Text, { color: "gray", dimColor: true, wrap: "truncate", children: rightText })) : null] }));
463
+ }
464
+ // --- Helpers ----------------------------------------------------------
465
+ function seedScrollback(banner, offline, hint) {
466
+ let id = 0;
467
+ const next = () => ++id;
468
+ const out = [{ id: next(), kind: 'raw', text: banner, noWrap: true }];
469
+ if (offline)
470
+ out.push({ id: next(), kind: 'raw', text: offline, noWrap: true });
471
+ out.push({ id: next(), kind: 'raw', text: hint, noWrap: true });
472
+ return out;
473
+ }
474
+ export function filterPaletteCommands(commands, query) {
475
+ if (!query)
476
+ return commands;
477
+ const q = query.toLowerCase();
478
+ const scored = commands
479
+ .map((c, i) => {
480
+ const body = c.cmd.slice(1).toLowerCase();
481
+ let s = 3;
482
+ if (body.startsWith(q))
483
+ s = 0;
484
+ else if (body.includes(q))
485
+ s = 1;
486
+ else if (c.description.toLowerCase().includes(q))
487
+ s = 2;
488
+ return { c, i, s };
489
+ })
490
+ .filter((x) => x.s < 3);
491
+ scored.sort((a, b) => (a.s - b.s) || (a.i - b.i));
492
+ return scored.map((x) => x.c);
493
+ }
@@ -0,0 +1,26 @@
1
+ import React from 'react';
2
+ /**
3
+ * Bordered panel chrome shared by every Ink picker / prompt in the
4
+ * 0.3.7 redesign.
5
+ *
6
+ * Why a Frame component? Ink owns the render loop and diffs the cell
7
+ * grid between frames — so we don't have to track cursor positions or
8
+ * worry about lines stacking. Every picker / wizard step / config
9
+ * panel is a `<Frame>` with a title, optional badge, and a body. Ink
10
+ * handles all the redraw mechanics that the previous raw-stdout
11
+ * approach got wrong.
12
+ *
13
+ * Visual reference: matches the previous renderFrame() chrome —
14
+ * theme-colored border, bold title left, muted badge right, muted
15
+ * footer hint at the bottom.
16
+ */
17
+ export interface FrameProps {
18
+ title: string;
19
+ badge?: string;
20
+ subtitle?: string;
21
+ footer?: string;
22
+ /** Border color — defaults to brand orange. */
23
+ accentColor?: string;
24
+ children?: React.ReactNode;
25
+ }
26
+ export declare function Frame({ title, badge, subtitle, footer, accentColor, children }: FrameProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export function Frame({ title, badge, subtitle, footer, accentColor = '#CC9166', children }) {
4
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: accentColor, paddingX: 1, marginY: 0, children: [_jsxs(Box, { justifyContent: "space-between", marginBottom: subtitle ? 0 : 0, children: [_jsx(Text, { bold: true, color: accentColor, children: title }), badge ? _jsx(Text, { color: "gray", children: badge }) : null] }), subtitle ? (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "gray", children: subtitle }) })) : null, _jsx(Box, { flexDirection: "column", children: children }), footer ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", dimColor: true, children: footer }) })) : null] }));
5
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Arrow-key picker built on Ink.
3
+ *
4
+ * Replaces the raw-stdout `pickFromList` primitive that was creeping
5
+ * upward on every cursor move + stacking frames on step transitions.
6
+ * Ink's render loop handles all the redraw / diff / cursor mechanics
7
+ * correctly because it owns the screen.
8
+ *
9
+ * Supports:
10
+ * - N rows (no 2-4 cap)
11
+ * - Optional "Other" free-text fallback
12
+ * - Pre-filled "Other" mode for env-var-derived defaults
13
+ * - Live preview rows (returned from `onCursorChange`, rendered
14
+ * INSIDE the frame above the footer)
15
+ * - Value column right-aligned per row
16
+ * - `›` selected glyph + theme-colored highlight
17
+ *
18
+ * Pattern lineage: state machine + reducer split lifted from grok-cli's
19
+ * SuggestionOverlay; live-preview-from-state contract from codex's
20
+ * theme_picker.rs.
21
+ */
22
+ export interface PickerRow {
23
+ id: string;
24
+ label: string;
25
+ value?: string;
26
+ description?: string;
27
+ }
28
+ export interface PickerProps {
29
+ title: string;
30
+ subtitle?: string;
31
+ badge?: string;
32
+ /** Optional footer override; defaults to "↑/↓ navigate · ↵ confirm · esc / q cancel". */
33
+ footer?: string;
34
+ rows: PickerRow[];
35
+ initialCursor?: number;
36
+ allowOther?: boolean;
37
+ otherLabel?: string;
38
+ otherDescription?: string;
39
+ prefilledOther?: string;
40
+ onCursorChange?: (id: string, index: number) => string[] | undefined;
41
+ /** Hex / named color for the panel border + title. Defaults to brand orange. */
42
+ accentColor?: string;
43
+ /**
44
+ * Back-compat: callers from `commands/config.ts` etc. pass a Theme
45
+ * object (chalk-based). When present, we pull `accentColor` from
46
+ * the theme's primary hex. Ignored if accentColor is also set.
47
+ */
48
+ theme?: {
49
+ mode: string;
50
+ };
51
+ /** Ignored (kept for back-compat with the raw-stdout picker shape). */
52
+ eraseOnClose?: boolean;
53
+ /** Resolves with the picker outcome. The component unmounts itself after the callback. */
54
+ onResolve: (result: PickerResult) => void;
55
+ }
56
+ export type PickerResult = {
57
+ kind: 'pick';
58
+ id: string;
59
+ } | {
60
+ kind: 'other';
61
+ text: string;
62
+ } | {
63
+ kind: 'cancelled';
64
+ };
65
+ export declare function Picker(props: PickerProps): import("react/jsx-runtime").JSX.Element;