pi-bro 0.22.2 → 0.23.1

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/btw.ts ADDED
@@ -0,0 +1,572 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { copyToClipboard, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
3
+ import { Input, Markdown, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Focusable } from "@earendil-works/pi-tui";
4
+ import { execute as executeBackend, type BackendProgress, type BackendSelection } from "./backend.ts";
5
+ import type { Theme, TuiLike } from "./config-ui.ts";
6
+ import { buildBtwPrompt } from "./prompt.ts";
7
+ import {
8
+ type BackendName,
9
+ type BroSettings,
10
+ capabilityBackend,
11
+ readPreferences,
12
+ readSettings,
13
+ selectionForCapability,
14
+ selectionLabel,
15
+ } from "./settings.ts";
16
+ import { captureShowTranscript } from "./show.ts";
17
+ import {
18
+ broModalRows,
19
+ canBroInsertIntoEditor,
20
+ insertBroDesktopText,
21
+ setRegularMouseReporting,
22
+ wheelDelta,
23
+ type BroMouseEvent,
24
+ type BroMouseResult,
25
+ } from "./ui-capabilities.ts";
26
+ import { errorMessage } from "./util.ts";
27
+
28
+ export const BTW_CONTEXT_TURNS = 8;
29
+ export const BTW_CONTEXT_MAX = 40_000;
30
+
31
+ export type BtwTurn = { question: string; answer: string };
32
+ // `context` keeps the main-session seed so a fresh native session can be reseeded with the whole
33
+ // thread; `sessionFull` and `sessionPreferences` are the access mode and preferences the native
34
+ // session last ran with. `preferences` records whether the latest turn used them (header tag).
35
+ export type BtwThread = {
36
+ turns: BtwTurn[];
37
+ conversationId?: string;
38
+ full: boolean;
39
+ backend?: BackendName;
40
+ model?: string;
41
+ preferences?: boolean;
42
+ context?: string;
43
+ sessionFull?: boolean;
44
+ sessionPreferences?: string;
45
+ };
46
+
47
+ export function parseBtwArguments(value: string): { question: string; invalid?: string } {
48
+ const rest = value.trim();
49
+ if (!rest.startsWith("--")) return { question: rest };
50
+ const token = rest.split(/\s/, 1)[0]!;
51
+ if (token === "--full" || token === "--sandbox") {
52
+ return { question: "", invalid: `${token} was removed. Open /bro btw and type /mode to switch between conversation-only and full permission.` };
53
+ }
54
+ if (token === "--fresh") {
55
+ return { question: "", invalid: "--fresh was removed. Every new thread starts with main-session context; type /clear inside /bro btw to start over." };
56
+ }
57
+ return { question: "", invalid: `Unknown /bro btw flag: ${token}` };
58
+ }
59
+
60
+ // A new thread starts conversation-only; reopening keeps the thread and its mode.
61
+ export function resolveBtwThread(existing: BtwThread | undefined): BtwThread {
62
+ return existing ?? { turns: [], full: false };
63
+ }
64
+
65
+ export function bindBtwBackend(thread: BtwThread, backend: BackendName): boolean {
66
+ const changed = thread.backend !== undefined && thread.backend !== backend;
67
+ if (changed) { thread.turns = []; thread.conversationId = undefined; thread.context = undefined; thread.sessionFull = undefined; thread.sessionPreferences = undefined; }
68
+ thread.backend = backend;
69
+ return changed;
70
+ }
71
+
72
+ export function btwModeLabel(full: boolean): string {
73
+ return full ? "full permission" : "conversation-only";
74
+ }
75
+
76
+ // /mode keeps the transcript; whether the native session survives is decided by
77
+ // nativeBtwContinuation on the next turn.
78
+ export function toggleBtwMode(thread: BtwThread): void {
79
+ thread.full = !thread.full;
80
+ }
81
+
82
+ // Claude, Grok, Codex, and Muse run btw in the workspace for both modes and resume one native session across
83
+ // /mode switches. An Agy conversation stays bound to the workspace it started in (a sandbox scratch
84
+ // dir vs. the repo), so an access change drops it; the next turn reseeds a fresh native session
85
+ // with the main-session context and the whole thread.
86
+ // A native session remembers every earlier prompt, including old preferences, so a preferences
87
+ // change (or deletion) on any backend starts a fresh session reseeded with the quoted thread.
88
+ export function nativeBtwContinuation(thread: BtwThread, backend: BackendName, preferences = ""): string | undefined {
89
+ if (backend === "agy" && thread.conversationId && thread.sessionFull !== undefined && thread.sessionFull !== thread.full) {
90
+ thread.conversationId = undefined;
91
+ }
92
+ if (thread.conversationId && (thread.sessionPreferences ?? "") !== preferences) thread.conversationId = undefined;
93
+ return thread.conversationId;
94
+ }
95
+
96
+ export function btwHeaderLabel(thread: Pick<BtwThread, "model" | "preferences">): string {
97
+ return thread.model ? `${thread.model}${thread.preferences ? " · prefs" : ""}` : "";
98
+ }
99
+
100
+ export function formatBtwTranscript(turns: readonly BtwTurn[]): string {
101
+ return turns
102
+ .map((turn) => {
103
+ const question = turn.question.split(/\r?\n/).map((line) => (line ? `> ${line}` : ">")).join("\n");
104
+ // A turn aborted mid-stream can end inside an unclosed code fence, which would swallow the
105
+ // separator and every later turn; close it so each turn renders as its own block.
106
+ const answer = (turn.answer.match(/^```/gm)?.length ?? 0) % 2 === 1 ? `${turn.answer}\n\`\`\`` : turn.answer;
107
+ return `> **You**\n>\n${question}\n\n**Bro**\n\n${answer}`;
108
+ })
109
+ .join("\n\n---\n\n");
110
+ }
111
+
112
+ export type BtwComposerAction =
113
+ | { kind: "clear" }
114
+ | { kind: "mode" }
115
+ | { kind: "retry" }
116
+ | { kind: "clipboard"; all: boolean }
117
+ | { kind: "insert"; all: boolean }
118
+ | { kind: "removed"; command: string }
119
+ | { kind: "question"; text: string };
120
+
121
+ export function parseBtwComposerCommand(value: string): BtwComposerAction {
122
+ const command = value.trim();
123
+ if (command === "/clear") return { kind: "clear" };
124
+ if (command === "/mode") return { kind: "mode" };
125
+ if (command === "/retry" || command === "") return { kind: "retry" };
126
+ if (command === "/copy" || command === "/copy-all") {
127
+ return { kind: "clipboard", all: command === "/copy-all" };
128
+ }
129
+ if (command === "/insert" || command === "/insert-all") return { kind: "insert", all: command === "/insert-all" };
130
+ if (command === "/insert!" || command === "/insert-all!") return { kind: "removed", command };
131
+ return { kind: "question", text: command };
132
+ }
133
+
134
+ // Thin presentation-boundary wrapper around the shared backend: coalesces raw text progress to the
135
+ // existing 75ms cadence and translates the backend's tagged outcome back into this function's
136
+ // existing throw-on-failure / { text, conversationId } contract. A conversationId is only ever
137
+ // returned on success (see backend.ts's continuation handling), preserving current behavior on
138
+ // failed turns.
139
+ export async function runBtwTurn(
140
+ prompt: string,
141
+ selection: BackendSelection,
142
+ options: { full: boolean; cwd: string; conversationId?: string },
143
+ signal: AbortSignal,
144
+ onProgress?: (text: string) => void,
145
+ ): Promise<{ text: string; conversationId?: string }> {
146
+ let updateTimer: ReturnType<typeof setTimeout> | undefined;
147
+ let latest: string | undefined;
148
+ const throttledProgress = onProgress
149
+ ? (progress: BackendProgress) => {
150
+ if (progress.kind !== "text") return;
151
+ latest = progress.text;
152
+ if (!updateTimer) {
153
+ updateTimer = setTimeout(() => {
154
+ updateTimer = undefined;
155
+ if (!signal.aborted && latest !== undefined) onProgress(latest);
156
+ }, 75);
157
+ }
158
+ }
159
+ : undefined;
160
+
161
+ try {
162
+ const outcome = await executeBackend(
163
+ {
164
+ feature: "btw",
165
+ prompt,
166
+ access: options.full ? "workspace-full" : "restricted",
167
+ cwd: options.cwd,
168
+ continuation: options.conversationId ? { id: options.conversationId } : undefined,
169
+ },
170
+ selection,
171
+ signal,
172
+ throttledProgress,
173
+ );
174
+ if (outcome.status === "success") return { text: outcome.text, conversationId: outcome.continuation?.id };
175
+ throw new Error(outcome.message);
176
+ } finally {
177
+ if (updateTimer) clearTimeout(updateTimer);
178
+ }
179
+ }
180
+
181
+ export class BtwModal implements Focusable {
182
+ private _focused = false;
183
+ private readonly markdown = new Markdown("", 0, 0, getMarkdownTheme());
184
+ private readonly input = new Input();
185
+ private notice = "";
186
+ private offset = 0;
187
+ // Pinned to the newest text until the reader scrolls up; scrolling back to the end re-pins.
188
+ private follow = false;
189
+ private maxOffset = 0;
190
+ private bodyHeight = 1;
191
+ private running = false;
192
+ private full = false;
193
+ private model = "";
194
+ private disposed = false;
195
+
196
+ get focused(): boolean {
197
+ return this._focused;
198
+ }
199
+
200
+ set focused(value: boolean) {
201
+ this._focused = value;
202
+ this.input.focused = value;
203
+ }
204
+
205
+ constructor(
206
+ private readonly tui: TuiLike,
207
+ private readonly theme: Theme,
208
+ private readonly onClose: () => void,
209
+ private readonly onSubmit: (value: string) => void,
210
+ private readonly onDispose: () => void,
211
+ ) {
212
+ setRegularMouseReporting(this.tui, true);
213
+ this.input.onSubmit = (value) => {
214
+ if (!this.running) this.onSubmit(value);
215
+ };
216
+ }
217
+
218
+ // Keeps the reader's place; render clamps the offset when the text shrinks (e.g. /clear).
219
+ setText(text: string): void {
220
+ this.markdown.setText(text);
221
+ this.tui.requestRender();
222
+ }
223
+
224
+ /** Show the newest text and keep following it while a reply streams. */
225
+ scrollToEnd(): void {
226
+ this.follow = true;
227
+ this.tui.requestRender();
228
+ }
229
+
230
+ setNotice(notice: string): void {
231
+ if (this.disposed) return;
232
+ this.notice = notice;
233
+ this.tui.requestRender();
234
+ }
235
+
236
+ setRunning(running: boolean): void {
237
+ this.running = running;
238
+ this.tui.requestRender();
239
+ }
240
+
241
+ setModel(model: string): void {
242
+ this.model = model;
243
+ this.tui.requestRender();
244
+ }
245
+
246
+ setFull(full: boolean): void {
247
+ this.full = full;
248
+ this.tui.requestRender();
249
+ }
250
+
251
+ clearComposer(): void {
252
+ this.input.setValue("");
253
+ this.tui.requestRender();
254
+ }
255
+
256
+ invalidate(): void {
257
+ this.markdown.invalidate();
258
+ this.input.invalidate();
259
+ }
260
+
261
+ // Pi 1.2+ fullscreen delivers the wheel here and no longer forwards raw wheel input to handleInput.
262
+ handleMouse(event: BroMouseEvent): BroMouseResult {
263
+ if (event.type !== "wheel") return;
264
+ const delta = Math.trunc(event.wheelDelta ?? 0);
265
+ if (delta) this.scroll(delta);
266
+ return { handled: true, render: false };
267
+ }
268
+
269
+ private scroll(delta: number): void {
270
+ this.offset = Math.max(0, Math.min(this.offset + delta, this.maxOffset));
271
+ this.follow = this.offset >= this.maxOffset;
272
+ this.notice = "";
273
+ this.tui.requestRender();
274
+ }
275
+
276
+ handleInput(data: string): void {
277
+ if (matchesKey(data, "escape")) {
278
+ this.onClose();
279
+ return;
280
+ }
281
+ const delta = wheelDelta(data) || (matchesKey(data, "up") ? -1 : matchesKey(data, "down") ? 1 : 0);
282
+ if (delta) {
283
+ this.scroll(delta);
284
+ return;
285
+ }
286
+ this.input.handleInput(data);
287
+ this.tui.requestRender();
288
+ }
289
+
290
+ private frameLine(content: string, innerWidth: number): string {
291
+ const truncated = truncateToWidth(content, innerWidth, "");
292
+ const padding = Math.max(0, innerWidth - visibleWidth(truncated));
293
+ return `${this.theme.fg("border", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
294
+ }
295
+
296
+ private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
297
+ const left = edge === "top" ? "┌" : "└";
298
+ const right = edge === "top" ? "┐" : "┘";
299
+ return this.theme.fg("border", `${left}${"─".repeat(innerWidth)}${right}`);
300
+ }
301
+
302
+ private ruleLine(innerWidth: number): string {
303
+ return this.theme.fg("border", `├${"─".repeat(innerWidth)}┤`);
304
+ }
305
+
306
+ render(width: number): string[] {
307
+ const dialogWidth = Math.max(24, width);
308
+ const innerWidth = Math.max(22, dialogWidth - 2);
309
+ const terminalRows = broModalRows(this.tui, process.stdout.rows);
310
+ const dialogHeight = Math.min(34, Math.max(8, Math.floor(terminalRows * 0.82)));
311
+ const footer = [
312
+ ...wrapTextWithAnsi(this.theme.fg("accent", this.theme.bold("Your message to Bro")), innerWidth),
313
+ this.input.render(innerWidth)[0] ?? "",
314
+ ...wrapTextWithAnsi(this.running ? "Bro is thinking… · Esc: close (stops reply)" : "Enter: send message · Esc: close panel", innerWidth),
315
+ ...wrapTextWithAnsi("Commands: /mode · /copy · /copy-all · /insert · /insert-all · /clear · /retry", innerWidth),
316
+ ...(this.notice ? wrapTextWithAnsi(this.theme.fg("accent", this.notice), innerWidth) : []),
317
+ ];
318
+ this.bodyHeight = Math.max(1, dialogHeight - 5 - footer.length);
319
+
320
+ const rendered = this.markdown.render(innerWidth);
321
+ this.maxOffset = Math.max(0, rendered.length - this.bodyHeight);
322
+ if (this.follow) this.offset = this.maxOffset;
323
+ this.offset = Math.max(0, Math.min(this.offset, this.maxOffset));
324
+ const visible = rendered.slice(this.offset, this.offset + this.bodyHeight);
325
+ const hiddenBelow = Math.max(0, this.maxOffset - this.offset);
326
+ const scroll = this.maxOffset > 0 ? ` · ↑${this.offset} ↓${hiddenBelow}` : "";
327
+
328
+ const model = this.model ? this.theme.fg("dim", ` · ${this.model}`) : "";
329
+ const mode = this.theme.fg("dim", " · ") + (this.full ? this.theme.fg("accent", this.theme.bold(btwModeLabel(true))) : this.theme.fg("dim", btwModeLabel(false)));
330
+ const header = this.theme.fg("accent", this.theme.bold("Bro · btw")) + model + mode + this.theme.fg("dim", scroll);
331
+
332
+ const lines = [
333
+ this.borderLine(innerWidth, "top"),
334
+ this.frameLine(header, innerWidth),
335
+ this.ruleLine(innerWidth),
336
+ ];
337
+ for (const line of visible) lines.push(this.frameLine(line, innerWidth));
338
+ for (let i = visible.length; i < this.bodyHeight; i++) lines.push(this.frameLine("", innerWidth));
339
+ lines.push(this.ruleLine(innerWidth));
340
+ for (const line of footer) lines.push(this.frameLine(line, innerWidth));
341
+ lines.push(this.borderLine(innerWidth, "bottom"));
342
+ return lines;
343
+ }
344
+
345
+ dispose(): void {
346
+ if (this.disposed) return;
347
+ this.disposed = true;
348
+ setRegularMouseReporting(this.tui, false);
349
+ this.onDispose();
350
+ }
351
+ }
352
+
353
+ export async function openBtwModal(
354
+ ctx: ExtensionCommandContext,
355
+ options: { thread: BtwThread; initialQuestion?: string },
356
+ ): Promise<void> {
357
+ const thread = options.thread;
358
+
359
+ await ctx.ui.custom<void>(
360
+ (tui, theme, _keybindings, done) => {
361
+ let closed = false;
362
+ let controller: AbortController | undefined;
363
+
364
+ const transcript = () => formatBtwTranscript(thread.turns);
365
+
366
+ const close = () => {
367
+ if (closed) return;
368
+ closed = true;
369
+ controller?.abort();
370
+ done(undefined);
371
+ };
372
+
373
+ const modal = new BtwModal(tui, theme, close, submit, () => {
374
+ closed = true;
375
+ controller?.abort();
376
+ });
377
+
378
+ // `replaced` is the turn /retry removed; it is put back if the turn never starts.
379
+ const runTurn = async (question: string, replaced?: BtwTurn) => {
380
+ if (controller) return;
381
+ const turnController = new AbortController();
382
+ controller = turnController;
383
+ modal.setRunning(true);
384
+ modal.clearComposer();
385
+
386
+ let settings: BroSettings;
387
+ let preferences: string;
388
+ try {
389
+ settings = await readSettings();
390
+ preferences = await readPreferences();
391
+ } catch (error) {
392
+ if (replaced) thread.turns.push(replaced);
393
+ if (controller === turnController) controller = undefined;
394
+ if (!closed) { modal.setRunning(false); modal.setNotice(errorMessage(error)); }
395
+ return;
396
+ }
397
+ // Closing (or reopening) during the reads above must not touch the shared thread.
398
+ if (closed || turnController.signal.aborted) {
399
+ if (replaced) thread.turns.push(replaced);
400
+ if (controller === turnController) controller = undefined;
401
+ return;
402
+ }
403
+ if (bindBtwBackend(thread, capabilityBackend(settings, "btw"))) modal.setNotice("Backend changed — started a fresh side thread.");
404
+ if (thread.turns.length === 0) {
405
+ thread.conversationId = undefined;
406
+ const context = captureShowTranscript(ctx, BTW_CONTEXT_TURNS, BTW_CONTEXT_MAX)?.text;
407
+ thread.context = context;
408
+ }
409
+ // Resume natively when possible; otherwise seed the fresh native session with the main-session
410
+ // context plus every earlier turn so nothing is silently lost.
411
+ const conversationId = nativeBtwContinuation(thread, capabilityBackend(settings, "btw"), preferences);
412
+ const history = !conversationId && thread.turns.length ? formatBtwTranscript(thread.turns) : undefined;
413
+ const context = conversationId ? undefined : thread.context;
414
+ const full = thread.full;
415
+
416
+ thread.turns.push({ question, answer: "…" });
417
+ modal.setText(transcript());
418
+ modal.scrollToEnd();
419
+
420
+ try {
421
+ const selection = selectionForCapability(settings, "btw");
422
+ thread.model = selectionLabel(selection);
423
+ thread.preferences = Boolean(preferences);
424
+ modal.setModel(btwHeaderLabel(thread));
425
+ const result = await runBtwTurn(
426
+ buildBtwPrompt(context, question, { full, history, preferences }),
427
+ selection,
428
+ { full, cwd: ctx.cwd, conversationId },
429
+ turnController.signal,
430
+ (partial) => {
431
+ if (closed || turnController.signal.aborted) return;
432
+ thread.turns[thread.turns.length - 1]!.answer = partial;
433
+ modal.setText(transcript());
434
+ },
435
+ );
436
+ if (turnController.signal.aborted) return;
437
+ if (result.conversationId) {
438
+ thread.conversationId = result.conversationId;
439
+ thread.sessionFull = full;
440
+ thread.sessionPreferences = preferences;
441
+ }
442
+ thread.turns[thread.turns.length - 1]!.answer = result.text;
443
+ } catch (error) {
444
+ if (turnController.signal.aborted || closed) return;
445
+ thread.turns[thread.turns.length - 1]!.answer = `_${errorMessage(error)}_`;
446
+ modal.setNotice(errorMessage(error));
447
+ } finally {
448
+ if (controller === turnController) controller = undefined;
449
+ if (!closed) {
450
+ modal.setRunning(false);
451
+ modal.setText(transcript());
452
+ }
453
+ }
454
+ };
455
+
456
+ const toggleMode = () => {
457
+ toggleBtwMode(thread);
458
+ modal.setFull(thread.full);
459
+ modal.setNotice(`Mode: ${btwModeLabel(thread.full)}`);
460
+ };
461
+
462
+ const clear = () => {
463
+ thread.turns = [];
464
+ thread.conversationId = undefined;
465
+ thread.context = undefined;
466
+ thread.sessionFull = undefined;
467
+ thread.sessionPreferences = undefined;
468
+ modal.clearComposer();
469
+ modal.setNotice("");
470
+ modal.setText("");
471
+ };
472
+
473
+ const retry = () => {
474
+ if (controller) return;
475
+ const last = thread.turns.at(-1);
476
+ if (!last) {
477
+ modal.setNotice("Nothing to retry yet.");
478
+ return;
479
+ }
480
+ thread.turns.pop();
481
+ void runTurn(last.question, last);
482
+ };
483
+
484
+ const copyOut = async (all: boolean) => {
485
+ const text = all ? transcript() : (thread.turns.at(-1)?.answer ?? "");
486
+ if (!text.trim()) {
487
+ modal.setNotice("Nothing to copy yet.");
488
+ return;
489
+ }
490
+ try {
491
+ await copyToClipboard(text);
492
+ modal.setNotice(all ? "Copied the full thread to the clipboard." : "Copied the latest answer to the clipboard.");
493
+ } catch (error) {
494
+ modal.setNotice(`Copy failed: ${errorMessage(error)}`);
495
+ }
496
+ };
497
+
498
+ const insert = async (all: boolean) => {
499
+ const text = all ? transcript() : (thread.turns.at(-1)?.answer ?? "");
500
+ if (!text.trim()) {
501
+ modal.setNotice("Nothing to insert yet.");
502
+ return;
503
+ }
504
+ if (!canBroInsertIntoEditor(ctx)) {
505
+ const result = await insertBroDesktopText(ctx, text);
506
+ modal.setNotice(result === "inserted" ? "Inserted into the main editor. Review it before sending." : result === "not_empty" ? "Main editor has a draft or attachment. Clear it first, then insert again." : "Could not confirm insertion. Check the main editor before trying again.");
507
+ return;
508
+ }
509
+ if (ctx.ui.getEditorText().trim()) {
510
+ modal.setNotice("Main editor has a draft. Edit or clear it first, then insert again.");
511
+ return;
512
+ }
513
+ ctx.ui.setEditorText(text);
514
+ modal.setNotice(all ? "Inserted the full thread into the main editor." : "Inserted the latest answer into the main editor.");
515
+ };
516
+
517
+ function submit(value: string): void {
518
+ const action = parseBtwComposerCommand(value);
519
+ if (action.kind === "clear") {
520
+ modal.clearComposer();
521
+ clear();
522
+ return;
523
+ }
524
+ if (action.kind === "mode") {
525
+ modal.clearComposer();
526
+ toggleMode();
527
+ return;
528
+ }
529
+ if (action.kind === "clipboard") {
530
+ modal.clearComposer();
531
+ void copyOut(action.all);
532
+ return;
533
+ }
534
+ if (action.kind === "insert") {
535
+ modal.clearComposer();
536
+ void insert(action.all);
537
+ return;
538
+ }
539
+ if (action.kind === "removed") {
540
+ modal.clearComposer();
541
+ modal.setNotice(`${action.command} was removed. Edit or clear the main editor draft, then use /insert or /insert-all.`);
542
+ return;
543
+ }
544
+ if (action.kind === "retry") {
545
+ modal.clearComposer();
546
+ retry();
547
+ return;
548
+ }
549
+ void runTurn(action.text);
550
+ }
551
+
552
+ modal.setFull(thread.full);
553
+ modal.setModel(btwHeaderLabel(thread));
554
+ modal.setText(transcript());
555
+ modal.scrollToEnd();
556
+
557
+ if (options.initialQuestion) void runTurn(options.initialQuestion);
558
+
559
+ return modal;
560
+ },
561
+ {
562
+ overlay: true,
563
+ overlayOptions: {
564
+ width: "78%",
565
+ minWidth: 48,
566
+ maxHeight: "82%",
567
+ anchor: "top-center",
568
+ margin: { top: 1, left: 2, right: 2 },
569
+ },
570
+ },
571
+ );
572
+ }
@@ -7,6 +7,7 @@ import {
7
7
  SelectList,
8
8
  SettingsList,
9
9
  Text,
10
+ TruncatedText,
10
11
  matchesKey,
11
12
  truncateToWidth,
12
13
  visibleWidth,
@@ -34,6 +35,7 @@ import {
34
35
  withCapabilityOverride,
35
36
  } from "./settings.ts";
36
37
  import { BRO_MODES, parseBroMode } from "./prompt.ts";
38
+ import { broModalRows, consumeWheel } from "./ui-capabilities.ts";
37
39
  import { errorMessage } from "./util.ts";
38
40
 
39
41
  export type Theme = ExtensionCommandContext["ui"]["theme"];
@@ -58,6 +60,10 @@ export function createConfigModal(
58
60
  persistSettings: (settings: BroSettings) => Promise<void>,
59
61
  ): (tui: TuiLike, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
60
62
  return (tui, theme, _keybindings, done) => {
63
+ // The overlay (maxHeight 78%) clips from the bottom: leave room for the borders, title,
64
+ // subtitle, a two-line notice and SettingsList's own scroll, blank and hint rows.
65
+ // shortcut: sized once at open, not on resize; fine while config stays short-lived.
66
+ const listRows = Math.max(3, Math.floor(broModalRows(tui, process.stdout.rows) * 0.78) - 9);
61
67
  let settings = initialSettings;
62
68
  // The last settings actually confirmed on disk. A failed save reverts `settings` (and the
63
69
  // whole displayed row set) back to this, so the screen never shows state that doesn't exist.
@@ -101,7 +107,7 @@ export function createConfigModal(
101
107
  pickerDone(`${meta.prefix}${value.trim()}`);
102
108
  }
103
109
  };
104
- const picker = new SelectList(options, Math.min(options.length, 8), getSelectListTheme());
110
+ const picker = new SelectList(options, Math.min(options.length, 8, listRows), getSelectListTheme());
105
111
  const selectedIndex = capability && current === "Default" ? 0 : options.findIndex((option) => option.value === current);
106
112
  picker.setSelectedIndex(Math.max(0, selectedIndex));
107
113
  picker.onSelect = (item) => {
@@ -112,7 +118,10 @@ export function createConfigModal(
112
118
  picker.onCancel = () => pickerDone();
113
119
  const customLabel = () => enteringBackend ? EXTERNAL_BACKENDS[enteringBackend].label : "";
114
120
  return {
115
- render: (width: number) => enteringBackend ? [`${customLabel()} model ID (Enter saves, Esc cancels)`, ...input.render(width)] : picker.render(width),
121
+ // SettingsList draws an open submenu instead of its own list and hints, so the picker brings its own.
122
+ render: (width: number) => enteringBackend
123
+ ? [`${customLabel()} model ID (Enter saves, Esc cancels)`, ...input.render(width)]
124
+ : [...picker.render(width), "", truncateToWidth(theme.fg("dim", " ↑/↓ choose · Enter select · Esc back"), width)],
116
125
  invalidate: () => { picker.invalidate(); input.invalidate(); },
117
126
  handleInput: (data: string) => {
118
127
  if (enteringBackend && matchesKey(data, "escape")) pickerDone();
@@ -197,7 +206,7 @@ export function createConfigModal(
197
206
  ...CAPABILITIES.flatMap((capability) => [capabilityItems[capability].model, capabilityItems[capability].effort]),
198
207
  ];
199
208
 
200
- const noticeText = new Text("");
209
+ const noticeText = new Text("", 1, 0);
201
210
 
202
211
  function runSave(toSave: BroSettings): void {
203
212
  saving = true;
@@ -298,13 +307,13 @@ export function createConfigModal(
298
307
  done(undefined);
299
308
  };
300
309
 
301
- const settingsList = new SettingsList(items, Math.min(items.length + 2, 18), getSettingsListTheme(), onChange, requestClose);
310
+ const settingsList = new SettingsList(items, Math.min(items.length, listRows), getSettingsListTheme(), onChange, requestClose);
302
311
  const container = new Container();
303
- container.addChild(new Text(theme.fg("accent", theme.bold("Bro · config"))));
304
- container.addChild(new Text(theme.fg("dim", "Shared defaults, with optional overrides per capability")));
312
+ container.addChild(new Text(theme.fg("accent", theme.bold("Bro · config")), 1, 0));
313
+ container.addChild(new TruncatedText(theme.fg("dim", "Shared defaults, with optional overrides per capability"), 1, 0));
314
+ // SettingsList prints its own key hints, so Bro adds none.
305
315
  container.addChild(settingsList);
306
316
  container.addChild(noticeText);
307
- container.addChild(new Text(theme.fg("dim", "↑/↓ navigate · Enter select/change · Esc back/close")));
308
317
 
309
318
  return {
310
319
  render: (w: number) => {
@@ -316,6 +325,7 @@ export function createConfigModal(
316
325
  }), border("└", "┘")];
317
326
  },
318
327
  invalidate: () => container.invalidate(),
328
+ handleMouse: consumeWheel,
319
329
  handleInput: (data: string) => {
320
330
  settingsList.handleInput?.(data);
321
331
  tui.requestRender();