@spinabot/brigade 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/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/SECURITY.md +208 -0
- package/assets/brigade-wordmark-on-black.png +0 -0
- package/assets/brigade-wordmark.png +0 -0
- package/brigade.mjs +96 -0
- package/dist/cli/chat-cmd.js +120 -0
- package/dist/cli/config-cmd.js +132 -0
- package/dist/cli/connect-cmd.js +447 -0
- package/dist/cli/doctor-cmd.js +317 -0
- package/dist/cli/gateway-cmd.js +92 -0
- package/dist/cli.js +287 -0
- package/dist/core/agent.js +1123 -0
- package/dist/core/config.js +80 -0
- package/dist/core/console-stream.js +188 -0
- package/dist/core/error-classifier.js +354 -0
- package/dist/core/event-logger.js +122 -0
- package/dist/core/model-caps.js +185 -0
- package/dist/core/provider-payload-mutators.js +517 -0
- package/dist/core/provider-quirks.js +285 -0
- package/dist/core/server.js +459 -0
- package/dist/core/smart-compaction.js +209 -0
- package/dist/core/system-prompt-defaults.js +88 -0
- package/dist/core/system-prompt-guidance.js +269 -0
- package/dist/core/system-prompt.js +884 -0
- package/dist/index.js +30 -0
- package/dist/integrations/ollama.js +140 -0
- package/dist/protocol.js +49 -0
- package/dist/providers/catalog.js +100 -0
- package/dist/providers/validate-key.js +197 -0
- package/dist/tui/client.js +263 -0
- package/dist/ui/brand-frames-cli.js +20 -0
- package/dist/ui/brand-frames.js +36 -0
- package/dist/ui/brand.js +402 -0
- package/dist/ui/chat.js +929 -0
- package/dist/ui/onboarding.js +400 -0
- package/dist/ui/theme.js +51 -0
- package/package.json +92 -0
package/dist/ui/chat.js
ADDED
|
@@ -0,0 +1,929 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brigade chat TUI.
|
|
3
|
+
*
|
|
4
|
+
* Pi-TUI components, glued to the Pi Agent event stream:
|
|
5
|
+
* - Status header (model · token usage · cost)
|
|
6
|
+
* - Conversation log (Markdown components, one per assistant turn)
|
|
7
|
+
* - Tool call indicators (Text components inserted inline)
|
|
8
|
+
* - Loader (CancellableLoader during agent thinking)
|
|
9
|
+
* - Editor (multi-line input with history)
|
|
10
|
+
* - Footer hint
|
|
11
|
+
*
|
|
12
|
+
* Streaming: each `message_delta` event updates the current Markdown component.
|
|
13
|
+
* Differential rendering means only changed lines repaint — no flicker.
|
|
14
|
+
*/
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { CancellableLoader, Editor, Input, Markdown, SelectList, Text, } from "@mariozechner/pi-tui";
|
|
17
|
+
import chalk from "chalk";
|
|
18
|
+
import { classifySensitiveStopReason, runWithContentQualityRetry, runWithFallback, runWithHeartbeat, runWithLengthContinuation, runWithStreamTimeout, runWithThinkingFallback, switchModelMidTurn, } from "../core/agent.js";
|
|
19
|
+
import { BRIGADE_DIR, loadConfig, saveConfig } from "../core/config.js";
|
|
20
|
+
import { cleanProviderError, describeModelCapabilities, pickStreamIdleMs } from "../core/model-caps.js";
|
|
21
|
+
import { discoverOllamaModels, writeOllamaToModelsJson } from "../integrations/ollama.js";
|
|
22
|
+
import { findProvider, PROVIDERS } from "../providers/catalog.js";
|
|
23
|
+
import { validateApiKeyOnline } from "../providers/validate-key.js";
|
|
24
|
+
import { renderBrandHeader } from "./brand.js";
|
|
25
|
+
import { brand, editorTheme, markdownTheme, selectListTheme } from "./theme.js";
|
|
26
|
+
const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
27
|
+
export async function runChat(opts) {
|
|
28
|
+
const { session, tui, authStorage, modelRegistry } = opts;
|
|
29
|
+
let provider = opts.provider;
|
|
30
|
+
let modelId = opts.modelId;
|
|
31
|
+
// ── brand wordmark at the top of the chat screen ────────────────────
|
|
32
|
+
// Same chunky 4-stop metallic wordmark from onboarding. Pi-TUI is
|
|
33
|
+
// stream-based, so this appears once at boot and naturally scrolls
|
|
34
|
+
// out of view as the conversation grows below.
|
|
35
|
+
renderBrandHeader(tui);
|
|
36
|
+
// ── status header (model · tokens · cost) ───────────────────────────
|
|
37
|
+
const header = new Text("", 0, 0);
|
|
38
|
+
tui.addChild(header);
|
|
39
|
+
const divider = new Text(brand.dim("─".repeat(80)), 0, 0);
|
|
40
|
+
tui.addChild(divider);
|
|
41
|
+
// usage state we render in the header
|
|
42
|
+
let totalIn = 0;
|
|
43
|
+
let totalOut = 0;
|
|
44
|
+
let totalCost = 0;
|
|
45
|
+
// Cost is only displayed when the provider reports it. Many free-tier providers
|
|
46
|
+
// (Groq, free Gemini tier, Ollama) don't report usage cost, so we hide the field
|
|
47
|
+
// instead of permanently showing $0.0000 — which would mislead users.
|
|
48
|
+
//
|
|
49
|
+
// Also includes per-model capabilities (thinking level, vision, ctx, $/Mtok)
|
|
50
|
+
// derived from the live `session.model` + `session.thinkingLevel` so it stays
|
|
51
|
+
// accurate across thinking-level changes and (future) model swaps.
|
|
52
|
+
const updateHeader = (extra) => {
|
|
53
|
+
const providerName = findProvider(provider)?.name ?? provider;
|
|
54
|
+
const caps = session.model
|
|
55
|
+
? describeModelCapabilities(session.model, session.thinkingLevel)
|
|
56
|
+
: "";
|
|
57
|
+
const capsStr = caps ? ` · ${caps}` : "";
|
|
58
|
+
const tokens = totalIn + totalOut > 0 ? ` · ${(totalIn + totalOut).toLocaleString()} tok` : "";
|
|
59
|
+
const cost = totalCost > 0 ? ` · $${totalCost.toFixed(4)}` : "";
|
|
60
|
+
// Live context usage. Pi recomputes this after each LLM response, so
|
|
61
|
+
// the percentage stays accurate as the conversation grows. We only
|
|
62
|
+
// show it once it crosses 50% — below that the user doesn't need to
|
|
63
|
+
// worry. Highlights in amber at 75%+ to warn before auto-compact fires.
|
|
64
|
+
const usage = session.getContextUsage();
|
|
65
|
+
let usageStr = "";
|
|
66
|
+
if (usage?.percent != null && usage.percent >= 50) {
|
|
67
|
+
const pct = Math.round(usage.percent);
|
|
68
|
+
const colored = pct >= 75 ? brand.amber(`${pct}% ctx`) : brand.dim(`${pct}% ctx`);
|
|
69
|
+
usageStr = ` · ${colored}`;
|
|
70
|
+
}
|
|
71
|
+
const tail = extra ? ` · ${extra}` : "";
|
|
72
|
+
header.setText(` ${brand.amber("●")} ${brand.white("Brigade")} ${brand.dim(`${providerName} · ${modelId}${capsStr}${tokens}${cost}`)}${usageStr}${brand.dim(tail)}`);
|
|
73
|
+
};
|
|
74
|
+
updateHeader();
|
|
75
|
+
// ── editor ──────────────────────────────────────────────────────────
|
|
76
|
+
const editor = new Editor(tui, editorTheme);
|
|
77
|
+
tui.addChild(editor);
|
|
78
|
+
tui.setFocus(editor);
|
|
79
|
+
// Hint line below the editor
|
|
80
|
+
tui.addChild(new Text(brand.dim(" Enter to send · Ctrl+C abort · Ctrl+D quit · /model /provider /thinking /compact /help"), 0, 0));
|
|
81
|
+
// ── streaming state ─────────────────────────────────────────────────
|
|
82
|
+
let isAgentRunning = false;
|
|
83
|
+
let activeAssistant = null;
|
|
84
|
+
let activeLoader = null;
|
|
85
|
+
let pendingTools = new Map();
|
|
86
|
+
/**
|
|
87
|
+
* Extract concatenated text from an assistant message's content blocks.
|
|
88
|
+
* The cumulative content is what Pi maintains — we read from it instead of
|
|
89
|
+
* trying to stitch deltas ourselves, because not every provider emits
|
|
90
|
+
* `text_delta` events (some only emit `text_start` + `text_end`, and
|
|
91
|
+
* reasoning models intersperse `thinking_*` events). Reading from
|
|
92
|
+
* `event.message.content` is what Pi's own interactive mode does and is
|
|
93
|
+
* the only robust approach across providers.
|
|
94
|
+
*/
|
|
95
|
+
const extractAssistantText = (message) => {
|
|
96
|
+
if (!message || !Array.isArray(message.content))
|
|
97
|
+
return "";
|
|
98
|
+
return message.content
|
|
99
|
+
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
100
|
+
.map((b) => b.text)
|
|
101
|
+
.join("");
|
|
102
|
+
};
|
|
103
|
+
/** Pull text from a user message — used by mid-turn /model switch to replay the last user message on the new model. */
|
|
104
|
+
const extractUserText = (message) => {
|
|
105
|
+
if (!message)
|
|
106
|
+
return "";
|
|
107
|
+
if (typeof message.content === "string")
|
|
108
|
+
return message.content;
|
|
109
|
+
if (!Array.isArray(message.content))
|
|
110
|
+
return "";
|
|
111
|
+
return message.content
|
|
112
|
+
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
113
|
+
.map((b) => b.text)
|
|
114
|
+
.join("");
|
|
115
|
+
};
|
|
116
|
+
const insertBeforeEditor = (component) => {
|
|
117
|
+
// children: [header, divider, ...messages, editor, hint]
|
|
118
|
+
const children = tui.children;
|
|
119
|
+
const editorIdx = children.indexOf(editor);
|
|
120
|
+
if (editorIdx < 0) {
|
|
121
|
+
tui.addChild(component);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
children.splice(editorIdx, 0, component);
|
|
125
|
+
tui.requestRender();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const removeChild = (component) => {
|
|
129
|
+
try {
|
|
130
|
+
tui.removeChild(component);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
/* ignore */
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Run an inline picker over the conversation: insert a SelectList right above
|
|
138
|
+
* the editor, steal focus, await a choice, then remove the list and any
|
|
139
|
+
* supporting label rows. Returns the chosen value, or `null` if the user
|
|
140
|
+
* pressed Esc/Ctrl+C. The chat stays scrolled and intact throughout —
|
|
141
|
+
* no clear-screen, no flash.
|
|
142
|
+
*/
|
|
143
|
+
const inlinePick = async (title, items, opts = {}) => {
|
|
144
|
+
const labelRow = new Text(` ${brand.amber(title)}`, 0, 0);
|
|
145
|
+
insertBeforeEditor(labelRow);
|
|
146
|
+
const [minW, maxW] = opts.primaryWidth ?? [22, 38];
|
|
147
|
+
const list = new SelectList(items, Math.min(items.length, 12), selectListTheme, {
|
|
148
|
+
minPrimaryColumnWidth: minW,
|
|
149
|
+
maxPrimaryColumnWidth: maxW,
|
|
150
|
+
});
|
|
151
|
+
insertBeforeEditor(list);
|
|
152
|
+
tui.setFocus(list);
|
|
153
|
+
tui.requestRender();
|
|
154
|
+
try {
|
|
155
|
+
const chosen = await new Promise((resolve, reject) => {
|
|
156
|
+
list.onSelect = (item) => resolve(item);
|
|
157
|
+
list.onCancel = () => reject(new Error("cancel"));
|
|
158
|
+
});
|
|
159
|
+
return chosen._value;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
removeChild(list);
|
|
166
|
+
removeChild(labelRow);
|
|
167
|
+
tui.setFocus(editor);
|
|
168
|
+
tui.requestRender();
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* Inline single-line text input — same lifecycle as inlinePick. Used for
|
|
173
|
+
* API-key entry, base-URL prompts, and the like during /provider.
|
|
174
|
+
* Returns the typed value, or `null` if the user pressed Esc.
|
|
175
|
+
*/
|
|
176
|
+
const inlinePrompt = async (label, hint) => {
|
|
177
|
+
const labelRow = new Text(` ${brand.amber(label)}`, 0, 0);
|
|
178
|
+
insertBeforeEditor(labelRow);
|
|
179
|
+
const hintRow = hint ? new Text(brand.dim(` ${hint}`), 0, 0) : null;
|
|
180
|
+
if (hintRow)
|
|
181
|
+
insertBeforeEditor(hintRow);
|
|
182
|
+
const input = new Input();
|
|
183
|
+
insertBeforeEditor(input);
|
|
184
|
+
tui.setFocus(input);
|
|
185
|
+
tui.requestRender();
|
|
186
|
+
try {
|
|
187
|
+
return await new Promise((resolve, reject) => {
|
|
188
|
+
input.onSubmit = (value) => resolve(value.trim());
|
|
189
|
+
input.onEscape = () => reject(new Error("cancel"));
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
removeChild(input);
|
|
197
|
+
if (hintRow)
|
|
198
|
+
removeChild(hintRow);
|
|
199
|
+
removeChild(labelRow);
|
|
200
|
+
tui.setFocus(editor);
|
|
201
|
+
tui.requestRender();
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
/**
|
|
205
|
+
* Switch to a new (provider, modelId) pair. Resolves the model object via
|
|
206
|
+
* ModelRegistry, calls Pi's setModel (which validates auth + persists to
|
|
207
|
+
* session), and updates Brigade's local state + saved config so the next
|
|
208
|
+
* boot resumes here. Returns whether the switch succeeded.
|
|
209
|
+
*/
|
|
210
|
+
const switchToModel = async (newProvider, newModelId) => {
|
|
211
|
+
const model = modelRegistry.find(newProvider, newModelId);
|
|
212
|
+
if (!model) {
|
|
213
|
+
insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`No model "${newModelId}" found for provider "${newProvider}".`)}`, 0, 0));
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
await session.setModel(model);
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
221
|
+
insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(msg)}`, 0, 0));
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
provider = newProvider;
|
|
225
|
+
modelId = newModelId;
|
|
226
|
+
await saveConfig({ defaultProvider: newProvider, defaultModelId: newModelId });
|
|
227
|
+
updateHeader();
|
|
228
|
+
insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim("switched to")} ${brand.white(`${newProvider} · ${newModelId}`)}`, 0, 0));
|
|
229
|
+
return true;
|
|
230
|
+
};
|
|
231
|
+
// ── replay: print prior conversation if resumed ────────────────────
|
|
232
|
+
for (const m of session.messages) {
|
|
233
|
+
if (m.role === "user" && Array.isArray(m.content)) {
|
|
234
|
+
const text = m.content
|
|
235
|
+
.filter((b) => b.type === "text")
|
|
236
|
+
.map((b) => b.text)
|
|
237
|
+
.join("");
|
|
238
|
+
if (text) {
|
|
239
|
+
insertBeforeEditor(new Markdown(`${brand.user("you")} ${text}`, 1, 0, markdownTheme));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
else if (m.role === "assistant" && Array.isArray(m.content)) {
|
|
243
|
+
const text = m.content
|
|
244
|
+
.filter((b) => b.type === "text")
|
|
245
|
+
.map((b) => b.text)
|
|
246
|
+
.join("");
|
|
247
|
+
if (text) {
|
|
248
|
+
insertBeforeEditor(new Markdown(`${brand.agent("brigade")} ${text}`, 1, 0, markdownTheme));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// ── subscribe to Pi events ─────────────────────────────────────────
|
|
253
|
+
session.subscribe((event) => {
|
|
254
|
+
switch (event.type) {
|
|
255
|
+
case "agent_start": {
|
|
256
|
+
isAgentRunning = true;
|
|
257
|
+
editor.disableSubmit = true;
|
|
258
|
+
updateHeader("thinking…");
|
|
259
|
+
activeLoader = new CancellableLoader(tui, (s) => brand.amber(s), (s) => brand.dim(s), "thinking");
|
|
260
|
+
insertBeforeEditor(activeLoader);
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
case "message_update": {
|
|
264
|
+
// Pull text from the cumulative message — works for every provider
|
|
265
|
+
// regardless of whether they emit text_delta, text_end, or just
|
|
266
|
+
// the final message all at once. This is the same pattern Pi's
|
|
267
|
+
// own interactive mode uses.
|
|
268
|
+
const msg = event.message;
|
|
269
|
+
if (!msg || msg.role !== "assistant")
|
|
270
|
+
break;
|
|
271
|
+
const text = extractAssistantText(msg);
|
|
272
|
+
if (!text)
|
|
273
|
+
break; // tool calls / thinking blocks only — nothing to render yet
|
|
274
|
+
if (activeLoader) {
|
|
275
|
+
removeChild(activeLoader);
|
|
276
|
+
activeLoader = null;
|
|
277
|
+
}
|
|
278
|
+
if (!activeAssistant) {
|
|
279
|
+
activeAssistant = new Markdown(`${brand.agent("brigade")} ${text}`, 1, 0, markdownTheme);
|
|
280
|
+
insertBeforeEditor(activeAssistant);
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
activeAssistant.setText(`${brand.agent("brigade")} ${text}`);
|
|
284
|
+
tui.requestRender();
|
|
285
|
+
}
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
case "message_end": {
|
|
289
|
+
// Final safety net: some providers don't stream incrementally —
|
|
290
|
+
// they only emit message_end with the full message. If we never
|
|
291
|
+
// rendered any text from message_update, render it now.
|
|
292
|
+
const msg = event.message;
|
|
293
|
+
if (!msg || msg.role !== "assistant")
|
|
294
|
+
break;
|
|
295
|
+
const text = extractAssistantText(msg);
|
|
296
|
+
if (!text)
|
|
297
|
+
break;
|
|
298
|
+
if (activeLoader) {
|
|
299
|
+
removeChild(activeLoader);
|
|
300
|
+
activeLoader = null;
|
|
301
|
+
}
|
|
302
|
+
if (!activeAssistant) {
|
|
303
|
+
activeAssistant = new Markdown(`${brand.agent("brigade")} ${text}`, 1, 0, markdownTheme);
|
|
304
|
+
insertBeforeEditor(activeAssistant);
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
activeAssistant.setText(`${brand.agent("brigade")} ${text}`);
|
|
308
|
+
tui.requestRender();
|
|
309
|
+
}
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
case "tool_execution_start": {
|
|
313
|
+
if (activeLoader) {
|
|
314
|
+
removeChild(activeLoader);
|
|
315
|
+
activeLoader = null;
|
|
316
|
+
}
|
|
317
|
+
const summary = formatToolArgs(event.toolName, event.args);
|
|
318
|
+
const indicator = new Text(` ${brand.tool("⚡")} ${brand.tool(event.toolName)} ${brand.dim(summary)}`, 0, 0);
|
|
319
|
+
pendingTools.set(event.toolCallId, indicator);
|
|
320
|
+
insertBeforeEditor(indicator);
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
case "tool_execution_end": {
|
|
324
|
+
const indicator = pendingTools.get(event.toolCallId);
|
|
325
|
+
if (indicator) {
|
|
326
|
+
const mark = event.isError ? brand.error("✗") : brand.tool("✓");
|
|
327
|
+
indicator.setText(` ${mark} ${brand.tool(event.toolName)}`);
|
|
328
|
+
tui.requestRender();
|
|
329
|
+
pendingTools.delete(event.toolCallId);
|
|
330
|
+
}
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
case "turn_end": {
|
|
334
|
+
const usage = event.message?.usage;
|
|
335
|
+
if (usage) {
|
|
336
|
+
totalIn += usage.input ?? 0;
|
|
337
|
+
totalOut += usage.output ?? 0;
|
|
338
|
+
totalCost += usage.cost ?? 0;
|
|
339
|
+
updateHeader();
|
|
340
|
+
}
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
case "compaction_start": {
|
|
344
|
+
// Pi auto-compacts when context fills. Surface it so the user
|
|
345
|
+
// understands the brief pause + the assistant message that
|
|
346
|
+
// follows comes from a fresh summary.
|
|
347
|
+
const usage = session.getContextUsage();
|
|
348
|
+
const pct = usage?.percent != null ? `${Math.round(usage.percent)}%` : "high";
|
|
349
|
+
insertBeforeEditor(new Text(` ${brand.dim(`⚡ compacting context (was ${pct})…`)}`, 0, 0));
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
case "compaction_end": {
|
|
353
|
+
const ev = event;
|
|
354
|
+
if (ev.aborted) {
|
|
355
|
+
insertBeforeEditor(new Text(` ${brand.dim("compaction aborted")}`, 0, 0));
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
// Pi's getContextUsage returns null right after compaction by
|
|
359
|
+
// design — token estimates need a fresh LLM response to
|
|
360
|
+
// recalculate. Show that explicitly instead of a confusing "?".
|
|
361
|
+
const after = session.getContextUsage();
|
|
362
|
+
const afterStr = after?.percent != null
|
|
363
|
+
? `usage now ${Math.round(after.percent)}%`
|
|
364
|
+
: "usage refreshes after your next message";
|
|
365
|
+
insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim(`compacted · ${afterStr}`)}`, 0, 0));
|
|
366
|
+
}
|
|
367
|
+
updateHeader();
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
case "agent_end": {
|
|
371
|
+
isAgentRunning = false;
|
|
372
|
+
editor.disableSubmit = false;
|
|
373
|
+
activeAssistant = null;
|
|
374
|
+
if (activeLoader) {
|
|
375
|
+
removeChild(activeLoader);
|
|
376
|
+
activeLoader = null;
|
|
377
|
+
}
|
|
378
|
+
// Last-resort safety net: if no text was ever rendered (no message_update,
|
|
379
|
+
// no message_end), surface the final message text or any error message
|
|
380
|
+
// from the agent end event. Without this, a bad request leaves the user
|
|
381
|
+
// staring at "thinking…" gone-but-no-reply. Provider errors arrive as
|
|
382
|
+
// nested JSON blobs (Pi wraps the upstream response) — cleanProviderError
|
|
383
|
+
// peels them down to a single human-readable line.
|
|
384
|
+
const messages = event.messages;
|
|
385
|
+
if (Array.isArray(messages) && messages.length > 0) {
|
|
386
|
+
const last = messages[messages.length - 1];
|
|
387
|
+
if (last && last.role === "assistant") {
|
|
388
|
+
const text = extractAssistantText(last);
|
|
389
|
+
const errMsg = last.errorMessage;
|
|
390
|
+
// Sensitive stop reasons (refusal, content filter, policy block)
|
|
391
|
+
// produce empty content with a meaningful stopReason. Translate
|
|
392
|
+
// to a friendly message — without this the user sees nothing.
|
|
393
|
+
const sensitive = !text ? classifySensitiveStopReason(last) : null;
|
|
394
|
+
if (!text && errMsg) {
|
|
395
|
+
const cleaned = cleanProviderError(errMsg);
|
|
396
|
+
insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(cleaned)}`, 0, 0));
|
|
397
|
+
}
|
|
398
|
+
else if (sensitive) {
|
|
399
|
+
insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(sensitive.userMessage)}`, 0, 0));
|
|
400
|
+
}
|
|
401
|
+
else if (!text && (last.stopReason === "error" || last.stopReason === "aborted")) {
|
|
402
|
+
insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`Agent ended with no reply (${last.stopReason})`)}`, 0, 0));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
updateHeader();
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
case "auto_retry_start": {
|
|
410
|
+
// Pi auto-retries transient provider errors (rate limit, 5xx,
|
|
411
|
+
// connection drop). Tell the user it's happening — without this,
|
|
412
|
+
// a slow retry looks like the model is just hanging.
|
|
413
|
+
const ev = event;
|
|
414
|
+
const attempt = ev.attempt ?? 1;
|
|
415
|
+
const max = ev.maxAttempts ?? 1;
|
|
416
|
+
const waitS = Math.round((ev.delayMs ?? 0) / 100) / 10;
|
|
417
|
+
insertBeforeEditor(new Text(` ${brand.dim(`↻ retrying (attempt ${attempt}/${max}, waiting ${waitS}s)…`)}`, 0, 0));
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
case "auto_retry_end": {
|
|
421
|
+
const ev = event;
|
|
422
|
+
if (ev.success === false) {
|
|
423
|
+
insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`gave up after ${ev.attempt} attempts`)}`, 0, 0));
|
|
424
|
+
}
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
// ── input handling ─────────────────────────────────────────────────
|
|
430
|
+
editor.onSubmit = async (value) => {
|
|
431
|
+
const trimmed = value.trim();
|
|
432
|
+
if (!trimmed)
|
|
433
|
+
return;
|
|
434
|
+
// Mid-turn submit → STEER, not drop. Pi queues the message and the
|
|
435
|
+
// model sees it after the current tool round completes — no abort,
|
|
436
|
+
// no lost context. This is what "steer" means at the loop level and
|
|
437
|
+
// it's a key part of a production-grade chat UX.
|
|
438
|
+
if (isAgentRunning) {
|
|
439
|
+
// Slash commands during a turn are still handled locally — they
|
|
440
|
+
// shouldn't reach the model. (This block intentionally mirrors the
|
|
441
|
+
// post-loop set; if a new local command lands below, mirror it here.)
|
|
442
|
+
if (trimmed === "/exit" || trimmed === "/quit") {
|
|
443
|
+
tui.stop();
|
|
444
|
+
process.exit(0);
|
|
445
|
+
}
|
|
446
|
+
// /model <id> mid-turn → live model switch. Aborts the current
|
|
447
|
+
// turn cleanly, swaps the model, re-prompts with the user's
|
|
448
|
+
// original message. Hot-swap UX; way better than "abort, wait,
|
|
449
|
+
// /model, retype".
|
|
450
|
+
if (trimmed.startsWith("/model ")) {
|
|
451
|
+
editor.setText("");
|
|
452
|
+
const targetId = trimmed.slice("/model ".length).trim();
|
|
453
|
+
const matches = modelRegistry.getAvailable().filter((m) => m.id === targetId);
|
|
454
|
+
const target = matches.find((m) => m.provider === provider) ?? matches[0];
|
|
455
|
+
if (!target) {
|
|
456
|
+
insertBeforeEditor(new Text(` ${brand.error(`✗ no configured model with id "${targetId}". Type /model to list.`)}`, 0, 0));
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
// Find the most recent user message to replay on the new model.
|
|
460
|
+
const lastUser = [...session.messages].reverse().find((m) => m.role === "user");
|
|
461
|
+
const replayMsg = lastUser ? extractUserText(lastUser) : "";
|
|
462
|
+
if (!replayMsg) {
|
|
463
|
+
insertBeforeEditor(new Text(` ${brand.error("✗ no user message to replay on the new model.")}`, 0, 0));
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
insertBeforeEditor(new Text(` ${brand.dim(`↻ aborting current turn, switching to ${targetId}, re-running…`)}`, 0, 0));
|
|
467
|
+
try {
|
|
468
|
+
const swapped = await switchModelMidTurn(session, target, replayMsg);
|
|
469
|
+
if (!swapped) {
|
|
470
|
+
// Turn ended between our `isAgentRunning` check and switchModelMidTurn —
|
|
471
|
+
// the in-flight signal had already cleared. Fall back to a normal
|
|
472
|
+
// post-turn switch using the same code path the post-turn /model uses.
|
|
473
|
+
await switchToModel(target.provider, target.id);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
provider = target.provider;
|
|
477
|
+
modelId = target.id;
|
|
478
|
+
await saveConfig({ defaultProvider: provider, defaultModelId: modelId });
|
|
479
|
+
updateHeader();
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`Switch failed: ${err instanceof Error ? err.message : String(err)}`)}`, 0, 0));
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
editor.setText("");
|
|
487
|
+
session.agent.steer({
|
|
488
|
+
role: "user",
|
|
489
|
+
content: [{ type: "text", text: trimmed }],
|
|
490
|
+
});
|
|
491
|
+
insertBeforeEditor(new Markdown(`${brand.user("you")} ${trimmed}`, 1, 0, markdownTheme));
|
|
492
|
+
insertBeforeEditor(new Text(` ${brand.dim("↳ queued — the model will see this on its next turn")}`, 0, 0));
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
// slash commands (handled locally, never sent to the model)
|
|
496
|
+
if (trimmed === "/exit" || trimmed === "/quit") {
|
|
497
|
+
tui.stop();
|
|
498
|
+
process.exit(0);
|
|
499
|
+
}
|
|
500
|
+
if (trimmed === "/clear") {
|
|
501
|
+
editor.setText("");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (trimmed === "/help") {
|
|
505
|
+
insertBeforeEditor(new Markdown(`${brand.dim("commands")}\n` +
|
|
506
|
+
`- ${chalk.bold("/exit")} or ${chalk.bold("/quit")} — quit Brigade\n` +
|
|
507
|
+
`- ${chalk.bold("/help")} — this list\n` +
|
|
508
|
+
`- ${chalk.bold("/clear")} — clear the input\n` +
|
|
509
|
+
`- ${chalk.bold("/model")} — switch to another configured model\n` +
|
|
510
|
+
`- ${chalk.bold("/model <id>")} — switch directly by model id\n` +
|
|
511
|
+
`- ${chalk.bold("/provider")} — add a new provider mid-session\n` +
|
|
512
|
+
`- ${chalk.bold("/thinking <level>")} — set reasoning effort (off|minimal|low|medium|high|xhigh)\n` +
|
|
513
|
+
`- ${chalk.bold("/compact")} — summarize older turns to free up context\n` +
|
|
514
|
+
`- ${chalk.bold("Ctrl+C")} — abort the current turn\n` +
|
|
515
|
+
`- ${chalk.bold("Ctrl+D")} — quit`, 1, 0, markdownTheme));
|
|
516
|
+
editor.setText("");
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
// /compact — manually trigger Pi's compaction. Auto-compaction runs in
|
|
520
|
+
// the background at high context usage; this lets the user trigger it
|
|
521
|
+
// on demand (or summarize early before a long task).
|
|
522
|
+
if (trimmed === "/compact") {
|
|
523
|
+
editor.setText("");
|
|
524
|
+
const usage = session.getContextUsage();
|
|
525
|
+
if (usage?.percent != null) {
|
|
526
|
+
insertBeforeEditor(new Text(` ${brand.dim(`Compacting (current usage ${Math.round(usage.percent)}%)…`)}`, 0, 0));
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
insertBeforeEditor(new Text(` ${brand.dim("Compacting…")}`, 0, 0));
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
const result = await session.compact();
|
|
533
|
+
// getContextUsage returns null right after compaction (Pi: token
|
|
534
|
+
// estimate needs a fresh LLM response). Show that explicitly.
|
|
535
|
+
const after = session.getContextUsage();
|
|
536
|
+
const afterStr = after?.percent != null
|
|
537
|
+
? `usage now ${Math.round(after.percent)}%`
|
|
538
|
+
: "usage refreshes after your next message";
|
|
539
|
+
const before = result?.tokensBefore ? `${(result.tokensBefore / 1000).toFixed(1)}k` : "?";
|
|
540
|
+
insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim(`Compacted ${before} of older context · ${afterStr}`)}`, 0, 0));
|
|
541
|
+
updateHeader();
|
|
542
|
+
}
|
|
543
|
+
catch (err) {
|
|
544
|
+
insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`Compaction failed: ${err instanceof Error ? err.message : String(err)}`)}`, 0, 0));
|
|
545
|
+
}
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
// /model — list/switch already-configured models. Without args, opens an
|
|
549
|
+
// inline picker over every model with auth set. With args, switches directly
|
|
550
|
+
// (matches by id; if multiple providers expose the same id, prefers the
|
|
551
|
+
// current provider, otherwise the first match).
|
|
552
|
+
if (trimmed === "/model" || trimmed.startsWith("/model ")) {
|
|
553
|
+
editor.setText("");
|
|
554
|
+
const available = modelRegistry.getAvailable();
|
|
555
|
+
if (available.length === 0) {
|
|
556
|
+
insertBeforeEditor(new Text(` ${brand.error("✗ no configured models — try /provider to add one.")}`, 0, 0));
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const arg = trimmed === "/model" ? "" : trimmed.slice("/model ".length).trim();
|
|
560
|
+
if (arg) {
|
|
561
|
+
// Direct switch by id. Prefer current provider on tie.
|
|
562
|
+
const matches = available.filter((m) => m.id === arg);
|
|
563
|
+
const target = matches.find((m) => m.provider === provider) ?? matches[0];
|
|
564
|
+
if (!target) {
|
|
565
|
+
insertBeforeEditor(new Text(` ${brand.error(`✗ no configured model with id "${arg}".`)} ${brand.dim(`Run /model to see the list.`)}`, 0, 0));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
await switchToModel(target.provider, target.id);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
// Inline picker. Sort by current-provider-first, then reasoning, then ctx.
|
|
572
|
+
const sorted = [...available].sort((a, b) => {
|
|
573
|
+
if (a.provider !== b.provider) {
|
|
574
|
+
if (a.provider === provider)
|
|
575
|
+
return -1;
|
|
576
|
+
if (b.provider === provider)
|
|
577
|
+
return 1;
|
|
578
|
+
return a.provider.localeCompare(b.provider);
|
|
579
|
+
}
|
|
580
|
+
if (!!a.reasoning !== !!b.reasoning)
|
|
581
|
+
return a.reasoning ? -1 : 1;
|
|
582
|
+
return (b.contextWindow ?? 0) - (a.contextWindow ?? 0);
|
|
583
|
+
});
|
|
584
|
+
const items = sorted.map((m) => ({
|
|
585
|
+
value: `${m.provider}::${m.id}`,
|
|
586
|
+
label: `${m.id}${m.id === modelId && m.provider === provider ? brand.amber(" (current)") : ""}`,
|
|
587
|
+
description: `${findProvider(m.provider)?.name ?? m.provider} · ${describeModelCapabilities(m)}`,
|
|
588
|
+
_value: { p: m.provider, id: m.id },
|
|
589
|
+
}));
|
|
590
|
+
const picked = await inlinePick("Switch model", items, { primaryWidth: [22, 38] });
|
|
591
|
+
if (!picked)
|
|
592
|
+
return;
|
|
593
|
+
if (picked.p === provider && picked.id === modelId) {
|
|
594
|
+
insertBeforeEditor(new Text(` ${brand.dim("already on that model.")}`, 0, 0));
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
await switchToModel(picked.p, picked.id);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
// /provider — add a new provider mid-session. Picks the unconfigured ones,
|
|
601
|
+
// runs the same key-entry / Ollama-discovery code as onboarding, and (on
|
|
602
|
+
// success) auto-switches to the first model from that provider.
|
|
603
|
+
if (trimmed === "/provider") {
|
|
604
|
+
editor.setText("");
|
|
605
|
+
// Show only providers we don't already have credentials/registration for.
|
|
606
|
+
const configuredProviders = new Set(modelRegistry.getAvailable().map((m) => m.provider));
|
|
607
|
+
const candidates = PROVIDERS.filter((p) => !configuredProviders.has(p.id));
|
|
608
|
+
if (candidates.length === 0) {
|
|
609
|
+
insertBeforeEditor(new Text(` ${brand.dim("all curated providers are already configured. Use /model to switch.")}`, 0, 0));
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const items = candidates.map((p) => ({
|
|
613
|
+
value: p.id,
|
|
614
|
+
label: p.name,
|
|
615
|
+
description: p.description,
|
|
616
|
+
_value: p,
|
|
617
|
+
}));
|
|
618
|
+
const picked = await inlinePick("Add a provider", items, { primaryWidth: [18, 22] });
|
|
619
|
+
if (!picked)
|
|
620
|
+
return;
|
|
621
|
+
// Three paths now:
|
|
622
|
+
// - custom: user supplies provider id + baseUrl + apiKey + a model id
|
|
623
|
+
// - local (ollama): discover models via /api/tags
|
|
624
|
+
// - remote: just collect API key + use Pi's catalog
|
|
625
|
+
if (picked.custom) {
|
|
626
|
+
const providerId = await inlinePrompt("Give this connection a short name", "Lowercase letters and dashes only — for example: together, fireworks, on-prem.");
|
|
627
|
+
if (!providerId)
|
|
628
|
+
return;
|
|
629
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(providerId)) {
|
|
630
|
+
insertBeforeEditor(new Text(` ${brand.error("✗ Use lowercase letters, numbers, and dashes only.")}`, 0, 0));
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const baseUrl = await inlinePrompt("Endpoint URL", "The OpenAI-compatible URL — for example: https://api.together.xyz/v1");
|
|
634
|
+
if (!baseUrl)
|
|
635
|
+
return;
|
|
636
|
+
if (!/^https?:\/\//i.test(baseUrl)) {
|
|
637
|
+
insertBeforeEditor(new Text(` ${brand.error("✗ The URL should start with https:// or http://.")}`, 0, 0));
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const apiKey = await inlinePrompt("API key", "Type \"none\" if your endpoint doesn't require one.");
|
|
641
|
+
if (!apiKey)
|
|
642
|
+
return;
|
|
643
|
+
const oneModelId = await inlinePrompt("Model name", "Pick the model you'd like to use first — for example: meta-llama/Llama-3.3-70B-Instruct-Turbo");
|
|
644
|
+
if (!oneModelId)
|
|
645
|
+
return;
|
|
646
|
+
// Confirmation step — show the user a summary of what we're about
|
|
647
|
+
// to save before we write anything. Catches typos in the URL or
|
|
648
|
+
// model id before they cause failed requests later.
|
|
649
|
+
const maskedKey = apiKey.length > 8
|
|
650
|
+
? `${apiKey.slice(0, 4)}…${apiKey.slice(-4)}`
|
|
651
|
+
: apiKey === "none"
|
|
652
|
+
? "none"
|
|
653
|
+
: "(hidden)";
|
|
654
|
+
insertBeforeEditor(new Text(` ${brand.amber("Review:")}`, 0, 0));
|
|
655
|
+
insertBeforeEditor(new Text(` ${brand.dim("Name:")} ${brand.white(providerId)}`, 0, 0));
|
|
656
|
+
insertBeforeEditor(new Text(` ${brand.dim("Endpoint:")} ${brand.white(baseUrl)}`, 0, 0));
|
|
657
|
+
insertBeforeEditor(new Text(` ${brand.dim("API key:")} ${brand.white(maskedKey)}`, 0, 0));
|
|
658
|
+
insertBeforeEditor(new Text(` ${brand.dim("Model:")} ${brand.white(oneModelId)}`, 0, 0));
|
|
659
|
+
const confirmed = await inlinePick("Save this connection?", [
|
|
660
|
+
{ value: "save", label: "Yes, save and switch", description: "Adds this connection and switches to it now", _value: true },
|
|
661
|
+
{ value: "cancel", label: "No, cancel", description: "Discard everything you typed", _value: false },
|
|
662
|
+
], { primaryWidth: [22, 28] });
|
|
663
|
+
if (!confirmed) {
|
|
664
|
+
insertBeforeEditor(new Text(` ${brand.dim("Cancelled — nothing was saved.")}`, 0, 0));
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
// Write the custom provider into models.json. We write a single model
|
|
668
|
+
// with conservative defaults; the user can extend the entry by hand
|
|
669
|
+
// for additional models. Reasoning is heuristic — set true if the id
|
|
670
|
+
// hints at a reasoning model.
|
|
671
|
+
const guessReasoning = /o[13]\b/i.test(oneModelId) || /r1\b/i.test(oneModelId) || /think/i.test(oneModelId) || /qwen[23]/i.test(oneModelId);
|
|
672
|
+
const fs = await import("node:fs/promises");
|
|
673
|
+
const modelsPath = path.join(BRIGADE_DIR, "models.json");
|
|
674
|
+
let existing = { providers: {} };
|
|
675
|
+
try {
|
|
676
|
+
existing = JSON.parse(await fs.readFile(modelsPath, "utf8"));
|
|
677
|
+
if (!existing.providers)
|
|
678
|
+
existing.providers = {};
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
/* file missing — start fresh */
|
|
682
|
+
}
|
|
683
|
+
existing.providers[providerId] = {
|
|
684
|
+
baseUrl: baseUrl.replace(/\/$/, ""),
|
|
685
|
+
api: "openai-completions",
|
|
686
|
+
apiKey,
|
|
687
|
+
models: [
|
|
688
|
+
{
|
|
689
|
+
id: oneModelId,
|
|
690
|
+
name: oneModelId,
|
|
691
|
+
reasoning: guessReasoning,
|
|
692
|
+
input: ["text"],
|
|
693
|
+
contextWindow: 32_768,
|
|
694
|
+
maxTokens: 8_192,
|
|
695
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
696
|
+
},
|
|
697
|
+
],
|
|
698
|
+
};
|
|
699
|
+
try {
|
|
700
|
+
await fs.writeFile(modelsPath, JSON.stringify(existing, null, 2), "utf8");
|
|
701
|
+
modelRegistry.refresh();
|
|
702
|
+
}
|
|
703
|
+
catch (err) {
|
|
704
|
+
insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`Couldn't save the connection: ${err instanceof Error ? err.message : String(err)}`)}`, 0, 0));
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim(`Connected ${providerId} · switching to ${oneModelId}…`)}`, 0, 0));
|
|
708
|
+
await switchToModel(providerId, oneModelId);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (picked.local && picked.id === "ollama") {
|
|
712
|
+
const baseUrl = picked.baseUrl ?? "http://localhost:11434";
|
|
713
|
+
insertBeforeEditor(new Text(` ${brand.dim("Scanning Ollama…")}`, 0, 0));
|
|
714
|
+
let discovered;
|
|
715
|
+
try {
|
|
716
|
+
discovered = await discoverOllamaModels(baseUrl);
|
|
717
|
+
}
|
|
718
|
+
catch (err) {
|
|
719
|
+
insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(err instanceof Error ? err.message : String(err))}`, 0, 0));
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
try {
|
|
723
|
+
await writeOllamaToModelsJson(path.join(BRIGADE_DIR, "models.json"), baseUrl, discovered);
|
|
724
|
+
modelRegistry.refresh();
|
|
725
|
+
}
|
|
726
|
+
catch (err) {
|
|
727
|
+
insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`Couldn't save the connection: ${err instanceof Error ? err.message : String(err)}`)}`, 0, 0));
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim(`Ollama connected · ${discovered.length} model${discovered.length === 1 ? "" : "s"} ready. Switching…`)}`, 0, 0));
|
|
731
|
+
// Auto-switch to the first discovered model.
|
|
732
|
+
if (discovered[0])
|
|
733
|
+
await switchToModel("ollama", discovered[0].id);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
// Remote provider — collect API key inline, validate, save.
|
|
737
|
+
const key = await inlinePrompt(`Paste your ${picked.name} API key`, `We'll keep it private to this device. Need a key? ${picked.keyUrl}`);
|
|
738
|
+
if (!key)
|
|
739
|
+
return;
|
|
740
|
+
if (key.length < 16 || /\s/.test(key)) {
|
|
741
|
+
insertBeforeEditor(new Text(` ${brand.error("✗ That doesn't look right — try copying the key again.")}`, 0, 0));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const validating = new CancellableLoader(tui, (s) => brand.amber(s), (s) => brand.dim(s), `Connecting to ${picked.name}…`);
|
|
745
|
+
insertBeforeEditor(validating);
|
|
746
|
+
const onlineCheck = await validateApiKeyOnline(picked.id, key);
|
|
747
|
+
removeChild(validating);
|
|
748
|
+
if (!onlineCheck.ok) {
|
|
749
|
+
insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(onlineCheck.reason)}`, 0, 0));
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
authStorage.set(picked.id, { type: "api_key", key });
|
|
753
|
+
authStorage.reload();
|
|
754
|
+
modelRegistry.refresh();
|
|
755
|
+
// Auto-switch to a sensible default model from the new provider —
|
|
756
|
+
// reasoning-first, then largest context window.
|
|
757
|
+
const newModels = modelRegistry.getAvailable().filter((m) => m.provider === picked.id);
|
|
758
|
+
if (newModels.length === 0) {
|
|
759
|
+
insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim(`${picked.name} connected, but no models are available right now. Try /model later.`)}`, 0, 0));
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const sorted = [...newModels].sort((a, b) => {
|
|
763
|
+
if (!!a.reasoning !== !!b.reasoning)
|
|
764
|
+
return a.reasoning ? -1 : 1;
|
|
765
|
+
return (b.contextWindow ?? 0) - (a.contextWindow ?? 0);
|
|
766
|
+
});
|
|
767
|
+
insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim(`${picked.name} connected · switching to ${sorted[0].id}…`)}`, 0, 0));
|
|
768
|
+
await switchToModel(picked.id, sorted[0].id);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
// /thinking [level] — reads or sets the model's reasoning effort.
|
|
772
|
+
// Pi clamps to model capabilities (e.g. "off" gets coerced for non-reasoning
|
|
773
|
+
// models), so we can pass through the user's choice and let Pi decide what's
|
|
774
|
+
// actually applicable. Without args, reports current state + valid options.
|
|
775
|
+
if (trimmed === "/thinking" || trimmed.startsWith("/thinking ")) {
|
|
776
|
+
editor.setText("");
|
|
777
|
+
if (!session.supportsThinking()) {
|
|
778
|
+
insertBeforeEditor(new Text(` ${brand.dim(`This model (${modelId}) does not support thinking — nothing to set.`)}`, 0, 0));
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const arg = trimmed === "/thinking" ? "" : trimmed.slice("/thinking ".length).trim().toLowerCase();
|
|
782
|
+
const available = session.getAvailableThinkingLevels();
|
|
783
|
+
if (!arg) {
|
|
784
|
+
insertBeforeEditor(new Text(` ${brand.dim("thinking is")} ${brand.amber(session.thinkingLevel)} ${brand.dim("· available:")} ${brand.dim(available.join(" "))}`, 0, 0));
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (!VALID_THINKING_LEVELS.includes(arg)) {
|
|
788
|
+
insertBeforeEditor(new Text(` ${brand.error(`✗ unknown level "${arg}".`)} ${brand.dim(`Try one of: ${available.join(", ")}`)}`, 0, 0));
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (!available.includes(arg)) {
|
|
792
|
+
insertBeforeEditor(new Text(` ${brand.error(`✗ "${arg}" is not supported on ${modelId}.`)} ${brand.dim(`Available: ${available.join(", ")}`)}`, 0, 0));
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
session.setThinkingLevel(arg);
|
|
796
|
+
updateHeader();
|
|
797
|
+
insertBeforeEditor(new Text(` ${brand.amber("✓")} ${brand.dim("thinking set to")} ${brand.amber(arg)}`, 0, 0));
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
// echo user message
|
|
801
|
+
insertBeforeEditor(new Markdown(`${brand.user("you")} ${trimmed}`, 1, 0, markdownTheme));
|
|
802
|
+
editor.setText("");
|
|
803
|
+
try {
|
|
804
|
+
// Build the fallback chain from config. We accept ONE fallback in
|
|
805
|
+
// BrigadeConfig today; future versions can extend to an array.
|
|
806
|
+
const cfg = await loadConfig();
|
|
807
|
+
const fallbackModel = cfg.fallbackProvider && cfg.fallbackModelId
|
|
808
|
+
? modelRegistry.find(cfg.fallbackProvider, cfg.fallbackModelId)
|
|
809
|
+
: undefined;
|
|
810
|
+
// Compose the loop wrappers from the inside out:
|
|
811
|
+
// 1. session.prompt() — Pi's actual loop
|
|
812
|
+
// 2. runWithStreamTimeout() — aborts if the loop goes silent for 60s
|
|
813
|
+
// 3. runWithFallback() — on hard error, walks the fallback chain
|
|
814
|
+
// Order matters: timeout wraps the prompt INSIDE fallback, so each
|
|
815
|
+
// fallback attempt gets its own fresh 60s watcher.
|
|
816
|
+
await runWithFallback(session, trimmed, {
|
|
817
|
+
fallbacks: fallbackModel ? [{ model: fallbackModel }] : [],
|
|
818
|
+
// Per-attempt wrappers. Composition (outer → inner):
|
|
819
|
+
// 1. runWithHeartbeat — every 30s of silence, show "still
|
|
820
|
+
// working… Ns elapsed" so the user
|
|
821
|
+
// knows we're alive (esp. local 30B
|
|
822
|
+
// models that take minutes per turn)
|
|
823
|
+
// 2. runWithStreamTimeout — abort after per-provider idle threshold
|
|
824
|
+
// (60s cloud / 5min Ollama)
|
|
825
|
+
// 3. runWithContentQualityRetry — re-prompt with a steer if the model
|
|
826
|
+
// returned empty / reasoning-only /
|
|
827
|
+
// planning-only output
|
|
828
|
+
// 4. runWithThinkingFallback — auto-downgrade thinking on rejection
|
|
829
|
+
// 5. session.prompt — Pi's actual loop
|
|
830
|
+
wrapAttempt: (promptFn) => runWithHeartbeat(session, () => runWithStreamTimeout(session, () => runWithLengthContinuation(session, () => runWithContentQualityRetry(session, () => runWithThinkingFallback(session, promptFn, {
|
|
831
|
+
onDowngrade: (originalLevel) => {
|
|
832
|
+
insertBeforeEditor(new Text(` ${brand.dim(`This model doesn't support thinking — switching from ${originalLevel} to off and retrying…`)}`, 0, 0));
|
|
833
|
+
},
|
|
834
|
+
}), {
|
|
835
|
+
onRetry: (reason) => {
|
|
836
|
+
const label = reason === "empty"
|
|
837
|
+
? "no visible answer — re-prompting"
|
|
838
|
+
: reason === "reasoning-only"
|
|
839
|
+
? "model emitted only reasoning — asking for visible answer"
|
|
840
|
+
: "model described an action but didn't take it — asking it to actually do it";
|
|
841
|
+
insertBeforeEditor(new Text(` ${brand.dim(`↻ ${label}…`)}`, 0, 0));
|
|
842
|
+
},
|
|
843
|
+
}), {
|
|
844
|
+
onContinue: () => {
|
|
845
|
+
insertBeforeEditor(new Text(` ${brand.dim("↻ reply was truncated — asking the model to continue…")}`, 0, 0));
|
|
846
|
+
},
|
|
847
|
+
}), {
|
|
848
|
+
// Per-provider timeout. Cloud non-reasoning: 60s; cloud
|
|
849
|
+
// reasoning: 180s; Ollama: 5min; custom: 3min.
|
|
850
|
+
idleMs: session.model ? pickStreamIdleMs(session.model) : 60_000,
|
|
851
|
+
onTimeout: (ms) => {
|
|
852
|
+
insertBeforeEditor(new Text(` ${brand.dim(`⏳ no response for ${Math.round(ms / 1000)}s — aborting…`)}`, 0, 0));
|
|
853
|
+
},
|
|
854
|
+
}), {
|
|
855
|
+
intervalMs: 30_000,
|
|
856
|
+
onHeartbeat: (ms) => {
|
|
857
|
+
const sec = Math.round(ms / 1000);
|
|
858
|
+
insertBeforeEditor(new Text(` ${brand.dim(`still working… ${sec}s elapsed`)}`, 0, 0));
|
|
859
|
+
},
|
|
860
|
+
}),
|
|
861
|
+
onFallback: (reason) => {
|
|
862
|
+
insertBeforeEditor(new Text(` ${brand.dim(`↻ primary failed (${cleanProviderError(reason)}) — trying ${cfg.fallbackModelId}…`)}`, 0, 0));
|
|
863
|
+
},
|
|
864
|
+
onFallbackExhausted: (reason) => {
|
|
865
|
+
insertBeforeEditor(new Text(` ${brand.error("✗ all fallback models failed:")} ${brand.error(cleanProviderError(reason))}`, 0, 0));
|
|
866
|
+
},
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
catch (err) {
|
|
870
|
+
// Provider errors arrive here when the request itself throws (vs being
|
|
871
|
+
// captured into an assistant message and surfaced via agent_end). They're
|
|
872
|
+
// often nested-JSON blobs — clean them before showing.
|
|
873
|
+
//
|
|
874
|
+
// We reset isAgentRunning + disableSubmit defensively because some error
|
|
875
|
+
// paths fire BEFORE `agent_start` (e.g. sync auth-resolver failures), so
|
|
876
|
+
// the `agent_end` event handler never runs. Re-assigning false when it's
|
|
877
|
+
// already false is a no-op, so this is safe even when agent_end did fire.
|
|
878
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
879
|
+
const msg = cleanProviderError(raw);
|
|
880
|
+
insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(msg)}`, 0, 0));
|
|
881
|
+
isAgentRunning = false;
|
|
882
|
+
editor.disableSubmit = false;
|
|
883
|
+
updateHeader();
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
// NOTE: tui.start() is called once in index.ts BEFORE splash + onboarding,
|
|
887
|
+
// so the renderer is already alive by the time we get here. Don't start twice.
|
|
888
|
+
// Likewise, the SIGINT handler is wired ONCE at the top level (index.ts) and
|
|
889
|
+
// delegates here via the returned ChatHandle — preventing handler stacking.
|
|
890
|
+
tui.requestRender();
|
|
891
|
+
return {
|
|
892
|
+
abort: () => {
|
|
893
|
+
if (!isAgentRunning)
|
|
894
|
+
return false;
|
|
895
|
+
session.abort().catch(() => { });
|
|
896
|
+
isAgentRunning = false;
|
|
897
|
+
editor.disableSubmit = false;
|
|
898
|
+
if (activeLoader) {
|
|
899
|
+
removeChild(activeLoader);
|
|
900
|
+
activeLoader = null;
|
|
901
|
+
}
|
|
902
|
+
insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.dim("aborted")}`, 0, 0));
|
|
903
|
+
updateHeader();
|
|
904
|
+
return true;
|
|
905
|
+
},
|
|
906
|
+
isRunning: () => isAgentRunning,
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
/* ────────────────────────────── helpers ──────────────────────────────── */
|
|
910
|
+
function formatToolArgs(name, args) {
|
|
911
|
+
if (!args || typeof args !== "object")
|
|
912
|
+
return "";
|
|
913
|
+
if (args.path)
|
|
914
|
+
return String(args.path);
|
|
915
|
+
if (args.command)
|
|
916
|
+
return String(args.command).slice(0, 60);
|
|
917
|
+
if (args.pattern)
|
|
918
|
+
return String(args.pattern);
|
|
919
|
+
if (args.query)
|
|
920
|
+
return `"${String(args.query)}"`;
|
|
921
|
+
const keys = Object.keys(args);
|
|
922
|
+
if (keys.length === 0)
|
|
923
|
+
return "";
|
|
924
|
+
return keys
|
|
925
|
+
.slice(0, 2)
|
|
926
|
+
.map((k) => `${k}=${JSON.stringify(args[k]).slice(0, 30)}`)
|
|
927
|
+
.join(" ");
|
|
928
|
+
}
|
|
929
|
+
//# sourceMappingURL=chat.js.map
|