@breviqcode/cli 0.1.0

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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -0
  3. package/dist/config-resolver.d.ts +22 -0
  4. package/dist/config-resolver.d.ts.map +1 -0
  5. package/dist/config-resolver.js +66 -0
  6. package/dist/config-resolver.js.map +1 -0
  7. package/dist/custom-commands.d.ts +21 -0
  8. package/dist/custom-commands.d.ts.map +1 -0
  9. package/dist/custom-commands.js +73 -0
  10. package/dist/custom-commands.js.map +1 -0
  11. package/dist/flags.d.ts +11 -0
  12. package/dist/flags.d.ts.map +1 -0
  13. package/dist/flags.js +16 -0
  14. package/dist/flags.js.map +1 -0
  15. package/dist/index.d.ts +3 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +159 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/onboarding.d.ts +35 -0
  20. package/dist/onboarding.d.ts.map +1 -0
  21. package/dist/onboarding.js +131 -0
  22. package/dist/onboarding.js.map +1 -0
  23. package/dist/prompt.d.ts +29 -0
  24. package/dist/prompt.d.ts.map +1 -0
  25. package/dist/prompt.js +60 -0
  26. package/dist/prompt.js.map +1 -0
  27. package/dist/provider-presets.d.ts +22 -0
  28. package/dist/provider-presets.d.ts.map +1 -0
  29. package/dist/provider-presets.js +60 -0
  30. package/dist/provider-presets.js.map +1 -0
  31. package/dist/provider.d.ts +26 -0
  32. package/dist/provider.d.ts.map +1 -0
  33. package/dist/provider.js +61 -0
  34. package/dist/provider.js.map +1 -0
  35. package/dist/runtime.d.ts +68 -0
  36. package/dist/runtime.d.ts.map +1 -0
  37. package/dist/runtime.js +127 -0
  38. package/dist/runtime.js.map +1 -0
  39. package/dist/system-prompt.d.ts +17 -0
  40. package/dist/system-prompt.d.ts.map +1 -0
  41. package/dist/system-prompt.js +32 -0
  42. package/dist/system-prompt.js.map +1 -0
  43. package/dist/tui/App.d.ts +44 -0
  44. package/dist/tui/App.d.ts.map +1 -0
  45. package/dist/tui/App.js +894 -0
  46. package/dist/tui/App.js.map +1 -0
  47. package/dist/tui/format-tool-call.d.ts +35 -0
  48. package/dist/tui/format-tool-call.d.ts.map +1 -0
  49. package/dist/tui/format-tool-call.js +129 -0
  50. package/dist/tui/format-tool-call.js.map +1 -0
  51. package/package.json +42 -0
@@ -0,0 +1,894 @@
1
+ import { jsxs as _jsxs, Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { randomBytes } from "node:crypto";
3
+ import { Box, render, Static, Text, useApp, useInput } from "ink";
4
+ import Spinner from "ink-spinner";
5
+ import { useEffect, useRef, useState } from "react";
6
+ import { Loop, SecretStore, UserConfig } from "@breviqcode/core";
7
+ import { buildRuntime, configPath } from "../runtime.js";
8
+ import { extractResumeId } from "../flags.js";
9
+ import { buildProviderNamed } from "../provider.js";
10
+ import { PROVIDER_PRESETS } from "../provider-presets.js";
11
+ import { runOnboarding } from "../onboarding.js";
12
+ import { systemMessage } from "../system-prompt.js";
13
+ import { loadCustomCommands, expandCommand } from "../custom-commands.js";
14
+ import { formatTodoChecklist, formatToolCallLabel, formatToolResultDetail, TOOLS_WITH_OWN_UI } from "./format-tool-call.js";
15
+ /** The slash-command palette's source of truth — shown as a filterable,
16
+ * arrow-navigable menu the moment the input starts with "/", matching
17
+ * Claude Code's own behavior (type "/" to see everything, keep typing to
18
+ * filter, ↑/↓ to navigate, Enter to run, Tab to complete). */
19
+ const SLASH_COMMANDS = [
20
+ { name: "model", args: "<name>", description: "Switch the model for this session" },
21
+ { name: "provider", description: "Switch provider (OpenAI, Anthropic, Gemini, Groq, local, ...)" },
22
+ { name: "resume", args: "<id>", description: "Resume a previous session (no id opens a picker)" },
23
+ { name: "mcp", description: "List configured MCP servers and their tools" },
24
+ { name: "plan", description: "Switch to plan mode (read-only until you approve a plan)" },
25
+ { name: "build", description: "Switch to build mode (all tools available)" },
26
+ { name: "clear", description: "Clear conversation context (history on disk is kept)" },
27
+ { name: "help", description: "Show commands and tips" },
28
+ { name: "exit", description: "Quit BreviqCode" },
29
+ ];
30
+ const CONFIRM_OPTIONS = [
31
+ { label: "Yes", approve: true, remember: false },
32
+ { label: "Yes, allow always this session", approve: true, remember: true },
33
+ { label: "No", approve: false, remember: false },
34
+ ];
35
+ let nextEntryId = 1;
36
+ /**
37
+ * The interactive, stay-open TUI — this project's equivalent of the Go
38
+ * version's Bubble Tea interface, built with Ink/React instead (matching
39
+ * Claude Code's own real stack, per the research behind this whole
40
+ * rewrite). Unlike the one-shot `breviq chat` command (send one message,
41
+ * stream the reply, exit), this keeps one process alive across many turns,
42
+ * reusing the exact same runtime wiring via `buildRuntime` — the same
43
+ * tools, gate, session store, symbol index, and LSP manager, just kept
44
+ * alive instead of torn down after one message.
45
+ *
46
+ * Tool calls render as a two-line "⏺ Label(args)" / " ⎿ detail" block —
47
+ * matching Claude Code's/OpenCode's own tool-call presentation — rather
48
+ * than a flat status line, and the input sits in a bordered box (colored
49
+ * by agent mode) rather than a plain text prompt, since a bare `[build] >`
50
+ * line was reported live as looking nothing like either tool's actual UI.
51
+ *
52
+ * The permission-confirmation `Prompter` and the `question` tool's `Asker`
53
+ * are implemented as plain closures over this component's own `useState`
54
+ * setters — no external pub/sub bridge needed, since `buildRuntime` (and
55
+ * everything it constructs) is only ever built *inside* this component's
56
+ * effect, after Ink already owns the render tree. Gate/tools calling
57
+ * `prompt()`/`ask()` from deep inside a tool-call chain just resolves to
58
+ * "set some state and return a Promise the state-driven UI resolves later"
59
+ * — normal React state updates are safe to call from any async context,
60
+ * not just event handlers.
61
+ */
62
+ export function App({ continueSession, initialAgentMode, resumeSessionId, }) {
63
+ const { exit } = useApp();
64
+ const [uiMode, setUiMode] = useState("loading");
65
+ const [transcript, setTranscript] = useState([]);
66
+ const [streamingText, setStreamingText] = useState("");
67
+ const [inputBuffer, setInputBuffer] = useState("");
68
+ const [pendingConfirm, setPendingConfirm] = useState();
69
+ const [pendingQuestion, setPendingQuestion] = useState();
70
+ const [model, setModel] = useState("");
71
+ const [agentModeDisplay, setAgentModeDisplay] = useState(initialAgentMode);
72
+ const runtimeRef = useRef(undefined);
73
+ const conversationRef = useRef([]);
74
+ const activeToolRef = useRef(undefined);
75
+ const [activeToolLabel, setActiveToolLabel] = useState(undefined);
76
+ // One shared selection index for whichever arrow-navigable list is
77
+ // currently visible (slash palette, confirm options, question options,
78
+ // model picker) — only one of those can ever be on screen at a time.
79
+ const [selectedIndex, setSelectedIndex] = useState(0);
80
+ // Model ids fetched from the provider's live listing endpoint for the
81
+ // /model picker; inputBuffer doubles as its filter text while open.
82
+ const [modelChoices, setModelChoices] = useState([]);
83
+ // The preset being switched to via /provider, while its base-URL/key
84
+ // questions are still being answered in the TUI.
85
+ const [providerDraft, setProviderDraft] = useState();
86
+ // Past sessions fetched for the /resume picker (id, creation time, and a
87
+ // preview of the first user message); inputBuffer doubles as its filter
88
+ // text, same convention as the /model picker.
89
+ const [sessionChoices, setSessionChoices] = useState([]);
90
+ // Project-level commands loaded from .breviqcode/commands/*.md, merged
91
+ // into the slash palette alongside the built-in SLASH_COMMANDS.
92
+ const [customCommands, setCustomCommands] = useState([]);
93
+ // The visible slash palette entries for the current input, or [] when
94
+ // the palette shouldn't show (input doesn't start with "/", or args are
95
+ // already being typed after a completed command name).
96
+ const paletteMatches = (() => {
97
+ if (uiMode !== "input" || !inputBuffer.startsWith("/"))
98
+ return [];
99
+ const afterSlash = inputBuffer.slice(1);
100
+ if (afterSlash.includes(" "))
101
+ return []; // command chosen, user is typing args now
102
+ const allCommands = [
103
+ ...SLASH_COMMANDS,
104
+ ...customCommands.map((c) => ({ name: c.name, args: c.argsHint, description: c.description })),
105
+ ];
106
+ return allCommands.filter((c) => c.name.startsWith(afterSlash.toLowerCase()));
107
+ })();
108
+ function pushTranscript(kind, text) {
109
+ setTranscript((prev) => [...prev, { id: nextEntryId++, kind, text }]);
110
+ }
111
+ function pushToolResult(label, detail, isError) {
112
+ setTranscript((prev) => [
113
+ ...prev,
114
+ { id: nextEntryId++, kind: "tool", text: "", toolLabel: label, toolDetail: detail, toolError: isError },
115
+ ]);
116
+ }
117
+ // Shared by the initial startup effect and resumeSession() below — both
118
+ // need a fresh Prompter/Asker closing over the same setState setters.
119
+ function makePrompter() {
120
+ return (toolName, argsJson, dangerous) => new Promise((resolve) => {
121
+ setPendingConfirm({ toolName, argsJson, dangerous, resolve });
122
+ setSelectedIndex(0);
123
+ setUiMode("confirm");
124
+ });
125
+ }
126
+ function makeAsker() {
127
+ return (question, options) => new Promise((resolve) => {
128
+ setPendingQuestion({ question, options, resolve });
129
+ setSelectedIndex(0);
130
+ setUiMode("question");
131
+ });
132
+ }
133
+ useEffect(() => {
134
+ let cancelled = false;
135
+ void (async () => {
136
+ const prompt = makePrompter();
137
+ const ask = makeAsker();
138
+ let runtime;
139
+ try {
140
+ runtime = await buildRuntime({
141
+ prompt,
142
+ ask,
143
+ initialMode: initialAgentMode,
144
+ continueSession,
145
+ newSessionId: randomBytes(8).toString("hex"),
146
+ resumeSessionId,
147
+ onSubAgentStatus: (text) => pushTranscript("status", text),
148
+ });
149
+ }
150
+ catch (err) {
151
+ // A bad --resume <id> is the one way buildRuntime can fail here —
152
+ // degrade to a fresh session instead of leaving the TUI stuck on
153
+ // the startup spinner forever.
154
+ if (!resumeSessionId)
155
+ throw err;
156
+ pushTranscript("status", `couldn't resume session "${resumeSessionId}": ${err instanceof Error ? err.message : String(err)} — starting a fresh session instead`);
157
+ runtime = await buildRuntime({
158
+ prompt,
159
+ ask,
160
+ initialMode: initialAgentMode,
161
+ continueSession,
162
+ newSessionId: randomBytes(8).toString("hex"),
163
+ onSubAgentStatus: (text) => pushTranscript("status", text),
164
+ });
165
+ }
166
+ if (cancelled) {
167
+ await runtime.close();
168
+ return;
169
+ }
170
+ runtimeRef.current = runtime;
171
+ setModel(runtime.model);
172
+ setCustomCommands(loadCustomCommands(runtime.projectRoot));
173
+ conversationRef.current = [
174
+ systemMessage(runtime.projectRoot, initialAgentMode, runtime.projectInstructions),
175
+ ...runtime.history,
176
+ ];
177
+ for (const m of runtime.history) {
178
+ if (m.role === "user")
179
+ pushTranscript("user", m.content);
180
+ else if (m.role === "assistant" && m.content)
181
+ pushTranscript("assistant", m.content);
182
+ }
183
+ setUiMode("input");
184
+ })();
185
+ return () => {
186
+ cancelled = true;
187
+ void runtimeRef.current?.close();
188
+ };
189
+ // eslint-disable-next-line react-hooks/exhaustive-deps
190
+ }, []);
191
+ async function submit(text) {
192
+ const trimmed = text.trim();
193
+ if (!trimmed)
194
+ return;
195
+ if (trimmed.startsWith("/")) {
196
+ handleSlashCommand(trimmed);
197
+ return;
198
+ }
199
+ await sendToModel(trimmed);
200
+ }
201
+ /** Pushes one user turn and runs it through the model — shared by plain
202
+ * typed input and an expanded custom slash-command's prompt, so both go
203
+ * through identical tool-call rendering/history-persistence logic rather
204
+ * than duplicating it. */
205
+ async function sendToModel(trimmed) {
206
+ const runtime = runtimeRef.current;
207
+ if (!runtime)
208
+ return;
209
+ const preSubmit = await runtime.hooks.runUserPromptSubmit(trimmed);
210
+ if (preSubmit.block) {
211
+ pushTranscript("status", `prompt blocked by hook: ${preSubmit.reason ?? "denied"}`);
212
+ return;
213
+ }
214
+ pushTranscript("user", trimmed);
215
+ conversationRef.current.push({ role: "user", content: trimmed });
216
+ runtime.store.appendMessage(runtime.sessionId, "user", trimmed);
217
+ setUiMode("busy");
218
+ setStreamingText("");
219
+ activeToolRef.current = undefined;
220
+ setActiveToolLabel(undefined);
221
+ const loop = new Loop({
222
+ provider: runtime.provider,
223
+ tools: runtime.registry,
224
+ gate: runtime.gate,
225
+ hooks: runtime.hooks,
226
+ onTextDelta: (delta) => setStreamingText((prev) => prev + delta),
227
+ // Only non-tool status lines (currently just the tool_use_failed
228
+ // retry notice) come through here — tool call/result presentation
229
+ // is handled by onToolCall/onToolResult below instead of parsing
230
+ // this raw `[tool] name {json}`-style text.
231
+ onStatusLine: (line) => {
232
+ if (!line.startsWith("[tool]"))
233
+ pushTranscript("status", line);
234
+ },
235
+ onToolCall: (name, argsJson) => {
236
+ if (name === "todowrite") {
237
+ pushTranscript("status", formatTodoChecklist(argsJson));
238
+ return;
239
+ }
240
+ if (TOOLS_WITH_OWN_UI.has(name))
241
+ return; // question/exit_plan_mode show their own prompt UI
242
+ const label = formatToolCallLabel(name, argsJson);
243
+ activeToolRef.current = { name, label };
244
+ setActiveToolLabel(label);
245
+ },
246
+ onToolResult: (name, result, isError) => {
247
+ if (name === "todowrite" || TOOLS_WITH_OWN_UI.has(name))
248
+ return;
249
+ const label = activeToolRef.current?.name === name ? activeToolRef.current.label : `${name}()`;
250
+ pushToolResult(label, formatToolResultDetail(name, result, isError), isError);
251
+ activeToolRef.current = undefined;
252
+ setActiveToolLabel(undefined);
253
+ },
254
+ onMessage: (m) => {
255
+ conversationRef.current.push(m);
256
+ runtime.store.appendMessage(runtime.sessionId, m.role, m.content, m.toolCalls ?? [], m.toolCallId ?? "");
257
+ if (m.role === "assistant" && m.content) {
258
+ pushTranscript("assistant", m.content);
259
+ setStreamingText("");
260
+ }
261
+ },
262
+ });
263
+ try {
264
+ await loop.run(model, conversationRef.current);
265
+ }
266
+ catch (err) {
267
+ pushTranscript("status", `error: ${err instanceof Error ? err.message : String(err)}`);
268
+ }
269
+ setUiMode("input");
270
+ }
271
+ function pushSystemNote(content) {
272
+ conversationRef.current.push({ role: "system", content });
273
+ const runtime = runtimeRef.current;
274
+ if (runtime)
275
+ runtime.store.appendMessage(runtime.sessionId, "system", content);
276
+ }
277
+ /** Completes a /resume: tears down the current runtime entirely and
278
+ * rebuilds it pointed at a different session id — a bigger operation
279
+ * than /provider's swap-in-place, since the session store, history, and
280
+ * conversation context all change together, not just the provider. */
281
+ async function resumeSession(sessionId) {
282
+ const current = runtimeRef.current;
283
+ if (current && sessionId === current.sessionId) {
284
+ pushTranscript("status", "already in that session");
285
+ return;
286
+ }
287
+ setUiMode("loading");
288
+ try {
289
+ // Build the new runtime before tearing down the old one — if this
290
+ // throws (e.g. the session was deleted between listing it and
291
+ // selecting it), the current session stays fully usable instead of
292
+ // being left half-closed.
293
+ const runtime = await buildRuntime({
294
+ prompt: makePrompter(),
295
+ ask: makeAsker(),
296
+ initialMode: agentModeDisplay,
297
+ continueSession: false,
298
+ newSessionId: randomBytes(8).toString("hex"),
299
+ resumeSessionId: sessionId,
300
+ onSubAgentStatus: (text) => pushTranscript("status", text),
301
+ });
302
+ void current?.close();
303
+ runtimeRef.current = runtime;
304
+ setModel(runtime.model);
305
+ conversationRef.current = [
306
+ systemMessage(runtime.projectRoot, agentModeDisplay, runtime.projectInstructions),
307
+ ...runtime.history,
308
+ ];
309
+ pushTranscript("status", `resumed session ${runtime.sessionId}`);
310
+ for (const m of runtime.history) {
311
+ if (m.role === "user")
312
+ pushTranscript("user", m.content);
313
+ else if (m.role === "assistant" && m.content)
314
+ pushTranscript("assistant", m.content);
315
+ }
316
+ }
317
+ catch (err) {
318
+ pushTranscript("status", `couldn't resume session: ${err instanceof Error ? err.message : String(err)}`);
319
+ }
320
+ setUiMode("input");
321
+ }
322
+ /** Completes a /provider switch: builds the new provider instance, swaps
323
+ * it into the live runtime (sub-agents follow via TaskTool's getters),
324
+ * and persists the choice exactly the way onboarding does (config.json +
325
+ * per-preset keychain slot) so it survives restarts. */
326
+ function switchProvider(preset, baseUrl, apiKey, extras) {
327
+ const runtime = runtimeRef.current;
328
+ if (!runtime)
329
+ return;
330
+ try {
331
+ const provider = buildProviderNamed({
332
+ provider: preset.adapter,
333
+ baseUrl,
334
+ apiKey,
335
+ maxContext: runtime.maxContext,
336
+ azureResourceName: extras?.azureResourceName,
337
+ azureDeploymentName: extras?.azureDeploymentName,
338
+ azureApiVersion: extras?.azureApiVersion,
339
+ awsRegion: extras?.awsRegion,
340
+ });
341
+ runtime.setProviderAndModel(provider, preset.defaultModel);
342
+ setModel(preset.defaultModel);
343
+ new UserConfig(configPath()).save({
344
+ preset: preset.id,
345
+ provider: preset.adapter,
346
+ baseUrl: baseUrl || undefined,
347
+ model: preset.defaultModel,
348
+ azureResourceName: extras?.azureResourceName,
349
+ azureDeploymentName: extras?.azureDeploymentName,
350
+ azureApiVersion: extras?.azureApiVersion,
351
+ awsRegion: extras?.awsRegion,
352
+ });
353
+ if (apiKey)
354
+ new SecretStore().setApiKey(preset.id, apiKey);
355
+ pushTranscript("status", `provider: ${preset.label} · model: ${preset.defaultModel} — /model to pick another`);
356
+ }
357
+ catch (err) {
358
+ pushTranscript("status", `provider switch failed: ${err instanceof Error ? err.message : String(err)}`);
359
+ }
360
+ setProviderDraft(undefined);
361
+ setInputBuffer("");
362
+ setSelectedIndex(0);
363
+ setUiMode("input");
364
+ }
365
+ /** Runs when a preset is picked in the /provider list — decides whether
366
+ * it can switch immediately (key already stored / saved config has the
367
+ * extra fields) or needs more answers first (base URL, key). */
368
+ function choosePreset(preset) {
369
+ const secrets = new SecretStore();
370
+ const storedKey = secrets.getApiKey(preset.id);
371
+ if (preset.flow === "azure" || preset.flow === "bedrock" || preset.flow === "workers-ai") {
372
+ // These need fields beyond one key (resource/deployment, AWS creds
373
+ // trio, account id). If a complete saved setup for this exact preset
374
+ // already exists, switch straight to it; otherwise point at the
375
+ // wizard rather than reproducing its whole multi-field flow here.
376
+ const saved = new UserConfig(configPath()).load();
377
+ const savedMatches = saved?.preset === preset.id || (saved?.provider === preset.adapter && preset.adapter !== "openai");
378
+ const complete = preset.flow === "azure"
379
+ ? Boolean(saved?.azureResourceName && saved.azureDeploymentName && saved.azureApiVersion && storedKey)
380
+ : preset.flow === "bedrock"
381
+ ? Boolean(saved?.awsRegion && storedKey)
382
+ : Boolean(saved?.baseUrl && storedKey);
383
+ if (savedMatches && complete && saved) {
384
+ switchProvider(preset, saved.baseUrl ?? "", storedKey ?? "", {
385
+ azureResourceName: saved.azureResourceName,
386
+ azureDeploymentName: saved.azureDeploymentName,
387
+ azureApiVersion: saved.azureApiVersion,
388
+ awsRegion: saved.awsRegion,
389
+ });
390
+ }
391
+ else {
392
+ pushTranscript("status", `${preset.label} needs extra setup fields — run \`breviq config\` once to set it up, then /provider switches to it instantly`);
393
+ setSelectedIndex(0);
394
+ setUiMode("input");
395
+ }
396
+ return;
397
+ }
398
+ if (preset.flow === "ask-base-url") {
399
+ setProviderDraft({ preset });
400
+ setInputBuffer("");
401
+ setUiMode("provider-url");
402
+ return;
403
+ }
404
+ // fixed-base-url / none: only a key is needed — reuse a stored one, or
405
+ // ask for it right here in the TUI.
406
+ const baseUrl = preset.fixedBaseUrl ?? "";
407
+ if (storedKey) {
408
+ switchProvider(preset, baseUrl, storedKey);
409
+ }
410
+ else {
411
+ setProviderDraft({ preset, baseUrl });
412
+ setInputBuffer("");
413
+ setUiMode("provider-key");
414
+ }
415
+ }
416
+ function handleSlashCommand(cmd) {
417
+ const [name, ...rest] = cmd.slice(1).split(/\s+/);
418
+ const arg = rest.join(" ");
419
+ const runtime = runtimeRef.current;
420
+ switch (name) {
421
+ case "help":
422
+ pushTranscript("status", "commands: /model <name> /provider /resume [id] /mcp /plan /build /clear /help /exit (or Ctrl+C)\ntip: end a line with \\ then Enter to insert a newline instead of submitting");
423
+ break;
424
+ case "resume": {
425
+ if (arg) {
426
+ void resumeSession(arg);
427
+ break;
428
+ }
429
+ if (!runtime)
430
+ break;
431
+ const sessions = runtime.store.listSessions().filter((s) => s.id !== runtime.sessionId);
432
+ if (sessions.length === 0) {
433
+ pushTranscript("status", "no other sessions to resume");
434
+ break;
435
+ }
436
+ const choices = sessions.map((s) => ({
437
+ id: s.id,
438
+ createdAt: s.createdAt,
439
+ preview: runtime.store.firstUserMessage(s.id) ?? "(no messages yet)",
440
+ }));
441
+ setSessionChoices(choices);
442
+ setSelectedIndex(0);
443
+ setInputBuffer("");
444
+ setUiMode("resume-picker");
445
+ break;
446
+ }
447
+ case "mcp":
448
+ setUiMode("mcp-list");
449
+ break;
450
+ case "provider":
451
+ setSelectedIndex(0);
452
+ setUiMode("provider-picker");
453
+ break;
454
+ case "exit":
455
+ case "quit":
456
+ exit();
457
+ break;
458
+ case "clear":
459
+ // Resets the model-facing conversation only — the *visible*
460
+ // transcript is intentionally left alone. `<Static>` tracks "how
461
+ // many items have I already printed" as a cursor that only ever
462
+ // advances, the same way real terminal scrollback can't be
463
+ // un-printed; resetting `transcript` to `[]` here previously broke
464
+ // that invariant (confirmed via an isolated test): Static then
465
+ // treated the next pushed item as one it had "already printed" at
466
+ // that same array index and silently skipped rendering it, along
467
+ // with everything appended afterward. Never shrink/replace the
468
+ // array Static is fed — only ever append to it.
469
+ if (runtime)
470
+ conversationRef.current = [systemMessage(runtime.projectRoot, agentModeDisplay, runtime.projectInstructions)];
471
+ pushTranscript("status", "context cleared (new messages start fresh; session history on disk is untouched)");
472
+ break;
473
+ case "model":
474
+ if (arg) {
475
+ setModel(arg);
476
+ runtime?.setModel(arg);
477
+ pushTranscript("status", `model set to ${arg}`);
478
+ break;
479
+ }
480
+ // No arg: open the model picker, fetching live from the provider's
481
+ // listing endpoint (OpenAI-compatible /models, Anthropic
482
+ // /v1/models, Gemini /v1beta/models). Providers without a listing
483
+ // endpoint (Bedrock) fall back to showing the current model and a
484
+ // type-a-name hint.
485
+ if (runtime?.provider.listModels) {
486
+ setUiMode("busy");
487
+ void runtime.provider
488
+ .listModels()
489
+ .then((models) => {
490
+ if (models.length === 0) {
491
+ pushTranscript("status", `provider returned no models; current model: ${model}`);
492
+ setUiMode("input");
493
+ return;
494
+ }
495
+ setModelChoices(models);
496
+ setSelectedIndex(Math.max(0, models.indexOf(model)));
497
+ setInputBuffer("");
498
+ setUiMode("model-picker");
499
+ })
500
+ .catch((err) => {
501
+ pushTranscript("status", `couldn't list models (${err instanceof Error ? err.message : String(err)}); current model: ${model} — use /model <name> to set one directly`);
502
+ setUiMode("input");
503
+ });
504
+ }
505
+ else {
506
+ pushTranscript("status", `current model: ${model} (this provider has no listing endpoint — use /model <name>)`);
507
+ }
508
+ break;
509
+ case "plan":
510
+ runtime?.gate.setMode("plan");
511
+ setAgentModeDisplay("plan");
512
+ pushSystemNote("Mode switched to PLAN: only read-only tools work now. Call exit_plan_mode once ready to make changes.");
513
+ pushTranscript("status", "mode: plan");
514
+ break;
515
+ case "build":
516
+ runtime?.gate.setMode("build");
517
+ setAgentModeDisplay("build");
518
+ pushSystemNote("Mode switched to BUILD: all tools are available now.");
519
+ pushTranscript("status", "mode: build");
520
+ break;
521
+ default: {
522
+ const custom = customCommands.find((c) => c.name === name);
523
+ if (custom) {
524
+ void sendToModel(expandCommand(custom, arg));
525
+ break;
526
+ }
527
+ pushTranscript("status", `unknown command: /${name} (try /help)`);
528
+ }
529
+ }
530
+ }
531
+ useInput((input, key) => {
532
+ if (key.ctrl && input === "c") {
533
+ exit();
534
+ return;
535
+ }
536
+ if (uiMode === "confirm" && pendingConfirm) {
537
+ // Arrow-selectable option list (Yes / Yes-always / No), matching
538
+ // Claude Code's own permission prompt — with y/a/n still accepted
539
+ // as shortcut keys for people used to the old style.
540
+ if (key.upArrow) {
541
+ setSelectedIndex((prev) => (prev - 1 + CONFIRM_OPTIONS.length) % CONFIRM_OPTIONS.length);
542
+ return;
543
+ }
544
+ if (key.downArrow) {
545
+ setSelectedIndex((prev) => (prev + 1) % CONFIRM_OPTIONS.length);
546
+ return;
547
+ }
548
+ const resolveConfirm = (option) => {
549
+ pendingConfirm.resolve({ approve: option.approve, remember: option.remember });
550
+ setPendingConfirm(undefined);
551
+ setSelectedIndex(0);
552
+ setUiMode("busy");
553
+ };
554
+ if (key.return) {
555
+ resolveConfirm(CONFIRM_OPTIONS[selectedIndex] ?? CONFIRM_OPTIONS[0]);
556
+ return;
557
+ }
558
+ const lower = input.toLowerCase();
559
+ if (lower === "y")
560
+ resolveConfirm({ approve: true, remember: false });
561
+ else if (lower === "a")
562
+ resolveConfirm({ approve: true, remember: true });
563
+ else if (lower === "n")
564
+ resolveConfirm({ approve: false, remember: false });
565
+ return;
566
+ }
567
+ if (uiMode === "question" && pendingQuestion) {
568
+ const options = pendingQuestion.options ?? [];
569
+ // With options: ↑/↓ selects among them; typing anything switches to
570
+ // a free-text answer (options are suggestions, not a closed set —
571
+ // same contract the readline-based Asker in prompt.ts has).
572
+ if (options.length > 0 && inputBuffer === "") {
573
+ if (key.upArrow) {
574
+ setSelectedIndex((prev) => (prev - 1 + options.length) % options.length);
575
+ return;
576
+ }
577
+ if (key.downArrow) {
578
+ setSelectedIndex((prev) => (prev + 1) % options.length);
579
+ return;
580
+ }
581
+ }
582
+ if (key.return) {
583
+ const answer = inputBuffer !== "" ? inputBuffer : (options[selectedIndex] ?? "");
584
+ pendingQuestion.resolve(answer);
585
+ setPendingQuestion(undefined);
586
+ setInputBuffer("");
587
+ setSelectedIndex(0);
588
+ setUiMode("busy");
589
+ return;
590
+ }
591
+ if (key.backspace || key.delete) {
592
+ setInputBuffer((prev) => prev.slice(0, -1));
593
+ return;
594
+ }
595
+ if (input)
596
+ setInputBuffer((prev) => prev + input);
597
+ return;
598
+ }
599
+ if (uiMode === "provider-picker") {
600
+ // inputBuffer doubles as filter text, same convention the /model
601
+ // picker uses — found missing live (typing a provider name did
602
+ // nothing and Enter silently picked whatever arrow position was
603
+ // last active, defaulting to the first entry), so this matches that
604
+ // already-proven pattern instead of inventing a new one.
605
+ const filtered = PROVIDER_PRESETS.filter((p) => p.label.toLowerCase().includes(inputBuffer.toLowerCase()));
606
+ if (key.escape) {
607
+ setInputBuffer("");
608
+ setSelectedIndex(0);
609
+ setUiMode("input");
610
+ return;
611
+ }
612
+ if (key.upArrow && filtered.length > 0) {
613
+ setSelectedIndex((prev) => (prev - 1 + filtered.length) % filtered.length);
614
+ return;
615
+ }
616
+ if (key.downArrow && filtered.length > 0) {
617
+ setSelectedIndex((prev) => (prev + 1) % filtered.length);
618
+ return;
619
+ }
620
+ if (key.return) {
621
+ const preset = filtered[Math.min(selectedIndex, Math.max(0, filtered.length - 1))];
622
+ setInputBuffer("");
623
+ setSelectedIndex(0);
624
+ if (preset)
625
+ choosePreset(preset);
626
+ return;
627
+ }
628
+ if (key.backspace || key.delete) {
629
+ setInputBuffer((prev) => prev.slice(0, -1));
630
+ setSelectedIndex(0);
631
+ return;
632
+ }
633
+ if (input) {
634
+ setInputBuffer((prev) => prev + input);
635
+ setSelectedIndex(0);
636
+ }
637
+ return;
638
+ }
639
+ if (uiMode === "provider-url" && providerDraft) {
640
+ if (key.escape) {
641
+ setProviderDraft(undefined);
642
+ setInputBuffer("");
643
+ setUiMode("input");
644
+ return;
645
+ }
646
+ if (key.return) {
647
+ const baseUrl = inputBuffer.trim() || providerDraft.preset.askDefaultBaseUrl || "";
648
+ setProviderDraft({ ...providerDraft, baseUrl });
649
+ setInputBuffer("");
650
+ setUiMode("provider-key");
651
+ return;
652
+ }
653
+ if (key.backspace || key.delete) {
654
+ setInputBuffer((prev) => prev.slice(0, -1));
655
+ return;
656
+ }
657
+ if (input)
658
+ setInputBuffer((prev) => prev + input);
659
+ return;
660
+ }
661
+ if (uiMode === "provider-key" && providerDraft) {
662
+ if (key.escape) {
663
+ setProviderDraft(undefined);
664
+ setInputBuffer("");
665
+ setUiMode("input");
666
+ return;
667
+ }
668
+ if (key.return) {
669
+ switchProvider(providerDraft.preset, providerDraft.baseUrl ?? "", inputBuffer.trim());
670
+ return;
671
+ }
672
+ if (key.backspace || key.delete) {
673
+ setInputBuffer((prev) => prev.slice(0, -1));
674
+ return;
675
+ }
676
+ if (input)
677
+ setInputBuffer((prev) => prev + input);
678
+ return;
679
+ }
680
+ if (uiMode === "model-picker") {
681
+ // inputBuffer doubles as the filter text; selection wraps within the
682
+ // filtered list, same interaction shape as the slash palette.
683
+ const filtered = modelChoices.filter((m) => m.toLowerCase().includes(inputBuffer.toLowerCase()));
684
+ if (key.escape) {
685
+ setModelChoices([]);
686
+ setInputBuffer("");
687
+ setSelectedIndex(0);
688
+ setUiMode("input");
689
+ return;
690
+ }
691
+ if (key.upArrow && filtered.length > 0) {
692
+ setSelectedIndex((prev) => (prev - 1 + filtered.length) % filtered.length);
693
+ return;
694
+ }
695
+ if (key.downArrow && filtered.length > 0) {
696
+ setSelectedIndex((prev) => (prev + 1) % filtered.length);
697
+ return;
698
+ }
699
+ if (key.return) {
700
+ const chosen = filtered[Math.min(selectedIndex, Math.max(0, filtered.length - 1))];
701
+ if (chosen) {
702
+ setModel(chosen);
703
+ runtimeRef.current?.setModel(chosen);
704
+ pushTranscript("status", `model set to ${chosen}`);
705
+ }
706
+ setModelChoices([]);
707
+ setInputBuffer("");
708
+ setSelectedIndex(0);
709
+ setUiMode("input");
710
+ return;
711
+ }
712
+ if (key.backspace || key.delete) {
713
+ setInputBuffer((prev) => prev.slice(0, -1));
714
+ setSelectedIndex(0);
715
+ return;
716
+ }
717
+ if (input) {
718
+ setInputBuffer((prev) => prev + input);
719
+ setSelectedIndex(0);
720
+ }
721
+ return;
722
+ }
723
+ if (uiMode === "resume-picker") {
724
+ // Same filter-by-typing convention as /model and /provider: filter
725
+ // both the preview text and the raw id, since a user resuming a
726
+ // session with no messages yet has nothing but the id to match on.
727
+ const filtered = sessionChoices.filter((s) => s.preview.toLowerCase().includes(inputBuffer.toLowerCase()) || s.id.includes(inputBuffer));
728
+ if (key.escape) {
729
+ setSessionChoices([]);
730
+ setInputBuffer("");
731
+ setSelectedIndex(0);
732
+ setUiMode("input");
733
+ return;
734
+ }
735
+ if (key.upArrow && filtered.length > 0) {
736
+ setSelectedIndex((prev) => (prev - 1 + filtered.length) % filtered.length);
737
+ return;
738
+ }
739
+ if (key.downArrow && filtered.length > 0) {
740
+ setSelectedIndex((prev) => (prev + 1) % filtered.length);
741
+ return;
742
+ }
743
+ if (key.return) {
744
+ const chosen = filtered[Math.min(selectedIndex, Math.max(0, filtered.length - 1))];
745
+ setSessionChoices([]);
746
+ setInputBuffer("");
747
+ setSelectedIndex(0);
748
+ if (chosen)
749
+ void resumeSession(chosen.id);
750
+ return;
751
+ }
752
+ if (key.backspace || key.delete) {
753
+ setInputBuffer((prev) => prev.slice(0, -1));
754
+ setSelectedIndex(0);
755
+ return;
756
+ }
757
+ if (input) {
758
+ setInputBuffer((prev) => prev + input);
759
+ setSelectedIndex(0);
760
+ }
761
+ return;
762
+ }
763
+ if (uiMode === "mcp-list") {
764
+ if (key.escape || key.return)
765
+ setUiMode("input");
766
+ return;
767
+ }
768
+ if (uiMode !== "input")
769
+ return;
770
+ // Slash palette navigation, when visible.
771
+ if (paletteMatches.length > 0) {
772
+ if (key.upArrow) {
773
+ setSelectedIndex((prev) => (prev - 1 + paletteMatches.length) % paletteMatches.length);
774
+ return;
775
+ }
776
+ if (key.downArrow) {
777
+ setSelectedIndex((prev) => (prev + 1) % paletteMatches.length);
778
+ return;
779
+ }
780
+ if (key.tab) {
781
+ const chosen = paletteMatches[Math.min(selectedIndex, paletteMatches.length - 1)];
782
+ if (chosen)
783
+ setInputBuffer(`/${chosen.name} `);
784
+ setSelectedIndex(0);
785
+ return;
786
+ }
787
+ if (key.escape) {
788
+ setInputBuffer("");
789
+ setSelectedIndex(0);
790
+ return;
791
+ }
792
+ if (key.return) {
793
+ const chosen = paletteMatches[Math.min(selectedIndex, paletteMatches.length - 1)];
794
+ setInputBuffer("");
795
+ setSelectedIndex(0);
796
+ if (chosen)
797
+ void submit(`/${chosen.name}`);
798
+ return;
799
+ }
800
+ }
801
+ if (key.return) {
802
+ if (inputBuffer.endsWith("\\")) {
803
+ setInputBuffer((prev) => prev.slice(0, -1) + "\n");
804
+ return;
805
+ }
806
+ const toSubmit = inputBuffer;
807
+ setInputBuffer("");
808
+ void submit(toSubmit);
809
+ return;
810
+ }
811
+ if (key.backspace || key.delete) {
812
+ setInputBuffer((prev) => {
813
+ const next = prev.slice(0, -1);
814
+ return next;
815
+ });
816
+ setSelectedIndex(0);
817
+ return;
818
+ }
819
+ if (input) {
820
+ setInputBuffer((prev) => prev + input);
821
+ setSelectedIndex(0);
822
+ }
823
+ });
824
+ const modeColor = agentModeDisplay === "plan" ? "yellow" : "cyan";
825
+ const footerBorderColor = uiMode === "confirm" || uiMode === "question" ? "yellow" : modeColor;
826
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: transcript, children: (entry) => (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: entry.kind === "tool" ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: entry.toolError ? "red" : "blue", children: [entry.toolError ? "✗" : "⏺", " ", entry.toolLabel] }), _jsxs(Text, { dimColor: true, children: [" ⎿ ", entry.toolDetail] })] })) : (_jsxs(Text, { color: entry.kind === "user" ? "cyan" : entry.kind === "assistant" ? undefined : undefined, dimColor: entry.kind === "status", bold: entry.kind === "user", children: [entry.kind === "user" ? "> " : "", entry.text] })) }, entry.id)) }), activeToolLabel && (_jsxs(Text, { color: "blue", children: [_jsx(Spinner, { type: "dots" }), " ", activeToolLabel] })), streamingText.length > 0 && _jsx(Text, { children: streamingText }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { borderStyle: "round", borderColor: footerBorderColor, paddingX: 1, children: [uiMode === "loading" && (_jsxs(Text, { dimColor: true, children: [_jsx(Spinner, { type: "dots" }), " starting..."] })), uiMode === "confirm" && pendingConfirm && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: pendingConfirm.dangerous ? "red" : undefined, children: [pendingConfirm.dangerous ? "⚠ DANGEROUS — " : "", formatToolCallLabel(pendingConfirm.toolName, pendingConfirm.argsJson)] }), _jsx(Text, { dimColor: true, children: "Do you want to allow this?" }), CONFIRM_OPTIONS.map((option, i) => (_jsxs(Text, { color: i === selectedIndex ? "cyan" : undefined, dimColor: i !== selectedIndex, children: [i === selectedIndex ? "❯ " : " ", option.label] }, option.label)))] })), uiMode === "question" && pendingQuestion && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: pendingQuestion.question }), (pendingQuestion.options ?? []).map((option, i) => (_jsxs(Text, { color: inputBuffer === "" && i === selectedIndex ? "cyan" : undefined, dimColor: inputBuffer !== "" || i !== selectedIndex, children: [inputBuffer === "" && i === selectedIndex ? "❯ " : " ", option] }, option))), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "or type: " }), _jsx(Text, { children: inputBuffer })] })] })), uiMode === "busy" && !activeToolLabel && (_jsxs(Text, { dimColor: true, children: [_jsx(Spinner, { type: "dots" }), " thinking..."] })), uiMode === "provider-picker" &&
827
+ (() => {
828
+ const filtered = PROVIDER_PRESETS.filter((p) => p.label.toLowerCase().includes(inputBuffer.toLowerCase()));
829
+ const sel = Math.min(selectedIndex, Math.max(0, filtered.length - 1));
830
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Select a provider " }), _jsx(Text, { dimColor: true, children: "(type to filter, \u2191/\u2193, Enter, Esc to cancel)" })] }), inputBuffer !== "" && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "filter: " }), _jsx(Text, { children: inputBuffer })] })), filtered.map((preset, i) => (_jsxs(Text, { color: i === sel ? "cyan" : undefined, dimColor: i !== sel, children: [i === sel ? "❯ " : " ", preset.label] }, preset.id))), filtered.length === 0 && _jsxs(Text, { dimColor: true, children: ["no providers match \"", inputBuffer, "\""] })] }));
831
+ })(), uiMode === "provider-url" && providerDraft && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: ["Base URL for ", providerDraft.preset.label] }), _jsxs(Text, { dimColor: true, children: ["blank = ", providerDraft.preset.askDefaultBaseUrl, " (Esc to cancel)"] }), _jsxs(Box, { children: [_jsx(Text, { color: modeColor, children: "> " }), _jsx(Text, { children: inputBuffer })] })] })), uiMode === "provider-key" && providerDraft && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [providerDraft.preset.label, " API key"] }), _jsxs(Text, { dimColor: true, children: [providerDraft.preset.flow === "ask-base-url" ? "blank = keyless local server " : "", "(stored in the OS keychain, Esc to cancel)"] }), _jsxs(Box, { children: [_jsx(Text, { color: modeColor, children: "> " }), _jsx(Text, { children: inputBuffer })] })] })), uiMode === "model-picker" &&
832
+ (() => {
833
+ const filtered = modelChoices.filter((m) => m.toLowerCase().includes(inputBuffer.toLowerCase()));
834
+ const sel = Math.min(selectedIndex, Math.max(0, filtered.length - 1));
835
+ // Windowed view: keep the selection visible without rendering
836
+ // a provider's full catalog (Groq alone lists ~50 models).
837
+ const WINDOW = 8;
838
+ const start = Math.max(0, Math.min(sel - Math.floor(WINDOW / 2), filtered.length - WINDOW));
839
+ const visible = filtered.slice(start, start + WINDOW);
840
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Select a model " }), _jsxs(Text, { dimColor: true, children: ["(type to filter, \u2191/\u2193, Enter, Esc to cancel) \u2014 ", filtered.length, " of ", modelChoices.length] })] }), inputBuffer !== "" && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "filter: " }), _jsx(Text, { children: inputBuffer })] })), visible.map((m, i) => {
841
+ const actualIndex = start + i;
842
+ const isSelected = actualIndex === sel;
843
+ return (_jsxs(Text, { color: isSelected ? "cyan" : undefined, dimColor: !isSelected, children: [isSelected ? "❯ " : " ", m, m === model ? " (current)" : ""] }, m));
844
+ }), filtered.length === 0 && _jsxs(Text, { dimColor: true, children: ["no models match \"", inputBuffer, "\""] })] }));
845
+ })(), uiMode === "resume-picker" &&
846
+ (() => {
847
+ const filtered = sessionChoices.filter((s) => s.preview.toLowerCase().includes(inputBuffer.toLowerCase()) || s.id.includes(inputBuffer));
848
+ const sel = Math.min(selectedIndex, Math.max(0, filtered.length - 1));
849
+ const WINDOW = 8;
850
+ const start = Math.max(0, Math.min(sel - Math.floor(WINDOW / 2), filtered.length - WINDOW));
851
+ const visible = filtered.slice(start, start + WINDOW);
852
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Resume a session " }), _jsxs(Text, { dimColor: true, children: ["(type to filter, \u2191/\u2193, Enter, Esc to cancel) \u2014 ", filtered.length, " of ", sessionChoices.length] })] }), inputBuffer !== "" && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "filter: " }), _jsx(Text, { children: inputBuffer })] })), visible.map((s, i) => {
853
+ const actualIndex = start + i;
854
+ const isSelected = actualIndex === sel;
855
+ const preview = s.preview.replace(/\s+/g, " ").slice(0, 60);
856
+ return (_jsxs(Text, { color: isSelected ? "cyan" : undefined, dimColor: !isSelected, children: [isSelected ? "❯ " : " ", s.createdAt.toLocaleString(), " \u2014 ", preview, s.preview.length > 60 ? "…" : ""] }, s.id));
857
+ }), filtered.length === 0 && _jsxs(Text, { dimColor: true, children: ["no sessions match \"", inputBuffer, "\""] })] }));
858
+ })(), uiMode === "mcp-list" &&
859
+ (() => {
860
+ const servers = runtimeRef.current?.mcpManager.listServers() ?? [];
861
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "MCP servers " }), _jsx(Text, { dimColor: true, children: "(Enter/Esc to close)" })] }), servers.length === 0 && (_jsx(Text, { dimColor: true, children: "no MCP servers configured \u2014 add .breviqcode/mcp.json to configure some" })), servers.map((s) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: s.connected ? "green" : "red", children: [s.connected ? "✓" : "✗", " ", s.name, s.connected ? ` (${s.toolNames.length} tool${s.toolNames.length === 1 ? "" : "s"})` : ""] }), s.connected &&
862
+ s.toolNames.map((name) => (_jsxs(Text, { dimColor: true, children: [" ", name] }, name))), !s.connected && _jsxs(Text, { dimColor: true, children: [" ", s.error] })] }, s.name)))] }));
863
+ })(), uiMode === "input" && (_jsxs(Box, { children: [_jsx(Text, { color: modeColor, children: "> " }), inputBuffer ? _jsx(Text, { children: inputBuffer }) : _jsx(Text, { dimColor: true, children: "type a message, or / for commands\u2026" })] }))] }), paletteMatches.length > 0 && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, children: paletteMatches.map((cmd, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: i === selectedIndex ? "cyan" : undefined, dimColor: i !== selectedIndex, children: [i === selectedIndex ? "❯ " : " ", "/", cmd.name, cmd.args ? ` ${cmd.args}` : ""] }), _jsxs(Text, { dimColor: true, children: [" ", cmd.description] })] }, cmd.name))) })), _jsxs(Text, { dimColor: true, children: [agentModeDisplay, " mode \u00B7 ", model || "…", runtimeRef.current ? ` · ${runtimeRef.current.projectRoot}` : ""] })] })] }));
864
+ }
865
+ /**
866
+ * Entry point for the interactive TUI. If neither real env vars nor a
867
+ * saved config exist yet, runs the first-run onboarding wizard *before*
868
+ * Ink ever renders — `resolveConfig()` (called later, inside
869
+ * `buildRuntime`) would otherwise try to run that same readline-based
870
+ * wizard while Ink already owns stdin in raw mode, which is exactly the
871
+ * kind of two-consumers-fighting-over-stdin problem this project's
872
+ * `createInteractiveIO` fix (see CLAUDE.md) exists to avoid — simplest fix
873
+ * here is just not letting the two ever overlap in the first place.
874
+ */
875
+ export async function runTui(args) {
876
+ // Ink needs raw-mode TTY input for keystroke-by-keystroke handling
877
+ // (useInput); a real terminal launch always has this, but a non-TTY
878
+ // stdin (piped input, CI, a script) does not, and Ink's own resulting
879
+ // error is a raw stack trace, not a helpful message. Fail with a clear
880
+ // pointer to the scriptable one-shot command instead.
881
+ if (!process.stdin.isTTY) {
882
+ throw new Error('the interactive TUI needs a real terminal (stdin is not a TTY here) — use `breviq chat "prompt"` for scripted/piped/CI use instead');
883
+ }
884
+ const { resumeSessionId, rest } = extractResumeId(args);
885
+ const continueSession = rest.includes("--continue");
886
+ const plan = rest.includes("--plan");
887
+ const hasEnvConfig = Boolean(process.env.BREVIQ_PROVIDER || process.env.BREVIQ_API_KEY || process.env.BREVIQ_BASE_URL || process.env.BREVIQ_MODEL);
888
+ if (!hasEnvConfig && !new UserConfig(configPath()).exists()) {
889
+ await runOnboarding(configPath());
890
+ }
891
+ const instance = render(_jsx(App, { continueSession: continueSession, initialAgentMode: plan ? "plan" : "build", resumeSessionId: resumeSessionId }));
892
+ await instance.waitUntilExit();
893
+ }
894
+ //# sourceMappingURL=App.js.map