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/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.theme.fg("dim", " · ") + (this.full ? this.theme.fg("accent", this.theme.bold(btwModeLabel(true))) : this.theme.fg("dim", btwModeLabel(false)));
302
- return this.theme.fg("accent", this.theme.bold("Bro · btw")) + model + mode + this.theme.fg("dim", scroll ? ` · ${scroll}` : "");
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.theme.bold("Your message to Bro")),
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
- const result = await runBtwTurn(
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 options: SelectItem[] = [
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
- ...families.map((family) => ({
79
- value: family.id,
80
- label: `${family.label}${family.efforts.length ? "" : " · fixed effort"}`,
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
- for (const meta of Object.values(EXTERNAL_BACKENDS)) {
91
- options.push({ value: `__${meta.name}_custom__`, label: `${meta.label} · custom model ID…` });
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 enteringBackend: ExternalBackend | undefined;
95
- input.onSubmit = (value) => {
96
- if (value.trim() && enteringBackend) {
97
- const meta = EXTERNAL_BACKENDS[enteringBackend];
98
- pickerDone(`${meta.prefix}${value.trim()}`);
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
- const picker = new SelectList(options, Math.min(options.length, 8, listRows), getSelectListTheme());
102
- const selectedIndex = capability && current === "Default" ? 0 : options.findIndex((option) => option.value === current);
103
- picker.setSelectedIndex(Math.max(0, selectedIndex));
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 custom = (Object.keys(EXTERNAL_BACKENDS) as ExternalBackend[]).find((b) => item.value === `__${b}_custom__`);
106
- if (custom) { enteringBackend = custom; tui.requestRender(); }
107
- else pickerDone(item.value);
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
- const customLabel = () => enteringBackend ? EXTERNAL_BACKENDS[enteringBackend].label : "";
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) => enteringBackend
114
- ? [
115
- `${customLabel()} model ID:`,
116
- ...input.render(width),
117
- "",
118
- truncateToWidth(theme.fg("dim", " Enter save · Esc back"), width),
119
- ]
120
- : [...picker.render(width), "", truncateToWidth(theme.fg("dim", " ↑/↓ choose · Enter select · Esc back"), width)],
121
- invalidate: () => { picker.invalidate(); input.invalidate(); },
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) => (enteringBackend ? delegateMouse(input, event, { top: 1, left: 0, width: tui.terminal?.columns ?? 80, height: 1 }) : delegateMouse(picker, event)),
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 (enteringBackend && matchesKey(data, "escape")) pickerDone();
126
- else if (enteringBackend) input.handleInput(data);
127
- else picker.handleInput(data);
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 in EXTERNAL_BACKENDS
165
- ? `${EXTERNAL_BACKENDS[defaultBackend as ExternalBackend].prefix}${settings.model}`
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
- defaultBackend in EXTERNAL_BACKENDS
170
- ? ["default", ...EXTERNAL_BACKENDS[defaultBackend as ExternalBackend].efforts]
171
- : def.family?.efforts.length
172
- ? [...def.family.efforts]
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 in EXTERNAL_BACKENDS
186
- ? `${EXTERNAL_BACKENDS[backend as ExternalBackend].prefix}${override.model}`
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 =