pi-bro 0.23.0 → 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/config-ui.ts CHANGED
@@ -2,20 +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
8
  matchesKey,
11
9
  truncateToWidth,
12
- visibleWidth,
13
10
  } from "@earendil-works/pi-tui";
14
- import {
15
- type ExtensionCommandContext,
16
- getSelectListTheme,
17
- getSettingsListTheme,
18
- } from "@earendil-works/pi-coding-agent";
11
+ import { getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
19
12
  import {
20
13
  type AgyModelFamily,
21
14
  type BroSettings,
@@ -34,15 +27,10 @@ import {
34
27
  withCapabilityOverride,
35
28
  } from "./settings.ts";
36
29
  import { BRO_MODES, parseBroMode } from "./prompt.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";
37
32
  import { errorMessage } from "./util.ts";
38
33
 
39
- export type Theme = ExtensionCommandContext["ui"]["theme"];
40
- export type TuiLike = {
41
- readonly mode: "regular" | "fullscreen";
42
- readonly terminal?: { rows?: number; columns?: number; write?: (data: string) => void };
43
- requestRender(): void;
44
- };
45
-
46
34
  const SHOW_TURNS_PRESETS = [1, 2, 3, 5, 8];
47
35
 
48
36
  function showTurnsValues(current: number): string[] {
@@ -58,6 +46,9 @@ export function createConfigModal(
58
46
  persistSettings: (settings: BroSettings) => Promise<void>,
59
47
  ): (tui: TuiLike, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
60
48
  return (tui, theme, _keybindings, done) => {
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);
61
52
  let settings = initialSettings;
62
53
  // The last settings actually confirmed on disk. A failed save reverts `settings` (and the
63
54
  // whole displayed row set) back to this, so the screen never shows state that doesn't exist.
@@ -73,6 +64,12 @@ export function createConfigModal(
73
64
  // that only actually happens once the in-flight (and any coalesced) save has settled, and
74
65
  // only if it succeeded — a failure cancels the pending close so its notice stays visible.
75
66
  let closeRequested = false;
67
+ let closed = false;
68
+ const close = () => {
69
+ if (closed) return;
70
+ closed = true;
71
+ done(undefined);
72
+ };
76
73
 
77
74
  const modelPicker = (current: string, pickerDone: (value?: string) => void, capability?: Capability) => {
78
75
  const defaultResolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
@@ -101,7 +98,7 @@ export function createConfigModal(
101
98
  pickerDone(`${meta.prefix}${value.trim()}`);
102
99
  }
103
100
  };
104
- const picker = new SelectList(options, Math.min(options.length, 8), getSelectListTheme());
101
+ const picker = new SelectList(options, Math.min(options.length, 8, listRows), getSelectListTheme());
105
102
  const selectedIndex = capability && current === "Default" ? 0 : options.findIndex((option) => option.value === current);
106
103
  picker.setSelectedIndex(Math.max(0, selectedIndex));
107
104
  picker.onSelect = (item) => {
@@ -112,8 +109,18 @@ export function createConfigModal(
112
109
  picker.onCancel = () => pickerDone();
113
110
  const customLabel = () => enteringBackend ? EXTERNAL_BACKENDS[enteringBackend].label : "";
114
111
  return {
115
- render: (width: number) => enteringBackend ? [`${customLabel()} model ID (Enter saves, Esc cancels)`, ...input.render(width)] : picker.render(width),
112
+ // 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)],
116
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)),
117
124
  handleInput: (data: string) => {
118
125
  if (enteringBackend && matchesKey(data, "escape")) pickerDone();
119
126
  else if (enteringBackend) input.handleInput(data);
@@ -197,14 +204,17 @@ export function createConfigModal(
197
204
  ...CAPABILITIES.flatMap((capability) => [capabilityItems[capability].model, capabilityItems[capability].effort]),
198
205
  ];
199
206
 
200
- const noticeText = new Text("");
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 };
201
211
 
202
212
  function runSave(toSave: BroSettings): void {
203
213
  saving = true;
204
214
  void persistSettings(toSave)
205
215
  .then(() => {
206
216
  savedSettings = toSave;
207
- noticeText.setText("");
217
+ notice = "";
208
218
  })
209
219
  .catch((error: unknown) => {
210
220
  // Restore the last state that is actually on disk: showing the failed, unsaved
@@ -213,7 +223,8 @@ export function createConfigModal(
213
223
  queued = undefined;
214
224
  closeRequested = false;
215
225
  refresh();
216
- 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");
217
228
  })
218
229
  .finally(() => {
219
230
  saving = false;
@@ -224,7 +235,7 @@ export function createConfigModal(
224
235
  runSave(next);
225
236
  } else if (closeRequested) {
226
237
  closeRequested = false;
227
- done(undefined);
238
+ close();
228
239
  }
229
240
  });
230
241
  }
@@ -295,30 +306,56 @@ export function createConfigModal(
295
306
  closeRequested = true;
296
307
  return;
297
308
  }
298
- done(undefined);
309
+ close();
299
310
  };
300
311
 
301
- const settingsList = new SettingsList(items, Math.min(items.length + 2, 18), getSettingsListTheme(), onChange, requestClose);
302
- 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")));
305
- container.addChild(settingsList);
306
- container.addChild(noticeText);
307
- container.addChild(new Text(theme.fg("dim", "↑/↓ navigate · Enter select/change · Esc back/close")));
308
-
312
+ const settingsList = new SettingsList(items, Math.min(items.length, listRows), getSettingsListTheme(), onChange, requestClose);
309
313
  return {
310
- render: (w: number) => {
311
- const inner = Math.max(1, w - 4);
312
- const border = (left: string, right: string) => theme.fg("border", left + "─".repeat(inner + 2) + right);
313
- return [border("┌", "┐"), ...container.render(inner).map(line => {
314
- const text = truncateToWidth(line, inner, "");
315
- return theme.fg("border", "│") + " " + text + " ".repeat(Math.max(0, inner - visibleWidth(text))) + " " + theme.fg("border", "│");
316
- }), 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
+ });
317
341
  },
318
- invalidate: () => container.invalidate(),
342
+ invalidate: () => settingsList.invalidate(),
343
+ handleMouse: (event: BroMouseEvent) => delegateMouse(settingsList, event, listArea),
319
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
+ }
320
357
  settingsList.handleInput?.(data);
321
- tui.requestRender();
358
+ if (!closed) tui.requestRender();
322
359
  },
323
360
  };
324
361
  };