@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.
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/config-resolver.d.ts +22 -0
- package/dist/config-resolver.d.ts.map +1 -0
- package/dist/config-resolver.js +66 -0
- package/dist/config-resolver.js.map +1 -0
- package/dist/custom-commands.d.ts +21 -0
- package/dist/custom-commands.d.ts.map +1 -0
- package/dist/custom-commands.js +73 -0
- package/dist/custom-commands.js.map +1 -0
- package/dist/flags.d.ts +11 -0
- package/dist/flags.d.ts.map +1 -0
- package/dist/flags.js +16 -0
- package/dist/flags.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +159 -0
- package/dist/index.js.map +1 -0
- package/dist/onboarding.d.ts +35 -0
- package/dist/onboarding.d.ts.map +1 -0
- package/dist/onboarding.js +131 -0
- package/dist/onboarding.js.map +1 -0
- package/dist/prompt.d.ts +29 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +60 -0
- package/dist/prompt.js.map +1 -0
- package/dist/provider-presets.d.ts +22 -0
- package/dist/provider-presets.d.ts.map +1 -0
- package/dist/provider-presets.js +60 -0
- package/dist/provider-presets.js.map +1 -0
- package/dist/provider.d.ts +26 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +61 -0
- package/dist/provider.js.map +1 -0
- package/dist/runtime.d.ts +68 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +127 -0
- package/dist/runtime.js.map +1 -0
- package/dist/system-prompt.d.ts +17 -0
- package/dist/system-prompt.d.ts.map +1 -0
- package/dist/system-prompt.js +32 -0
- package/dist/system-prompt.js.map +1 -0
- package/dist/tui/App.d.ts +44 -0
- package/dist/tui/App.d.ts.map +1 -0
- package/dist/tui/App.js +894 -0
- package/dist/tui/App.js.map +1 -0
- package/dist/tui/format-tool-call.d.ts +35 -0
- package/dist/tui/format-tool-call.d.ts.map +1 -0
- package/dist/tui/format-tool-call.js +129 -0
- package/dist/tui/format-tool-call.js.map +1 -0
- package/package.json +42 -0
package/dist/tui/App.js
ADDED
|
@@ -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
|