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/CHANGELOG.md +15 -0
- package/README.md +10 -9
- package/docs/guided-review.md +8 -12
- package/docs/user-guide.md +35 -17
- package/package.json +3 -2
- package/src/advisor.ts +3 -3
- package/src/backend.ts +65 -42
- package/src/btw.ts +100 -116
- package/src/config-ui.ts +71 -44
- package/src/doctor.ts +21 -24
- package/src/explain-modal.ts +188 -169
- package/src/modal-kit.ts +258 -0
- package/src/review-modal.ts +95 -48
- package/src/review-ui.ts +52 -46
- package/src/ui-capabilities.ts +13 -6
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,
|
|
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
|
-
|
|
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
|
|
186
|
-
private
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
private
|
|
190
|
-
|
|
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;
|
|
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.
|
|
224
|
+
this.scroller.toEnd();
|
|
227
225
|
this.tui.requestRender();
|
|
228
226
|
}
|
|
229
227
|
|
|
230
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
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 {
|
|
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
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
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
|
-
? [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: (
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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: () =>
|
|
328
|
-
handleMouse:
|
|
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
|
};
|