pi-bro 0.25.1 → 0.27.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 +50 -0
- package/README.md +18 -11
- package/docs/README.md +2 -0
- package/docs/getting-started.md +2 -2
- package/docs/grill-me.md +63 -0
- package/docs/guided-review.md +4 -4
- package/docs/reference/commands.md +2 -0
- package/docs/reference/data-and-permissions.md +17 -3
- package/docs/reference/settings.md +11 -2
- package/docs/reference/troubleshooting.md +5 -0
- package/docs/remember-me.md +90 -0
- package/package.json +14 -4
- package/src/advisor.ts +4 -2
- package/src/backend.ts +511 -11
- package/src/bro.ts +37 -0
- package/src/btw.ts +55 -9
- package/src/config-ui.ts +149 -51
- package/src/doctor.ts +142 -3
- package/src/explain-modal.ts +6 -2
- package/src/grill-engine.ts +116 -0
- package/src/grill.ts +312 -0
- package/src/remember-engine.ts +141 -0
- package/src/remember-store.ts +193 -0
- package/src/remember-tool.ts +22 -0
- package/src/remember.ts +294 -0
- package/src/settings.ts +85 -6
package/src/bro.ts
CHANGED
|
@@ -8,7 +8,12 @@ import {
|
|
|
8
8
|
resolveBtwThread,
|
|
9
9
|
} from "./btw.ts";
|
|
10
10
|
import { doctorReport, showBroConfigModal } from "./doctor.ts";
|
|
11
|
+
import { setPiHostContext } from "./backend.ts";
|
|
11
12
|
import { openElevateModal } from "./elevate.ts";
|
|
13
|
+
import { openGrillModal } from "./grill.ts";
|
|
14
|
+
import { openRememberModal } from "./remember.ts";
|
|
15
|
+
import { registerAboutUserTool } from "./remember-tool.ts";
|
|
16
|
+
import { parseGrillArguments, type GrillThread } from "./grill-engine.ts";
|
|
12
17
|
import {
|
|
13
18
|
type BroResult,
|
|
14
19
|
type ModalResult,
|
|
@@ -56,6 +61,10 @@ export * from "./doctor.ts";
|
|
|
56
61
|
export * from "./btw.ts";
|
|
57
62
|
export * from "./elevate.ts";
|
|
58
63
|
export * from "./elevate-engine.ts";
|
|
64
|
+
export * from "./grill.ts";
|
|
65
|
+
export * from "./grill-engine.ts";
|
|
66
|
+
export * from "./remember-store.ts";
|
|
67
|
+
export * from "./remember-engine.ts";
|
|
59
68
|
export * from "./explain-modal.ts";
|
|
60
69
|
|
|
61
70
|
const COMMANDS = [
|
|
@@ -71,6 +80,8 @@ const COMMANDS = [
|
|
|
71
80
|
{ value: "guided-review", label: "guided-review", description: "Understand and assess a PR with captured code and optional private questions" },
|
|
72
81
|
{ value: "btw", label: "btw", description: "Open a side conversation (starts conversation-only; /mode toggles full permission)" },
|
|
73
82
|
{ value: "elevate", label: "elevate", description: "Discover missing expert considerations, then improve a request" },
|
|
83
|
+
{ value: "remember-me", label: "remember-me", description: "Help write and review personal memories; agents can unfold them on demand" },
|
|
84
|
+
{ value: "grill-me", label: "grill-me", description: "A private, one-question-at-a-time decision interview" },
|
|
74
85
|
{ value: "config", label: "config", description: "Configure shared defaults and per-capability model/effort overrides" },
|
|
75
86
|
{ value: "advisor", label: "advisor", description: "Check whether the executor's advisor tool is available right now" },
|
|
76
87
|
{ value: "advisor-steer", label: "advisor-steer", description: "View, edit, save, or clear the advisor's persistent steering brief" },
|
|
@@ -82,6 +93,7 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
82
93
|
let lastResult: ModalResult | undefined;
|
|
83
94
|
let lastShowSteering: string | undefined;
|
|
84
95
|
let btwThread: BtwThread | undefined;
|
|
96
|
+
let grillThread: GrillThread | undefined;
|
|
85
97
|
const remember = (result: ModalResult, showSteering?: string) => {
|
|
86
98
|
if (result.source) {
|
|
87
99
|
lastResult = result;
|
|
@@ -94,9 +106,11 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
94
106
|
pi.on("session_start", async (_event, _ctx) => {
|
|
95
107
|
lastResult = undefined;
|
|
96
108
|
btwThread = undefined;
|
|
109
|
+
grillThread = undefined;
|
|
97
110
|
});
|
|
98
111
|
|
|
99
112
|
registerAdvisorTool(pi);
|
|
113
|
+
registerAboutUserTool(pi);
|
|
100
114
|
|
|
101
115
|
pi.registerCommand("bro", {
|
|
102
116
|
description: "Explain text, documents, and webpages; draw session turns; open a side conversation; configure Bro and the executor's advisor tool",
|
|
@@ -106,6 +120,8 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
106
120
|
return matches.length ? matches : null;
|
|
107
121
|
},
|
|
108
122
|
handler: async (args, ctx) => {
|
|
123
|
+
// Publish the host registry so the in-process Pi backend can resolve models and runtime keys.
|
|
124
|
+
setPiHostContext(ctx);
|
|
109
125
|
const raw = args.trim();
|
|
110
126
|
const normalized = raw.toLowerCase();
|
|
111
127
|
const parts = normalized ? normalized.split(/\s+/) : [];
|
|
@@ -285,6 +301,25 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
285
301
|
return;
|
|
286
302
|
}
|
|
287
303
|
|
|
304
|
+
if (action === "remember-me") {
|
|
305
|
+
if (!hasBroCustomUi(ctx)) { ctx.ui.notify("Use /bro remember-me in Pi interactive UI.", "warning"); return; }
|
|
306
|
+
try { await openRememberModal(ctx, value); }
|
|
307
|
+
catch (error) { ctx.ui.notify(withDoctor(error), "error"); }
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (action === "grill-me") {
|
|
312
|
+
if (!hasBroCustomUi(ctx)) { ctx.ui.notify("Use /bro grill-me in Pi's interactive UI.", "warning"); return; }
|
|
313
|
+
const parsed = parseGrillArguments(value);
|
|
314
|
+
if (parsed.invalid) { ctx.ui.notify(parsed.invalid, "warning"); return; }
|
|
315
|
+
const start = Boolean(parsed.topic);
|
|
316
|
+
if (start) grillThread = { topic: parsed.topic, depth: parsed.depth, turns: [], fullAccess: false };
|
|
317
|
+
else grillThread ??= { topic: "", depth: parsed.depth, turns: [], fullAccess: false };
|
|
318
|
+
try { await openGrillModal(ctx, grillThread, { start }); }
|
|
319
|
+
catch (error) { ctx.ui.notify(withDoctor(error), "error"); }
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
288
323
|
if (action === "elevate") {
|
|
289
324
|
if (!hasBroCustomUi(ctx)) { ctx.ui.notify("Use /bro elevate in Pi interactive UI.", "warning"); return; }
|
|
290
325
|
try { await openElevateModal(ctx, value); }
|
|
@@ -438,6 +473,8 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
438
473
|
description: "Bro Elevate: improve the current editor draft",
|
|
439
474
|
handler: async (ctx) => {
|
|
440
475
|
if (!hasBroCustomUi(ctx)) { ctx.ui.notify("Elevate requires Pi interactive UI.", "warning"); return; }
|
|
476
|
+
// The shortcut bypasses the /bro handler, so publish the registry here for the Pi backend.
|
|
477
|
+
setPiHostContext(ctx);
|
|
441
478
|
try { await openElevateModal(ctx as import("@earendil-works/pi-coding-agent").ExtensionCommandContext); }
|
|
442
479
|
catch (error) { ctx.ui.notify(withDoctor(error), "error"); }
|
|
443
480
|
},
|
package/src/btw.ts
CHANGED
|
@@ -75,11 +75,13 @@ export function toggleBtwMode(thread: BtwThread): void {
|
|
|
75
75
|
// Claude, Grok, Codex, and Muse run btw in the workspace for both modes and resume one native session across
|
|
76
76
|
// /mode switches. An Agy conversation stays bound to the workspace it started in (a sandbox scratch
|
|
77
77
|
// dir vs. the repo), so an access change drops it; the next turn reseeds a fresh native session
|
|
78
|
-
// with the main-session context and the whole thread.
|
|
78
|
+
// with the main-session context and the whole thread. Pi behaves like Agy here: its restricted mode
|
|
79
|
+
// exposes no tools at all while full access uses read/bash/edit/write, so an access change must not
|
|
80
|
+
// continue a session whose tool loadout is now wrong.
|
|
79
81
|
// A native session remembers every earlier prompt, including old preferences, so a preferences
|
|
80
82
|
// change (or deletion) on any backend starts a fresh session reseeded with the quoted thread.
|
|
81
83
|
export function nativeBtwContinuation(thread: BtwThread, backend: BackendName, preferences = ""): string | undefined {
|
|
82
|
-
if (backend === "agy" && thread.conversationId && thread.sessionFull !== undefined && thread.sessionFull !== thread.full) {
|
|
84
|
+
if ((backend === "agy" || backend === "pi") && thread.conversationId && thread.sessionFull !== undefined && thread.sessionFull !== thread.full) {
|
|
83
85
|
thread.conversationId = undefined;
|
|
84
86
|
}
|
|
85
87
|
if (thread.conversationId && (thread.sessionPreferences ?? "") !== preferences) thread.conversationId = undefined;
|
|
@@ -129,13 +131,15 @@ export function parseBtwComposerCommand(value: string): BtwComposerAction {
|
|
|
129
131
|
// existing throw-on-failure / { text, conversationId } contract. A conversationId is only ever
|
|
130
132
|
// returned on success (see backend.ts's continuation handling), preserving current behavior on
|
|
131
133
|
// failed turns.
|
|
134
|
+
// An expired continuation is reported as `continuationExpired` so the caller can forget the dead id;
|
|
135
|
+
// otherwise every later turn would resend it and the thread would stay stuck.
|
|
132
136
|
export async function runBtwTurn(
|
|
133
137
|
prompt: string,
|
|
134
138
|
selection: BackendSelection,
|
|
135
139
|
options: { full: boolean; cwd: string; conversationId?: string },
|
|
136
140
|
signal: AbortSignal,
|
|
137
141
|
onProgress?: (text: string) => void,
|
|
138
|
-
): Promise<{ text: string; conversationId?: string }> {
|
|
142
|
+
): Promise<{ text: string; conversationId?: string; expiredContinuation?: true }> {
|
|
139
143
|
let updateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
140
144
|
let latest: string | undefined;
|
|
141
145
|
const throttledProgress = onProgress
|
|
@@ -165,6 +169,7 @@ export async function runBtwTurn(
|
|
|
165
169
|
throttledProgress,
|
|
166
170
|
);
|
|
167
171
|
if (outcome.status === "success") return { text: outcome.text, conversationId: outcome.continuation?.id };
|
|
172
|
+
if (outcome.continuationExpired) return { text: "", expiredContinuation: true };
|
|
168
173
|
throw new Error(outcome.message);
|
|
169
174
|
} finally {
|
|
170
175
|
if (updateTimer) clearTimeout(updateTimer);
|
|
@@ -189,8 +194,16 @@ export class BtwModal implements Focusable {
|
|
|
189
194
|
private running = false;
|
|
190
195
|
private full = false;
|
|
191
196
|
private model = "";
|
|
197
|
+
private customMode = "";
|
|
192
198
|
private disposed = false;
|
|
193
199
|
onInput?: () => void;
|
|
200
|
+
onKey?: (data: string) => boolean;
|
|
201
|
+
private suggestions = "";
|
|
202
|
+
|
|
203
|
+
setSuggestions(text: string): void {
|
|
204
|
+
this.suggestions = text;
|
|
205
|
+
this.tui.requestRender();
|
|
206
|
+
}
|
|
194
207
|
|
|
195
208
|
get focused(): boolean {
|
|
196
209
|
return this._focused;
|
|
@@ -207,6 +220,7 @@ export class BtwModal implements Focusable {
|
|
|
207
220
|
private readonly onClose: () => void,
|
|
208
221
|
private readonly onSubmit: (value: string) => void,
|
|
209
222
|
private readonly onDispose: () => void,
|
|
223
|
+
private readonly appearance: { title?: string; commands?: string; inputLabel?: string; idleHint?: string } = {},
|
|
210
224
|
) {
|
|
211
225
|
this.input.onSubmit = (value) => {
|
|
212
226
|
if (!this.running) this.onSubmit(value);
|
|
@@ -255,6 +269,12 @@ export class BtwModal implements Focusable {
|
|
|
255
269
|
this.tui.requestRender();
|
|
256
270
|
}
|
|
257
271
|
|
|
272
|
+
/** Reuse the BTW modal for independent interview flows without sharing BTW state. */
|
|
273
|
+
setCustomMode(label: string): void {
|
|
274
|
+
this.customMode = label;
|
|
275
|
+
this.tui.requestRender();
|
|
276
|
+
}
|
|
277
|
+
|
|
258
278
|
clearComposer(): void {
|
|
259
279
|
this.input.setValue("");
|
|
260
280
|
this.tui.requestRender();
|
|
@@ -280,6 +300,7 @@ export class BtwModal implements Focusable {
|
|
|
280
300
|
}
|
|
281
301
|
|
|
282
302
|
handleInput(data: string): void {
|
|
303
|
+
if (this.onKey?.(data)) return;
|
|
283
304
|
if (matchesKey(data, "escape")) {
|
|
284
305
|
this.onClose();
|
|
285
306
|
return;
|
|
@@ -298,18 +319,24 @@ export class BtwModal implements Focusable {
|
|
|
298
319
|
header: () => {
|
|
299
320
|
const scroll = this.scroller.indicator();
|
|
300
321
|
const model = this.model ? this.theme.fg("dim", ` · ${this.model}`) : "";
|
|
301
|
-
const mode = this.
|
|
302
|
-
|
|
322
|
+
const mode = this.customMode
|
|
323
|
+
? this.theme.fg("dim", this.customMode)
|
|
324
|
+
: this.theme.fg("dim", " · ") + (this.full ? this.theme.fg("accent", this.theme.bold(btwModeLabel(true))) : this.theme.fg("dim", btwModeLabel(false)));
|
|
325
|
+
const title = this.theme.fg("accent", this.theme.bold(this.appearance.title ?? "Bro · btw"));
|
|
326
|
+
// Keep Grill's access warning ahead of text the frame may truncate.
|
|
327
|
+
const header = this.customMode ? `${mode} · ${title}${model}` : title + model + mode;
|
|
328
|
+
return header + this.theme.fg("dim", scroll ? ` · ${scroll}` : "");
|
|
303
329
|
},
|
|
304
330
|
body: (rows, innerWidth) => this.scroller.view(this.markdown.render(innerWidth), rows),
|
|
305
331
|
// The frame drops lines from the top when space runs out, so the composer goes last.
|
|
306
332
|
footer: [
|
|
307
|
-
...wrapTextWithAnsi("Commands: /mode · /copy · /copy-all · /insert · /insert-all · /clear · /retry", inner),
|
|
308
|
-
this.theme.fg("accent", this.
|
|
333
|
+
...(this.appearance.commands === "" ? [] : wrapTextWithAnsi(this.appearance.commands ?? "Commands: /mode · /copy · /copy-all · /insert · /insert-all · /clear · /retry", inner)),
|
|
334
|
+
...(this.suggestions ? wrapTextWithAnsi(this.theme.fg("accent", this.suggestions), inner) : []),
|
|
335
|
+
this.theme.fg("accent", this.theme.bold(this.appearance.inputLabel ?? "Your message to Bro")),
|
|
309
336
|
this.input.render(inner)[0] ?? "",
|
|
310
337
|
],
|
|
311
338
|
notice: this.noticeText ? formatNotice(this.theme, this.noticeText, this.noticeTone) : undefined,
|
|
312
|
-
hints: this.running ? "Bro is thinking… · Esc close (stops reply)" : "Enter send · Esc close",
|
|
339
|
+
hints: this.running ? "Bro is thinking… · Esc close (stops reply)" : (this.appearance.idleHint ?? "Enter send · Esc close"),
|
|
313
340
|
});
|
|
314
341
|
}
|
|
315
342
|
|
|
@@ -410,7 +437,7 @@ export async function openBtwModal(
|
|
|
410
437
|
thread.model = selectionLabel(selection);
|
|
411
438
|
thread.preferences = Boolean(preferences);
|
|
412
439
|
modal.setModel(btwHeaderLabel(thread));
|
|
413
|
-
|
|
440
|
+
let result = await runBtwTurn(
|
|
414
441
|
buildBtwPrompt(context, question, { full, history, preferences }),
|
|
415
442
|
selection,
|
|
416
443
|
{ full, cwd: ctx.cwd, conversationId },
|
|
@@ -422,6 +449,25 @@ export async function openBtwModal(
|
|
|
422
449
|
},
|
|
423
450
|
);
|
|
424
451
|
if (turnController.signal.aborted) return;
|
|
452
|
+
if (result.expiredContinuation) {
|
|
453
|
+
// The native session is gone. Drop the dead id, rebuild the prompt from this thread's own
|
|
454
|
+
// transcript AND its captured main-session context (the local `context` is undefined
|
|
455
|
+
// whenever a continuation existed), and run once more so nothing is silently lost.
|
|
456
|
+
thread.conversationId = undefined;
|
|
457
|
+
modal.setNotice("Side thread expired — reseeded from the transcript and session context.", "warning");
|
|
458
|
+
const reseeded = await runBtwTurn(
|
|
459
|
+
buildBtwPrompt(thread.context, question, { full, history: formatBtwTranscript(thread.turns.slice(0, -1)), preferences }),
|
|
460
|
+
selection,
|
|
461
|
+
{ full, cwd: ctx.cwd },
|
|
462
|
+
turnController.signal,
|
|
463
|
+
(partial) => {
|
|
464
|
+
if (closed || turnController.signal.aborted) return;
|
|
465
|
+
thread.turns[thread.turns.length - 1]!.answer = partial;
|
|
466
|
+
modal.setText(transcript());
|
|
467
|
+
},
|
|
468
|
+
);
|
|
469
|
+
result = reseeded;
|
|
470
|
+
}
|
|
425
471
|
if (result.conversationId) {
|
|
426
472
|
thread.conversationId = result.conversationId;
|
|
427
473
|
thread.sessionFull = full;
|
package/src/config-ui.ts
CHANGED
|
@@ -11,9 +11,11 @@ import {
|
|
|
11
11
|
import { getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import {
|
|
13
13
|
type AgyModelFamily,
|
|
14
|
+
type BackendName,
|
|
14
15
|
type BroSettings,
|
|
15
16
|
type Capability,
|
|
16
17
|
type ExternalBackend,
|
|
18
|
+
type PiModelChoice,
|
|
17
19
|
CAPABILITIES,
|
|
18
20
|
CAPABILITY_LABELS,
|
|
19
21
|
EXTERNAL_BACKENDS,
|
|
@@ -23,6 +25,8 @@ import {
|
|
|
23
25
|
capabilityOverride,
|
|
24
26
|
capabilityPair,
|
|
25
27
|
effortDisplay,
|
|
28
|
+
externalModelDisplay,
|
|
29
|
+
isExternalBackend,
|
|
26
30
|
resolveModelEffort,
|
|
27
31
|
withCapabilityOverride,
|
|
28
32
|
} from "./settings.ts";
|
|
@@ -33,6 +37,21 @@ import { errorMessage } from "./util.ts";
|
|
|
33
37
|
|
|
34
38
|
const SHOW_TURNS_PRESETS = [1, 2, 3, 5, 8];
|
|
35
39
|
|
|
40
|
+
// The step-1 backend list, in display order. "Default" has no backend value and is only offered for
|
|
41
|
+
// per-capability rows (a capability can inherit the shared default). "agy" is included but has no
|
|
42
|
+
// EXTERNAL_BACKENDS entry: its models come from the live Agy catalog.
|
|
43
|
+
const BACKEND_CHOICES: readonly { value: BackendName; label: string }[] = [
|
|
44
|
+
{ value: "agy", label: "Agy" },
|
|
45
|
+
{ value: "claude", label: "Claude" },
|
|
46
|
+
{ value: "grok", label: "Grok" },
|
|
47
|
+
{ value: "codex", label: "Codex" },
|
|
48
|
+
{ value: "muse", label: "Muse" },
|
|
49
|
+
{ value: "pi", label: "Pi" },
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
// The prefix used to carry a backend selection out of the step-1 picker and into applyModelChange.
|
|
53
|
+
const BACKEND_SENTINEL = "__backend__:";
|
|
54
|
+
|
|
36
55
|
function showTurnsValues(current: number): string[] {
|
|
37
56
|
return [...new Set([...SHOW_TURNS_PRESETS, current])].sort((a, b) => a - b).map(String);
|
|
38
57
|
}
|
|
@@ -44,6 +63,7 @@ export function createConfigModal(
|
|
|
44
63
|
initialSettings: BroSettings,
|
|
45
64
|
families: AgyModelFamily[],
|
|
46
65
|
persistSettings: (settings: BroSettings) => Promise<void>,
|
|
66
|
+
piModels: PiModelChoice[] = [],
|
|
47
67
|
): (tui: TuiLike, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
|
|
48
68
|
return (tui, theme, _keybindings, done) => {
|
|
49
69
|
// Size the host list once, then stop interaction if a later resize cannot show the whole
|
|
@@ -71,60 +91,140 @@ export function createConfigModal(
|
|
|
71
91
|
done(undefined);
|
|
72
92
|
};
|
|
73
93
|
|
|
94
|
+
// Two-step picker: backend first, then a model for that backend. Esc in step 2 returns to step 1
|
|
95
|
+
// without saving; only a complete model selection calls pickerDone, so a cancel never mutates
|
|
96
|
+
// settings. The Agy family list is step 2 for the Agy backend, keeping the catalog behavior.
|
|
74
97
|
const modelPicker = (current: string, pickerDone: (value?: string) => void, capability?: Capability) => {
|
|
75
98
|
const defaultResolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
|
|
76
|
-
const
|
|
99
|
+
const currentBackend = capability
|
|
100
|
+
? (capabilityOverride(settings, capability) ? capabilityBackend(settings, capability) : undefined)
|
|
101
|
+
: (settings.backend ?? "agy");
|
|
102
|
+
const backendOptions: SelectItem[] = [
|
|
77
103
|
...(capability ? [{ value: "__default__", label: `Default (${defaultResolved.family?.label ?? settings.model})` }] : []),
|
|
78
|
-
...
|
|
79
|
-
value:
|
|
80
|
-
label:
|
|
104
|
+
...BACKEND_CHOICES.map((choice) => ({
|
|
105
|
+
value: `${BACKEND_SENTINEL}${choice.value}`,
|
|
106
|
+
label: choice.label,
|
|
81
107
|
})),
|
|
82
|
-
// Alternative CLI entries stay last so existing Agy keyboard navigation is unaffected.
|
|
83
|
-
...Object.values(EXTERNAL_BACKENDS).flatMap((meta) =>
|
|
84
|
-
meta.models.map((model) => ({
|
|
85
|
-
value: `${meta.prefix}${model.id}`,
|
|
86
|
-
label: `${model.label} · ${meta.name}`,
|
|
87
|
-
})),
|
|
88
|
-
),
|
|
89
108
|
];
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
109
|
+
const backendIndex = capability && current === "Default" ? 0 : backendOptions.findIndex((option) => option.value === `${BACKEND_SENTINEL}${currentBackend ?? "agy"}`);
|
|
110
|
+
|
|
93
111
|
const input = new Input();
|
|
94
|
-
let
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
let step: "backend" | "model" | "custom" = "backend";
|
|
113
|
+
let chosenBackend: BackendName | undefined;
|
|
114
|
+
let customError: string | undefined;
|
|
115
|
+
|
|
116
|
+
const buildModelOptions = (backend: BackendName): SelectItem[] => {
|
|
117
|
+
if (backend === "agy") {
|
|
118
|
+
return families.map((family) => ({
|
|
119
|
+
value: family.id,
|
|
120
|
+
label: `${family.label}${family.efforts.length ? "" : " · fixed effort"}`,
|
|
121
|
+
}));
|
|
99
122
|
}
|
|
123
|
+
const meta = EXTERNAL_BACKENDS[backend];
|
|
124
|
+
const models = backend === "pi" ? piModels.map((choice) => ({ id: choice.value, label: choice.label })) : meta.models;
|
|
125
|
+
return [
|
|
126
|
+
...models.map((model) => ({ value: `${meta.prefix}${model.id}`, label: model.label })),
|
|
127
|
+
{ value: `__${backend}_custom__`, label: `${meta.label} · custom model ID…` },
|
|
128
|
+
];
|
|
100
129
|
};
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
130
|
+
|
|
131
|
+
let modelSelectList: SelectList | undefined;
|
|
132
|
+
const currentModelPicker = (): SelectList => {
|
|
133
|
+
if (!modelSelectList) {
|
|
134
|
+
const items = chosenBackend ? buildModelOptions(chosenBackend) : [];
|
|
135
|
+
modelSelectList = new SelectList(items, Math.min(items.length, 8, listRows), getSelectListTheme());
|
|
136
|
+
// Pi's list row shows the bare "<provider>/<id>" while the option value carries the transport
|
|
137
|
+
// prefix, so also try the prefixed form when matching the current selection.
|
|
138
|
+
const prefix = chosenBackend && chosenBackend !== "agy" ? EXTERNAL_BACKENDS[chosenBackend].prefix : "";
|
|
139
|
+
modelSelectList.setSelectedIndex(Math.max(0, items.findIndex((option) => option.value === current || option.value === `${prefix}${current}`)));
|
|
140
|
+
modelSelectList.onSelect = (item) => {
|
|
141
|
+
if (chosenBackend && chosenBackend !== "agy" && item.value === `__${chosenBackend}_custom__`) {
|
|
142
|
+
step = "custom";
|
|
143
|
+
customError = undefined;
|
|
144
|
+
tui.requestRender();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
pickerDone(item.value);
|
|
148
|
+
};
|
|
149
|
+
// Esc in step 2 goes back to the backend list; it never saves a half-made choice.
|
|
150
|
+
modelSelectList.onCancel = () => {
|
|
151
|
+
step = "backend";
|
|
152
|
+
chosenBackend = undefined;
|
|
153
|
+
customError = undefined;
|
|
154
|
+
tui.requestRender();
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return modelSelectList;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const picker = new SelectList(backendOptions, Math.min(backendOptions.length, 8, listRows), getSelectListTheme());
|
|
161
|
+
picker.setSelectedIndex(Math.max(0, backendIndex));
|
|
104
162
|
picker.onSelect = (item) => {
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
107
|
-
|
|
163
|
+
const value = item.value;
|
|
164
|
+
if (!value.startsWith(BACKEND_SENTINEL)) {
|
|
165
|
+
pickerDone(value); // "__default__" for a capability
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const backend = value.slice(BACKEND_SENTINEL.length) as BackendName;
|
|
169
|
+
chosenBackend = backend;
|
|
170
|
+
modelSelectList = undefined;
|
|
171
|
+
currentModelPicker();
|
|
172
|
+
step = "model";
|
|
173
|
+
tui.requestRender();
|
|
108
174
|
};
|
|
109
175
|
picker.onCancel = () => pickerDone();
|
|
110
|
-
|
|
176
|
+
|
|
177
|
+
input.onSubmit = (value) => {
|
|
178
|
+
if (!value.trim() || !chosenBackend || chosenBackend === "agy") return;
|
|
179
|
+
// Validate before leaving the step so a malformed ID (Pi needs "<provider>/<id>") shows an
|
|
180
|
+
// inline hint and leaves the field open, instead of throwing out of the input handler.
|
|
181
|
+
const choice = `${EXTERNAL_BACKENDS[chosenBackend].prefix}${value.trim()}`;
|
|
182
|
+
try {
|
|
183
|
+
EXTERNAL_BACKENDS[chosenBackend].resolveModel(value.trim());
|
|
184
|
+
} catch (error) {
|
|
185
|
+
customError = errorMessage(error);
|
|
186
|
+
tui.requestRender();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
customError = undefined;
|
|
190
|
+
pickerDone(choice);
|
|
191
|
+
};
|
|
192
|
+
|
|
111
193
|
return {
|
|
112
194
|
// SettingsList draws an open submenu instead of its own list and hints, so the picker brings its own.
|
|
113
|
-
render: (width: number) =>
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
195
|
+
render: (width: number) => {
|
|
196
|
+
if (step === "custom" && chosenBackend && chosenBackend !== "agy") {
|
|
197
|
+
return [
|
|
198
|
+
`${EXTERNAL_BACKENDS[chosenBackend].label} model ID:${chosenBackend === "pi" ? " <provider>/<id>" : ""}`,
|
|
199
|
+
...input.render(width),
|
|
200
|
+
...(customError ? [truncateToWidth(theme.fg("warning", ` ${customError}`), width)] : []),
|
|
201
|
+
"",
|
|
202
|
+
truncateToWidth(theme.fg("dim", " Enter save · Esc back"), width),
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
const list = step === "backend" ? picker : currentModelPicker();
|
|
206
|
+
const hint = step === "backend" ? " ↑/↓ choose backend · Enter select · Esc back" : " ↑/↓ choose model · Enter select · Esc back";
|
|
207
|
+
return [...list.render(width), "", truncateToWidth(theme.fg("dim", hint), width)];
|
|
208
|
+
},
|
|
209
|
+
invalidate: () => { picker.invalidate(); modelSelectList?.invalidate(); input.invalidate(); },
|
|
122
210
|
// SettingsList hands an open submenu its own events unchanged.
|
|
123
|
-
handleMouse: (event: BroMouseEvent) =>
|
|
211
|
+
handleMouse: (event: BroMouseEvent) =>
|
|
212
|
+
step === "custom"
|
|
213
|
+
? delegateMouse(input, event, { top: 1, left: 0, width: tui.terminal?.columns ?? 80, height: 1 })
|
|
214
|
+
: delegateMouse(step === "backend" ? picker : currentModelPicker(), event),
|
|
124
215
|
handleInput: (data: string) => {
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
216
|
+
if (step === "custom") {
|
|
217
|
+
if (matchesKey(data, "escape")) {
|
|
218
|
+
step = "model";
|
|
219
|
+
customError = undefined;
|
|
220
|
+
tui.requestRender();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
input.handleInput(data);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (step === "backend") picker.handleInput(data);
|
|
227
|
+
else currentModelPicker().handleInput(data);
|
|
128
228
|
},
|
|
129
229
|
};
|
|
130
230
|
};
|
|
@@ -160,17 +260,15 @@ export function createConfigModal(
|
|
|
160
260
|
function refresh(): void {
|
|
161
261
|
const defaultBackend = settings.backend ?? "agy";
|
|
162
262
|
const def = resolveModelEffort({ backend: settings.backend, model: settings.model, effort: settings.effort }, families);
|
|
163
|
-
modelItem.currentValue =
|
|
164
|
-
defaultBackend
|
|
165
|
-
|
|
166
|
-
: (def.family?.id ?? settings.model);
|
|
263
|
+
modelItem.currentValue = isExternalBackend(defaultBackend)
|
|
264
|
+
? externalModelDisplay(defaultBackend, settings.model)
|
|
265
|
+
: (def.family?.id ?? settings.model);
|
|
167
266
|
effortItem.currentValue = effortDisplay(def, defaultBackend);
|
|
168
|
-
effortItem.values =
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
: undefined;
|
|
267
|
+
effortItem.values = isExternalBackend(defaultBackend)
|
|
268
|
+
? ["default", ...EXTERNAL_BACKENDS[defaultBackend].efforts]
|
|
269
|
+
: def.family?.efforts.length
|
|
270
|
+
? [...def.family.efforts]
|
|
271
|
+
: undefined;
|
|
174
272
|
modeItem.currentValue = settings.mode;
|
|
175
273
|
showTurnsItem.currentValue = String(settings.showTurns);
|
|
176
274
|
showTurnsItem.values = showTurnsValues(settings.showTurns);
|
|
@@ -182,8 +280,8 @@ export function createConfigModal(
|
|
|
182
280
|
const rows = capabilityItems[capability];
|
|
183
281
|
rows.model.currentValue = !override
|
|
184
282
|
? "Default"
|
|
185
|
-
: backend
|
|
186
|
-
?
|
|
283
|
+
: isExternalBackend(backend)
|
|
284
|
+
? externalModelDisplay(backend, override.model)
|
|
187
285
|
: (resolved.family?.id ?? override.model);
|
|
188
286
|
rows.effort.currentValue = effortDisplay(resolved, backend);
|
|
189
287
|
rows.effort.values =
|