pi-bro 0.23.1 → 0.24.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/btw.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
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";
3
+ import { Input, Markdown, matchesKey, wrapTextWithAnsi, type Focusable } from "@earendil-works/pi-tui";
4
4
  import { execute as executeBackend, type BackendProgress, type BackendSelection } from "./backend.ts";
5
- import type { Theme, TuiLike } from "./config-ui.ts";
6
5
  import { buildBtwPrompt } from "./prompt.ts";
7
6
  import {
8
7
  type BackendName,
@@ -14,15 +13,8 @@ import {
14
13
  selectionLabel,
15
14
  } from "./settings.ts";
16
15
  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";
16
+ import { BRO_OVERLAY, broOverlayArea, formatNotice, modalHeight, openBroOverlay, renderFrame, Scroller, type Area, type NoticeTone, type Theme, type TuiLike } from "./modal-kit.ts";
17
+ import { canBroInsertIntoEditor, insertBroDesktopText, type BroMouseEvent, type BroMouseResult } from "./ui-capabilities.ts";
26
18
  import { errorMessage } from "./util.ts";
27
19
 
28
20
  export const BTW_CONTEXT_TURNS = 8;
@@ -42,6 +34,7 @@ export type BtwThread = {
42
34
  context?: string;
43
35
  sessionFull?: boolean;
44
36
  sessionPreferences?: string;
37
+ draft?: string;
45
38
  };
46
39
 
47
40
  export function parseBtwArguments(value: string): { question: string; invalid?: string } {
@@ -182,16 +175,22 @@ export class BtwModal implements Focusable {
182
175
  private _focused = false;
183
176
  private readonly markdown = new Markdown("", 0, 0, getMarkdownTheme());
184
177
  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;
178
+ private noticeText = "";
179
+ private noticeTone: NoticeTone = "info";
180
+ private rawArea: Area | undefined;
181
+ // Follows the newest text until the reader scrolls up; scrolling back to the end follows again.
182
+ private readonly scroller = new Scroller(
183
+ () => {
184
+ this.noticeText = "";
185
+ this.tui.requestRender();
186
+ },
187
+ { followEnd: true },
188
+ );
191
189
  private running = false;
192
190
  private full = false;
193
191
  private model = "";
194
192
  private disposed = false;
193
+ onInput?: () => void;
195
194
 
196
195
  get focused(): boolean {
197
196
  return this._focused;
@@ -209,13 +208,12 @@ export class BtwModal implements Focusable {
209
208
  private readonly onSubmit: (value: string) => void,
210
209
  private readonly onDispose: () => void,
211
210
  ) {
212
- setRegularMouseReporting(this.tui, true);
213
211
  this.input.onSubmit = (value) => {
214
212
  if (!this.running) this.onSubmit(value);
215
213
  };
216
214
  }
217
215
 
218
- // Keeps the reader's place; render clamps the offset when the text shrinks (e.g. /clear).
216
+ // Keeps the reader's place; the scroller clamps when the text shrinks (e.g. /clear).
219
217
  setText(text: string): void {
220
218
  this.markdown.setText(text);
221
219
  this.tui.requestRender();
@@ -223,13 +221,22 @@ export class BtwModal implements Focusable {
223
221
 
224
222
  /** Show the newest text and keep following it while a reply streams. */
225
223
  scrollToEnd(): void {
226
- this.follow = true;
224
+ this.scroller.toEnd();
227
225
  this.tui.requestRender();
228
226
  }
229
227
 
230
- setNotice(notice: string): void {
228
+ get notice(): string {
229
+ return this.noticeText;
230
+ }
231
+
232
+ set notice(notice: string) {
233
+ this.setNotice(notice);
234
+ }
235
+
236
+ setNotice(notice: string, tone: NoticeTone = "info"): void {
231
237
  if (this.disposed) return;
232
- this.notice = notice;
238
+ this.noticeText = notice;
239
+ this.noticeTone = tone;
233
240
  this.tui.requestRender();
234
241
  }
235
242
 
@@ -253,24 +260,23 @@ export class BtwModal implements Focusable {
253
260
  this.tui.requestRender();
254
261
  }
255
262
 
263
+ getComposerText(): string {
264
+ return this.input.getValue();
265
+ }
266
+
267
+ setComposerText(text: string): void {
268
+ this.input.setValue(text);
269
+ (this.input as unknown as { cursor: number }).cursor = text.length;
270
+ this.tui.requestRender();
271
+ }
272
+
256
273
  invalidate(): void {
257
274
  this.markdown.invalidate();
258
275
  this.input.invalidate();
259
276
  }
260
277
 
261
- // Pi 1.2+ fullscreen delivers the wheel here and no longer forwards raw wheel input to handleInput.
262
278
  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();
279
+ return this.scroller.handleMouse(event);
274
280
  }
275
281
 
276
282
  handleInput(data: string): void {
@@ -278,74 +284,38 @@ export class BtwModal implements Focusable {
278
284
  this.onClose();
279
285
  return;
280
286
  }
281
- const delta = wheelDelta(data) || (matchesKey(data, "up") ? -1 : matchesKey(data, "down") ? 1 : 0);
282
- if (delta) {
283
- this.scroll(delta);
284
- return;
285
- }
287
+ if (this.scroller.handleKey(data, this.rawArea)) return;
286
288
  this.input.handleInput(data);
289
+ this.onInput?.();
287
290
  this.tui.requestRender();
288
291
  }
289
292
 
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
293
  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;
294
+ const inner = Math.max(1, width - 2);
295
+ const height = modalHeight(this.tui, BRO_OVERLAY, 34);
296
+ this.rawArea = broOverlayArea(this.tui, width, height);
297
+ return renderFrame(this.theme, width, height, {
298
+ header: () => {
299
+ const scroll = this.scroller.indicator();
300
+ 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}` : "");
303
+ },
304
+ body: (rows, innerWidth) => this.scroller.view(this.markdown.render(innerWidth), rows),
305
+ // The frame drops lines from the top when space runs out, so the composer goes last.
306
+ 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")),
309
+ this.input.render(inner)[0] ?? "",
310
+ ],
311
+ 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",
313
+ });
343
314
  }
344
315
 
345
316
  dispose(): void {
346
317
  if (this.disposed) return;
347
318
  this.disposed = true;
348
- setRegularMouseReporting(this.tui, false);
349
319
  this.onDispose();
350
320
  }
351
321
  }
@@ -356,24 +326,41 @@ export async function openBtwModal(
356
326
  ): Promise<void> {
357
327
  const thread = options.thread;
358
328
 
359
- await ctx.ui.custom<void>(
329
+ await openBroOverlay<void>(
330
+ ctx,
360
331
  (tui, theme, _keybindings, done) => {
361
332
  let closed = false;
362
333
  let controller: AbortController | undefined;
363
334
 
364
335
  const transcript = () => formatBtwTranscript(thread.turns);
365
336
 
337
+ let composerTouched = false;
338
+ const saveDraft = () => {
339
+ const draft = modal.getComposerText();
340
+ if (draft.trim()) {
341
+ thread.draft = draft;
342
+ } else if (!options.initialQuestion || composerTouched) {
343
+ delete thread.draft;
344
+ }
345
+ };
346
+
366
347
  const close = () => {
367
348
  if (closed) return;
368
349
  closed = true;
350
+ saveDraft();
369
351
  controller?.abort();
370
352
  done(undefined);
371
353
  };
372
354
 
373
355
  const modal = new BtwModal(tui, theme, close, submit, () => {
374
356
  closed = true;
357
+ saveDraft();
375
358
  controller?.abort();
376
359
  });
360
+ modal.onInput = () => {
361
+ composerTouched = true;
362
+ };
363
+ if (thread.draft && !options.initialQuestion) modal.setComposerText(thread.draft);
377
364
 
378
365
  // `replaced` is the turn /retry removed; it is put back if the turn never starts.
379
366
  const runTurn = async (question: string, replaced?: BtwTurn) => {
@@ -381,7 +368,6 @@ export async function openBtwModal(
381
368
  const turnController = new AbortController();
382
369
  controller = turnController;
383
370
  modal.setRunning(true);
384
- modal.clearComposer();
385
371
 
386
372
  let settings: BroSettings;
387
373
  let preferences: string;
@@ -390,17 +376,19 @@ export async function openBtwModal(
390
376
  preferences = await readPreferences();
391
377
  } catch (error) {
392
378
  if (replaced) thread.turns.push(replaced);
379
+ if (!replaced && !options.initialQuestion) modal.setComposerText(question);
393
380
  if (controller === turnController) controller = undefined;
394
- if (!closed) { modal.setRunning(false); modal.setNotice(errorMessage(error)); }
381
+ if (!closed) { modal.setRunning(false); modal.setNotice(errorMessage(error), "error"); }
395
382
  return;
396
383
  }
397
384
  // Closing (or reopening) during the reads above must not touch the shared thread.
398
385
  if (closed || turnController.signal.aborted) {
399
386
  if (replaced) thread.turns.push(replaced);
387
+ if (!replaced && !options.initialQuestion) modal.setComposerText(question);
400
388
  if (controller === turnController) controller = undefined;
401
389
  return;
402
390
  }
403
- if (bindBtwBackend(thread, capabilityBackend(settings, "btw"))) modal.setNotice("Backend changed — started a fresh side thread.");
391
+ if (bindBtwBackend(thread, capabilityBackend(settings, "btw"))) modal.setNotice("Backend changed — started a fresh side thread.", "warning");
404
392
  if (thread.turns.length === 0) {
405
393
  thread.conversationId = undefined;
406
394
  const context = captureShowTranscript(ctx, BTW_CONTEXT_TURNS, BTW_CONTEXT_MAX)?.text;
@@ -443,7 +431,7 @@ export async function openBtwModal(
443
431
  } catch (error) {
444
432
  if (turnController.signal.aborted || closed) return;
445
433
  thread.turns[thread.turns.length - 1]!.answer = `_${errorMessage(error)}_`;
446
- modal.setNotice(errorMessage(error));
434
+ modal.setNotice(errorMessage(error), "error");
447
435
  } finally {
448
436
  if (controller === turnController) controller = undefined;
449
437
  if (!closed) {
@@ -456,7 +444,7 @@ export async function openBtwModal(
456
444
  const toggleMode = () => {
457
445
  toggleBtwMode(thread);
458
446
  modal.setFull(thread.full);
459
- modal.setNotice(`Mode: ${btwModeLabel(thread.full)}`);
447
+ modal.setNotice(`Mode: ${btwModeLabel(thread.full)}`, "info");
460
448
  };
461
449
 
462
450
  const clear = () => {
@@ -465,6 +453,7 @@ export async function openBtwModal(
465
453
  thread.context = undefined;
466
454
  thread.sessionFull = undefined;
467
455
  thread.sessionPreferences = undefined;
456
+ delete thread.draft;
468
457
  modal.clearComposer();
469
458
  modal.setNotice("");
470
459
  modal.setText("");
@@ -474,7 +463,7 @@ export async function openBtwModal(
474
463
  if (controller) return;
475
464
  const last = thread.turns.at(-1);
476
465
  if (!last) {
477
- modal.setNotice("Nothing to retry yet.");
466
+ modal.setNotice("Nothing to retry yet.", "warning");
478
467
  return;
479
468
  }
480
469
  thread.turns.pop();
@@ -484,34 +473,34 @@ export async function openBtwModal(
484
473
  const copyOut = async (all: boolean) => {
485
474
  const text = all ? transcript() : (thread.turns.at(-1)?.answer ?? "");
486
475
  if (!text.trim()) {
487
- modal.setNotice("Nothing to copy yet.");
476
+ modal.setNotice("Nothing to copy yet.", "warning");
488
477
  return;
489
478
  }
490
479
  try {
491
480
  await copyToClipboard(text);
492
- modal.setNotice(all ? "Copied the full thread to the clipboard." : "Copied the latest answer to the clipboard.");
481
+ modal.setNotice(all ? "Copied the full thread to the clipboard." : "Copied the latest answer to the clipboard.", "success");
493
482
  } catch (error) {
494
- modal.setNotice(`Copy failed: ${errorMessage(error)}`);
483
+ modal.setNotice(`Copy failed: ${errorMessage(error)}`, "error");
495
484
  }
496
485
  };
497
486
 
498
487
  const insert = async (all: boolean) => {
499
488
  const text = all ? transcript() : (thread.turns.at(-1)?.answer ?? "");
500
489
  if (!text.trim()) {
501
- modal.setNotice("Nothing to insert yet.");
490
+ modal.setNotice("Nothing to insert yet.", "warning");
502
491
  return;
503
492
  }
504
493
  if (!canBroInsertIntoEditor(ctx)) {
505
494
  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.");
495
+ 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.", result === "inserted" ? "success" : result === "not_empty" ? "warning" : "error");
507
496
  return;
508
497
  }
509
498
  if (ctx.ui.getEditorText().trim()) {
510
- modal.setNotice("Main editor has a draft. Edit or clear it first, then insert again.");
499
+ modal.setNotice("Main editor has a draft. Edit or clear it first, then insert again.", "warning");
511
500
  return;
512
501
  }
513
502
  ctx.ui.setEditorText(text);
514
- modal.setNotice(all ? "Inserted the full thread into the main editor." : "Inserted the latest answer into the main editor.");
503
+ modal.setNotice(all ? "Inserted the full thread into the main editor." : "Inserted the latest answer into the main editor.", "success");
515
504
  };
516
505
 
517
506
  function submit(value: string): void {
@@ -542,10 +531,14 @@ export async function openBtwModal(
542
531
  return;
543
532
  }
544
533
  if (action.kind === "retry") {
534
+ composerTouched = true;
545
535
  modal.clearComposer();
546
536
  retry();
547
537
  return;
548
538
  }
539
+ composerTouched = true;
540
+ delete thread.draft;
541
+ modal.clearComposer();
549
542
  void runTurn(action.text);
550
543
  }
551
544
 
@@ -558,15 +551,6 @@ export async function openBtwModal(
558
551
 
559
552
  return modal;
560
553
  },
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
- },
554
+ { mouse: true },
571
555
  );
572
556
  }
package/src/config-ui.ts CHANGED
@@ -2,21 +2,13 @@ import {
2
2
  type Component,
3
3
  type SelectItem,
4
4
  type SettingItem,
5
- Container,
6
5
  Input,
7
6
  SelectList,
8
7
  SettingsList,
9
- Text,
10
- TruncatedText,
11
8
  matchesKey,
12
9
  truncateToWidth,
13
- visibleWidth,
14
10
  } from "@earendil-works/pi-tui";
15
- import {
16
- type ExtensionCommandContext,
17
- getSelectListTheme,
18
- getSettingsListTheme,
19
- } from "@earendil-works/pi-coding-agent";
11
+ import { getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
20
12
  import {
21
13
  type AgyModelFamily,
22
14
  type BroSettings,
@@ -35,16 +27,10 @@ import {
35
27
  withCapabilityOverride,
36
28
  } from "./settings.ts";
37
29
  import { BRO_MODES, parseBroMode } from "./prompt.ts";
38
- import { broModalRows, consumeWheel } from "./ui-capabilities.ts";
30
+ import { delegateMouse, formatNotice, modalHeight, renderFrame, type Area, type Theme, type TuiLike } from "./modal-kit.ts";
31
+ import type { BroMouseEvent } from "./ui-capabilities.ts";
39
32
  import { errorMessage } from "./util.ts";
40
33
 
41
- export type Theme = ExtensionCommandContext["ui"]["theme"];
42
- export type TuiLike = {
43
- readonly mode: "regular" | "fullscreen";
44
- readonly terminal?: { rows?: number; columns?: number; write?: (data: string) => void };
45
- requestRender(): void;
46
- };
47
-
48
34
  const SHOW_TURNS_PRESETS = [1, 2, 3, 5, 8];
49
35
 
50
36
  function showTurnsValues(current: number): string[] {
@@ -60,10 +46,9 @@ export function createConfigModal(
60
46
  persistSettings: (settings: BroSettings) => Promise<void>,
61
47
  ): (tui: TuiLike, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
62
48
  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);
49
+ // Size the host list once, then stop interaction if a later resize cannot show the whole
50
+ // component. SettingsList has no public resize API; clipping it would hide selection/actions.
51
+ const listRows = Math.max(1, modalHeight(tui) - 11);
67
52
  let settings = initialSettings;
68
53
  // The last settings actually confirmed on disk. A failed save reverts `settings` (and the
69
54
  // whole displayed row set) back to this, so the screen never shows state that doesn't exist.
@@ -79,6 +64,12 @@ export function createConfigModal(
79
64
  // that only actually happens once the in-flight (and any coalesced) save has settled, and
80
65
  // only if it succeeded — a failure cancels the pending close so its notice stays visible.
81
66
  let closeRequested = false;
67
+ let closed = false;
68
+ const close = () => {
69
+ if (closed) return;
70
+ closed = true;
71
+ done(undefined);
72
+ };
82
73
 
83
74
  const modelPicker = (current: string, pickerDone: (value?: string) => void, capability?: Capability) => {
84
75
  const defaultResolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
@@ -120,9 +111,16 @@ export function createConfigModal(
120
111
  return {
121
112
  // SettingsList draws an open submenu instead of its own list and hints, so the picker brings its own.
122
113
  render: (width: number) => enteringBackend
123
- ? [`${customLabel()} model ID (Enter saves, Esc cancels)`, ...input.render(width)]
114
+ ? [
115
+ `${customLabel()} model ID:`,
116
+ ...input.render(width),
117
+ "",
118
+ truncateToWidth(theme.fg("dim", " Enter save · Esc back"), width),
119
+ ]
124
120
  : [...picker.render(width), "", truncateToWidth(theme.fg("dim", " ↑/↓ choose · Enter select · Esc back"), width)],
125
121
  invalidate: () => { picker.invalidate(); input.invalidate(); },
122
+ // 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)),
126
124
  handleInput: (data: string) => {
127
125
  if (enteringBackend && matchesKey(data, "escape")) pickerDone();
128
126
  else if (enteringBackend) input.handleInput(data);
@@ -206,14 +204,17 @@ export function createConfigModal(
206
204
  ...CAPABILITIES.flatMap((capability) => [capabilityItems[capability].model, capabilityItems[capability].effort]),
207
205
  ];
208
206
 
209
- const noticeText = new Text("", 1, 0);
207
+ let notice = "";
208
+ let interactive = true;
209
+ // Where the complete list was last drawn inside the overlay, for click forwarding.
210
+ let listArea: Area = { top: 0, left: 0, width: 0, height: 0 };
210
211
 
211
212
  function runSave(toSave: BroSettings): void {
212
213
  saving = true;
213
214
  void persistSettings(toSave)
214
215
  .then(() => {
215
216
  savedSettings = toSave;
216
- noticeText.setText("");
217
+ notice = "";
217
218
  })
218
219
  .catch((error: unknown) => {
219
220
  // Restore the last state that is actually on disk: showing the failed, unsaved
@@ -222,7 +223,8 @@ export function createConfigModal(
222
223
  queued = undefined;
223
224
  closeRequested = false;
224
225
  refresh();
225
- noticeText.setText(theme.fg("warning", `Could not save settings: ${errorMessage(error)}. Reverted to the last saved settings.`));
226
+ // Outcome first: long notices are cut after three lines.
227
+ notice = formatNotice(theme, `Reverted to the last saved settings. Could not save settings: ${errorMessage(error)}`, "error");
226
228
  })
227
229
  .finally(() => {
228
230
  saving = false;
@@ -233,7 +235,7 @@ export function createConfigModal(
233
235
  runSave(next);
234
236
  } else if (closeRequested) {
235
237
  closeRequested = false;
236
- done(undefined);
238
+ close();
237
239
  }
238
240
  });
239
241
  }
@@ -304,31 +306,56 @@ export function createConfigModal(
304
306
  closeRequested = true;
305
307
  return;
306
308
  }
307
- done(undefined);
309
+ close();
308
310
  };
309
311
 
310
312
  const settingsList = new SettingsList(items, Math.min(items.length, listRows), getSettingsListTheme(), onChange, requestClose);
311
- const container = new Container();
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.
315
- container.addChild(settingsList);
316
- container.addChild(noticeText);
317
-
318
313
  return {
319
- render: (w: number) => {
320
- const inner = Math.max(1, w - 4);
321
- const border = (left: string, right: string) => theme.fg("border", left + "─".repeat(inner + 2) + right);
322
- return [border("┌", "┐"), ...container.render(inner).map(line => {
323
- const text = truncateToWidth(line, inner, "");
324
- return theme.fg("border", "│") + " " + text + " ".repeat(Math.max(0, inner - visibleWidth(text))) + " " + theme.fg("border", "│");
325
- }), border("└", "┘")];
314
+ render: (width: number) => {
315
+ listArea = { top: 0, left: 0, width: 0, height: 0 };
316
+ const height = modalHeight(tui);
317
+ const innerWidth = Math.max(1, width - 2);
318
+ const list = settingsList.render(innerWidth);
319
+ // renderFrame shrinks a notice to one line before taking minBody rows.
320
+ interactive = list.length <= Math.max(0, height - 5 - (notice ? 1 : 0));
321
+ return renderFrame(theme, width, height, {
322
+ header: () => theme.fg(
323
+ interactive ? "accent" : "warning",
324
+ theme.bold(interactive ? "Bro · config" : "Bro · config · enlarge terminal"),
325
+ ),
326
+ body: (rows) => {
327
+ if (!interactive) return [theme.fg("warning", "Enlarge the terminal to configure settings safely.")];
328
+ // The subtitle gives way before SettingsList's selection, scroll state and key hints.
329
+ const subtitle = rows > list.length
330
+ ? [truncateToWidth(theme.fg("dim", "Shared defaults, with optional overrides per capability"), innerWidth)]
331
+ : [];
332
+ // Clicks only reach list rows that were actually drawn.
333
+ listArea = { top: 3 + subtitle.length, left: 1, width: innerWidth, height: Math.min(list.length, rows - subtitle.length) };
334
+ return [...subtitle, ...list];
335
+ },
336
+ notice,
337
+ hints: interactive ? undefined : "Esc close",
338
+ fill: false,
339
+ minBody: interactive ? list.length : 1,
340
+ });
326
341
  },
327
- invalidate: () => container.invalidate(),
328
- handleMouse: consumeWheel,
342
+ invalidate: () => settingsList.invalidate(),
343
+ handleMouse: (event: BroMouseEvent) => delegateMouse(settingsList, event, listArea),
329
344
  handleInput: (data: string) => {
345
+ if (!interactive) {
346
+ // No picker is visible here: Esc closes config, still waiting for pending saves.
347
+ if (matchesKey(data, "escape")) {
348
+ requestClose();
349
+ return;
350
+ }
351
+ if (notice) {
352
+ notice = "";
353
+ tui.requestRender();
354
+ }
355
+ return;
356
+ }
330
357
  settingsList.handleInput?.(data);
331
- tui.requestRender();
358
+ if (!closed) tui.requestRender();
332
359
  },
333
360
  };
334
361
  };