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/explain-modal.ts
CHANGED
|
@@ -3,13 +3,10 @@ import { stripVTControlCharacters } from "node:util";
|
|
|
3
3
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { copyToClipboard, getMarkdownTheme, getSelectListTheme } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import {
|
|
6
|
-
Container,
|
|
7
6
|
Editor,
|
|
8
7
|
Markdown,
|
|
9
|
-
Text,
|
|
10
8
|
matchesKey,
|
|
11
|
-
|
|
12
|
-
visibleWidth,
|
|
9
|
+
wrapTextWithAnsi,
|
|
13
10
|
type Component,
|
|
14
11
|
type EditorTheme,
|
|
15
12
|
type Focusable,
|
|
@@ -17,7 +14,6 @@ import {
|
|
|
17
14
|
} from "@earendil-works/pi-tui";
|
|
18
15
|
import { ADVISOR_STEERING_ENTRY, ADVISOR_STEERING_FILE, resolveAdvisorState } from "./advisor.ts";
|
|
19
16
|
import { runBackendText } from "./backend.ts";
|
|
20
|
-
import type { Theme, TuiLike } from "./config-ui.ts";
|
|
21
17
|
import {
|
|
22
18
|
type BroMode,
|
|
23
19
|
MAX_PREFERENCES_CHARS,
|
|
@@ -40,15 +36,8 @@ import {
|
|
|
40
36
|
} from "./settings.ts";
|
|
41
37
|
import { openShowHtml, stripShowDiagramFence } from "./show.ts";
|
|
42
38
|
import type { BroSource } from "./sources.ts";
|
|
43
|
-
import {
|
|
44
|
-
|
|
45
|
-
consumeWheel,
|
|
46
|
-
hasBroCustomUi,
|
|
47
|
-
setRegularMouseReporting,
|
|
48
|
-
wheelDelta,
|
|
49
|
-
type BroMouseEvent,
|
|
50
|
-
type BroMouseResult,
|
|
51
|
-
} from "./ui-capabilities.ts";
|
|
39
|
+
import { BRO_OVERLAY, broOverlayArea, delegateMouse, formatNotice, modalHeight, openBroOverlay, renderFrame, Scroller, type Area, type NoticeTone, type Theme, type TuiLike } from "./modal-kit.ts";
|
|
40
|
+
import { hasBroCustomUi, type BroMouseEvent, type BroMouseResult } from "./ui-capabilities.ts";
|
|
52
41
|
import { errorMessage } from "./util.ts";
|
|
53
42
|
|
|
54
43
|
const LOADING_TEXT = "Simplifying for my bro…";
|
|
@@ -93,9 +82,12 @@ export async function simplify(
|
|
|
93
82
|
|
|
94
83
|
export type TextEditorModalOptions = {
|
|
95
84
|
title: string;
|
|
85
|
+
/** Always shown in full above the editor; the editor asks for a bigger terminal before hiding it. */
|
|
86
|
+
privacy: string;
|
|
96
87
|
subtitle: string;
|
|
97
88
|
initialText: string;
|
|
98
89
|
initialNotice?: string;
|
|
90
|
+
initialNoticeTone?: NoticeTone;
|
|
99
91
|
// Returns an error message to refuse the save without calling onSave.
|
|
100
92
|
validate?: (text: string) => string | undefined;
|
|
101
93
|
// Either may be async; the modal then waits, ignores further edits, and reports success only once it settles.
|
|
@@ -117,17 +109,30 @@ export function createTextEditorModal(
|
|
|
117
109
|
editor.setText(options.initialText);
|
|
118
110
|
let disposed = false;
|
|
119
111
|
let pending = false;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
112
|
+
let closeRequested = false;
|
|
113
|
+
let lastSavedText = editor.getExpandedText();
|
|
114
|
+
let discardArmed = false;
|
|
115
|
+
let notice = options.initialNotice
|
|
116
|
+
? formatNotice(theme, options.initialNotice, options.initialNoticeTone ?? "info")
|
|
117
|
+
: "";
|
|
118
|
+
// Where the editor was last drawn inside the overlay, for click forwarding.
|
|
119
|
+
let editorArea: Area = { top: 0, left: 0, width: 0, height: 0 };
|
|
120
|
+
// The editor did not fit at the last render; editing blind could save text the user never saw.
|
|
121
|
+
let tooSmall = false;
|
|
122
|
+
const showNotice = (message: string, tone: NoticeTone) => {
|
|
124
123
|
if (disposed) return;
|
|
125
|
-
notice
|
|
124
|
+
notice = formatNotice(theme, message, tone);
|
|
126
125
|
tui.requestRender();
|
|
127
126
|
};
|
|
128
|
-
editor.onChange = () =>
|
|
127
|
+
editor.onChange = () => {
|
|
128
|
+
notice = "";
|
|
129
|
+
discardArmed = false;
|
|
130
|
+
};
|
|
129
131
|
const persist = (action: () => void | Promise<void>, success: string, failure: string, after?: () => void) => {
|
|
130
|
-
const fail = (error: unknown) =>
|
|
132
|
+
const fail = (error: unknown) => {
|
|
133
|
+
closeRequested = false;
|
|
134
|
+
showNotice(`${failure}: ${errorMessage(error)}`, "error");
|
|
135
|
+
};
|
|
131
136
|
let result: void | Promise<void>;
|
|
132
137
|
try {
|
|
133
138
|
result = action();
|
|
@@ -147,6 +152,11 @@ export function createTextEditorModal(
|
|
|
147
152
|
if (disposed) return;
|
|
148
153
|
after?.();
|
|
149
154
|
showNotice(success, "success");
|
|
155
|
+
if (closeRequested) {
|
|
156
|
+
closeRequested = false;
|
|
157
|
+
disposed = true;
|
|
158
|
+
done(undefined);
|
|
159
|
+
}
|
|
150
160
|
},
|
|
151
161
|
(error) => {
|
|
152
162
|
pending = false;
|
|
@@ -155,31 +165,86 @@ export function createTextEditorModal(
|
|
|
155
165
|
);
|
|
156
166
|
};
|
|
157
167
|
|
|
158
|
-
const container = new Container();
|
|
159
|
-
container.addChild(new Text(theme.fg("accent", theme.bold(options.title)), 1, 0));
|
|
160
|
-
container.addChild(new Text(theme.fg("dim", options.subtitle), 1, 0));
|
|
161
|
-
container.addChild(editor);
|
|
162
|
-
container.addChild(notice);
|
|
163
|
-
container.addChild(new Text(theme.fg("dim", "Ctrl+S save · Ctrl+K clear · Ctrl+C copy · Esc close"), 1, 0));
|
|
164
|
-
|
|
165
168
|
return {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
// shortcut: Editor keeps its own min height (5 lines), so preferences fits from ~22 rows at
|
|
170
|
+
// 80+ columns; the subtitle gives way first, the privacy line never. Revisit if users report smaller terminals.
|
|
171
|
+
render: (width: number) => {
|
|
172
|
+
editorArea = { top: 0, left: 0, width: 0, height: 0 };
|
|
173
|
+
// Editor sizes itself from the terminal and cannot be cut without hiding its cursor.
|
|
174
|
+
const lines = editor.render(Math.max(1, width - 2));
|
|
175
|
+
const privacy = wrapTextWithAnsi(theme.fg("dim", options.privacy), Math.max(1, width - 2));
|
|
176
|
+
let fits = false;
|
|
177
|
+
const frame = renderFrame(theme, width, modalHeight(tui), {
|
|
178
|
+
header: () => theme.fg("accent", theme.bold(options.title)),
|
|
179
|
+
body: (rows, innerWidth) => {
|
|
180
|
+
fits = rows >= privacy.length + lines.length;
|
|
181
|
+
if (!fits) {
|
|
182
|
+
// Most important first: the body may have only a row or two.
|
|
183
|
+
const clear = notice ? "; any key clears the message below" : "";
|
|
184
|
+
return wrapTextWithAnsi(theme.fg("warning", `Enlarge the terminal to edit (about 22 rows). Ctrl+C still copies the text${clear}.`), innerWidth);
|
|
185
|
+
}
|
|
186
|
+
const subtitle = wrapTextWithAnsi(theme.fg("dim", options.subtitle), innerWidth).slice(0, rows - privacy.length - lines.length);
|
|
187
|
+
// Body rows start under the top border, header and rule.
|
|
188
|
+
editorArea = { top: 3 + privacy.length + subtitle.length, left: 1, width: innerWidth, height: lines.length };
|
|
189
|
+
return [...privacy, ...subtitle, ...lines];
|
|
190
|
+
},
|
|
191
|
+
notice,
|
|
192
|
+
// Hints change to discardArmed on first Esc without altering row height.
|
|
193
|
+
hints: discardArmed
|
|
194
|
+
? "Esc again to discard · Ctrl+S save · Ctrl+C copy"
|
|
195
|
+
: "Ctrl+S save · Ctrl+K clear · Ctrl+C copy · Esc close",
|
|
196
|
+
fill: false,
|
|
197
|
+
// A long notice shrinks before it can push the editor or the privacy line off screen.
|
|
198
|
+
minBody: privacy.length + lines.length,
|
|
199
|
+
});
|
|
200
|
+
tooSmall = !fits;
|
|
201
|
+
return frame;
|
|
202
|
+
},
|
|
203
|
+
invalidate: () => editor.invalidate(),
|
|
204
|
+
handleMouse: (event: BroMouseEvent) => {
|
|
205
|
+
if (discardArmed && event.type === "click") {
|
|
206
|
+
discardArmed = false;
|
|
207
|
+
tui.requestRender();
|
|
208
|
+
}
|
|
209
|
+
return delegateMouse(editor, event, editorArea);
|
|
173
210
|
},
|
|
174
|
-
invalidate: () => container.invalidate(),
|
|
175
|
-
handleMouse: consumeWheel,
|
|
176
211
|
handleInput: (data: string) => {
|
|
177
212
|
if (matchesKey(data, "escape")) {
|
|
213
|
+
if (pending) {
|
|
214
|
+
closeRequested = true;
|
|
215
|
+
showNotice("Saving… closes when done", "info");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const isDirty = editor.getExpandedText() !== lastSavedText;
|
|
219
|
+
if (isDirty && !discardArmed) {
|
|
220
|
+
discardArmed = true;
|
|
221
|
+
tui.requestRender();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
178
224
|
disposed = true;
|
|
179
225
|
done(undefined);
|
|
180
226
|
return;
|
|
181
227
|
}
|
|
182
|
-
if (
|
|
228
|
+
if (discardArmed) {
|
|
229
|
+
discardArmed = false;
|
|
230
|
+
tui.requestRender();
|
|
231
|
+
}
|
|
232
|
+
// Copy works even when the editor does not fit, so a draft can always be rescued.
|
|
233
|
+
if (matchesKey(data, "ctrl+c")) {
|
|
234
|
+
const text = editor.getExpandedText();
|
|
235
|
+
void Promise.resolve()
|
|
236
|
+
.then(() => copy(text))
|
|
237
|
+
.then(() => showNotice("Copied", "success"))
|
|
238
|
+
.catch((error) => showNotice(`Copy failed: ${errorMessage(error)}`, "error"));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
// Even a one-line notice can tip the editor out at the edge sizes; any key clears it.
|
|
242
|
+
if (tooSmall && notice) {
|
|
243
|
+
notice = "";
|
|
244
|
+
tui.requestRender();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (pending || tooSmall) return;
|
|
183
248
|
if (matchesKey(data, "ctrl+s")) {
|
|
184
249
|
const text = editor.getExpandedText();
|
|
185
250
|
const invalid = validate?.(text);
|
|
@@ -187,19 +252,18 @@ export function createTextEditorModal(
|
|
|
187
252
|
showNotice(invalid, "error");
|
|
188
253
|
return;
|
|
189
254
|
}
|
|
190
|
-
persist(() => onSave(text), "Saved", "Save failed")
|
|
255
|
+
persist(() => onSave(text), "Saved", "Save failed", () => {
|
|
256
|
+
lastSavedText = text;
|
|
257
|
+
discardArmed = false;
|
|
258
|
+
});
|
|
191
259
|
return;
|
|
192
260
|
}
|
|
193
261
|
if (matchesKey(data, "ctrl+k")) {
|
|
194
|
-
persist(onClear, "Cleared", "Clear failed", () =>
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
void Promise.resolve()
|
|
200
|
-
.then(() => copy(text))
|
|
201
|
-
.then(() => showNotice("Copied", "success"))
|
|
202
|
-
.catch((error) => showNotice(`Copy failed: ${errorMessage(error)}`, "error"));
|
|
262
|
+
persist(onClear, "Cleared", "Clear failed", () => {
|
|
263
|
+
editor.setText("");
|
|
264
|
+
lastSavedText = "";
|
|
265
|
+
discardArmed = false;
|
|
266
|
+
});
|
|
203
267
|
return;
|
|
204
268
|
}
|
|
205
269
|
if (matchesKey(data, "enter")) {
|
|
@@ -223,7 +287,8 @@ export function createAdvisorSteerModal(
|
|
|
223
287
|
): ReturnType<typeof createTextEditorModal> {
|
|
224
288
|
return createTextEditorModal({
|
|
225
289
|
title: "Bro · advisor steer",
|
|
226
|
-
|
|
290
|
+
privacy: "Never sent to the main model.",
|
|
291
|
+
subtitle: `Session-specific steering brief for the advisor (takes precedence over ${ADVISOR_STEERING_FILE}).`,
|
|
227
292
|
initialText,
|
|
228
293
|
onSave,
|
|
229
294
|
onClear,
|
|
@@ -248,9 +313,11 @@ export function createPreferencesModal(
|
|
|
248
313
|
const starter = loaded.text === undefined && !loaded.error;
|
|
249
314
|
return createTextEditorModal({
|
|
250
315
|
title: "Bro · preferences",
|
|
251
|
-
|
|
316
|
+
privacy: "Never sent to the advisor or Pi's main model.",
|
|
317
|
+
subtitle: "About you and how you like answers; sent to the selected backend with every explain, show, btw, and Guided Review request.",
|
|
252
318
|
initialText: starter ? STARTER_PREFERENCES : (loaded.text ?? ""),
|
|
253
|
-
initialNotice: loaded.error ?? (starter ? "Starter text — not saved. Ctrl+S saves it
|
|
319
|
+
initialNotice: loaded.error ?? (starter ? "Starter text — not saved. Ctrl+S saves it." : undefined),
|
|
320
|
+
initialNoticeTone: loaded.error ? "error" : "info",
|
|
254
321
|
validate: preferencesTooLong,
|
|
255
322
|
onSave: persist.save,
|
|
256
323
|
onClear: persist.clear,
|
|
@@ -268,18 +335,16 @@ export async function showPreferencesModal(ctx: ExtensionCommandContext): Promis
|
|
|
268
335
|
const text = await readPreferencesRaw();
|
|
269
336
|
loaded = text === undefined ? {} : { text };
|
|
270
337
|
} catch (error) {
|
|
271
|
-
|
|
338
|
+
// Action first: long notices are cut after three lines.
|
|
339
|
+
loaded = { error: `Unusable preferences file (Ctrl+K deletes it): ${errorMessage(error)}` };
|
|
272
340
|
}
|
|
273
|
-
await
|
|
341
|
+
await openBroOverlay<void>(
|
|
342
|
+
ctx,
|
|
274
343
|
createPreferencesModal(loaded, {
|
|
275
344
|
// ponytail: last writer wins against external edits while the editor is open, like writeSettings.
|
|
276
345
|
save: (text) => queuePreferencesWrite(() => writeFile(PREFERENCES_FILE, `${text.trim()}\n`, "utf8")),
|
|
277
346
|
clear: () => queuePreferencesWrite(() => rm(PREFERENCES_FILE, { force: true })),
|
|
278
347
|
}),
|
|
279
|
-
{
|
|
280
|
-
overlay: true,
|
|
281
|
-
overlayOptions: { width: "78%", minWidth: 48, maxHeight: "78%", anchor: "top-center", margin: { top: 1, left: 2, right: 2 } },
|
|
282
|
-
},
|
|
283
348
|
);
|
|
284
349
|
}
|
|
285
350
|
|
|
@@ -289,16 +354,13 @@ export async function showAdvisorSteerModal(ctx: ExtensionCommandContext, pi: Ex
|
|
|
289
354
|
return;
|
|
290
355
|
}
|
|
291
356
|
const { steering } = resolveAdvisorState(ctx.sessionManager.getBranch());
|
|
292
|
-
await
|
|
357
|
+
await openBroOverlay<void>(
|
|
358
|
+
ctx,
|
|
293
359
|
createAdvisorSteerModal(
|
|
294
360
|
steering,
|
|
295
361
|
(text) => pi.appendEntry(ADVISOR_STEERING_ENTRY, { text }),
|
|
296
362
|
() => pi.appendEntry(ADVISOR_STEERING_ENTRY, { text: "" }),
|
|
297
363
|
),
|
|
298
|
-
{
|
|
299
|
-
overlay: true,
|
|
300
|
-
overlayOptions: { width: "78%", minWidth: 48, maxHeight: "78%", anchor: "top-center", margin: { top: 1, left: 2, right: 2 } },
|
|
301
|
-
},
|
|
302
364
|
);
|
|
303
365
|
}
|
|
304
366
|
|
|
@@ -335,7 +397,7 @@ Quick reference. Full user guide: https://github.com/tranhoangnguyen03/pi-bro/bl
|
|
|
335
397
|
- \`/bro guided-review <PR number or URL>\` — automatically explain and assess a PR with captured code and optional private questions (local Pi interactive only; Git and authenticated gh required).
|
|
336
398
|
- Your work is saved automatically. Open the same PR to return without a model call; \`/bro guided-review\` lists saved reviews (\`resume\` is an alias). Reopening keeps the captured revision, not newer commits.
|
|
337
399
|
- Enter opens an item or focuses actions; Enter on a highlighted action activates it. Tab changes focus. Esc goes back, then closes from Contents. Close saves automatically; Stop and close stops active work first.
|
|
338
|
-
- Copy finding copies to the system clipboard, not GitHub. Regenerate guide is optional and makes a confirmed call about the same captured code.
|
|
400
|
+
- Copy finding copies to the system clipboard, not GitHub. Regenerate guide is optional and makes a confirmed call about the same captured code. Claude and Muse use strict file-only inspection (shell, writes and web tools disabled) and are recommended for untrusted PRs. Codex and Grok run shell commands in a read-only sandbox; Agy's sandboxed shell still allows project writes. All three show an advisory. Set a review override in /bro config; saved reviews remain readable with any backend. Repository text can still mislead the model; citations are not proof.
|
|
339
401
|
|
|
340
402
|
## Side conversation
|
|
341
403
|
|
|
@@ -352,7 +414,7 @@ Quick reference. Full user guide: https://github.com/tranhoangnguyen03/pi-bro/bl
|
|
|
352
414
|
## Configure and check
|
|
353
415
|
|
|
354
416
|
- \`/bro preferences\` — tell Bro about yourself and how you like answers; added to explain, show, btw, and Guided Review prompts, never the advisor (**Ctrl+S** save, **Ctrl+K** delete, **Ctrl+C** copy, **Esc** close)
|
|
355
|
-
- \`/bro config\` — shared default and per-capability (explain/show/btw/advisor/review) backend, model, and effort; explain mode; show turns. Changes save immediately.
|
|
417
|
+
- \`/bro config\` — shared default and per-capability (explain/show/btw/advisor/review) backend, model, and effort; explain mode; show turns. Changes save immediately. If the terminal is too small, Esc closes config after pending saves finish; a failed save keeps it open.
|
|
356
418
|
- \`/bro mode [brief|balanced|faithful]\` — view or choose the explanation mode
|
|
357
419
|
- \`/bro doctor\` — check settings, preferences, and every selected backend without running a model turn
|
|
358
420
|
|
|
@@ -375,7 +437,7 @@ The mode decides how much of the source to keep; your preferences decide who it'
|
|
|
375
437
|
|
|
376
438
|
## Controls
|
|
377
439
|
|
|
378
|
-
- **Mouse wheel / trackpad**, **↑ /
|
|
440
|
+
- **Mouse wheel / trackpad**, **↑ / ↓**, **PgUp / PgDn** — scroll
|
|
379
441
|
- **C** — copy the full explanation
|
|
380
442
|
- **R** — repeat the current action (an explanation keeps its mode)
|
|
381
443
|
- **M** — re-simplify in the next mode (brief → balanced → faithful); not saved
|
|
@@ -395,10 +457,13 @@ export class BroModal implements Focusable {
|
|
|
395
457
|
private rawText = "";
|
|
396
458
|
private sourceLabel = "";
|
|
397
459
|
private modelLabel = "";
|
|
398
|
-
private
|
|
399
|
-
private
|
|
400
|
-
private
|
|
401
|
-
private
|
|
460
|
+
private noticeText = "";
|
|
461
|
+
private noticeTone: NoticeTone = "info";
|
|
462
|
+
private rawArea: Area | undefined;
|
|
463
|
+
private readonly scroller = new Scroller(() => {
|
|
464
|
+
this.noticeText = "";
|
|
465
|
+
this.tui.requestRender();
|
|
466
|
+
});
|
|
402
467
|
private copyable = false;
|
|
403
468
|
private retryable = false;
|
|
404
469
|
private disposed = false;
|
|
@@ -416,9 +481,7 @@ export class BroModal implements Focusable {
|
|
|
416
481
|
private readonly onDispose: () => void,
|
|
417
482
|
private readonly retryLabel: string,
|
|
418
483
|
private readonly onSwitchMode: () => void = () => {},
|
|
419
|
-
) {
|
|
420
|
-
setRegularMouseReporting(this.tui, true);
|
|
421
|
-
}
|
|
484
|
+
) {}
|
|
422
485
|
|
|
423
486
|
setLoading(text = LOADING_TEXT): void {
|
|
424
487
|
this.setContent("loading", `**${text}**`, "", false, false);
|
|
@@ -428,8 +491,23 @@ export class BroModal implements Focusable {
|
|
|
428
491
|
this.setContent("streaming", text, "", false, false);
|
|
429
492
|
}
|
|
430
493
|
|
|
431
|
-
|
|
432
|
-
this.
|
|
494
|
+
get notice(): string {
|
|
495
|
+
return this.noticeText;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
set notice(text: string) {
|
|
499
|
+
this.setNotice(text);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
setResult(text: string, retryable: boolean, notice = "", sourceLabel = "", rawText = text, modelLabel = "", noticeTone: NoticeTone = "info"): void {
|
|
503
|
+
this.setContent("result", text, rawText, true, retryable, notice, sourceLabel, modelLabel, noticeTone);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
setNotice(text: string, tone: NoticeTone = "info"): void {
|
|
507
|
+
if (this.disposed) return;
|
|
508
|
+
this.noticeText = text;
|
|
509
|
+
this.noticeTone = tone;
|
|
510
|
+
this.tui.requestRender();
|
|
433
511
|
}
|
|
434
512
|
|
|
435
513
|
setHtmlPath(path: string): void {
|
|
@@ -464,39 +542,25 @@ export class BroModal implements Focusable {
|
|
|
464
542
|
notice = "",
|
|
465
543
|
sourceLabel = "",
|
|
466
544
|
modelLabel = "",
|
|
545
|
+
noticeTone: NoticeTone = "info",
|
|
467
546
|
): void {
|
|
468
547
|
this.kind = kind;
|
|
469
548
|
if (kind !== "result") this.htmlPath = "";
|
|
470
549
|
this.rawText = rawText;
|
|
471
550
|
this.copyable = copyable;
|
|
472
551
|
this.retryable = retryable;
|
|
473
|
-
this.
|
|
552
|
+
this.noticeText = notice;
|
|
553
|
+
this.noticeTone = noticeTone;
|
|
474
554
|
this.sourceLabel = stripVTControlCharacters(sourceLabel)
|
|
475
555
|
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
476
556
|
.replace(/\s+/g, " ")
|
|
477
557
|
.trim();
|
|
478
558
|
this.modelLabel = modelLabel;
|
|
479
|
-
if (kind !== "streaming") this.
|
|
559
|
+
if (kind !== "streaming") this.scroller.toStart();
|
|
480
560
|
this.markdown.setText(text);
|
|
481
561
|
this.tui.requestRender();
|
|
482
562
|
}
|
|
483
563
|
|
|
484
|
-
private frameLine(content: string, innerWidth: number): string {
|
|
485
|
-
const truncated = truncateToWidth(content, innerWidth, "");
|
|
486
|
-
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
487
|
-
return `${this.theme.fg("border", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
|
|
491
|
-
const left = edge === "top" ? "┌" : "└";
|
|
492
|
-
const right = edge === "top" ? "┐" : "┘";
|
|
493
|
-
return this.theme.fg("border", `${left}${"─".repeat(innerWidth)}${right}`);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
private ruleLine(innerWidth: number): string {
|
|
497
|
-
return this.theme.fg("border", `├${"─".repeat(innerWidth)}┤`);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
564
|
private canSwitchMode(): boolean {
|
|
501
565
|
return Boolean(this.modeLabel) && (this.kind === "loading" || this.kind === "streaming" || this.kind === "result");
|
|
502
566
|
}
|
|
@@ -514,55 +578,28 @@ export class BroModal implements Focusable {
|
|
|
514
578
|
}
|
|
515
579
|
|
|
516
580
|
render(width: number): string[] {
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
const lines = [
|
|
532
|
-
this.borderLine(innerWidth, "top"),
|
|
533
|
-
this.frameLine(
|
|
534
|
-
this.theme.fg("accent", this.theme.bold(`Bro${this.sourceLabel ? ` · ${this.sourceLabel}` : ""}`)) +
|
|
535
|
-
this.theme.fg("dim", `${this.modelLabel ? ` · ${this.modelLabel}` : ""}${this.modeLabel ? ` · ${this.modeLabel}` : ""}${this.preferencesUsed ? " · prefs" : ""}${scroll}`),
|
|
536
|
-
innerWidth,
|
|
537
|
-
),
|
|
538
|
-
this.ruleLine(innerWidth),
|
|
539
|
-
];
|
|
540
|
-
|
|
541
|
-
for (const line of visible) lines.push(this.frameLine(line, innerWidth));
|
|
542
|
-
for (let i = visible.length; i < this.bodyHeight; i++) lines.push(this.frameLine("", innerWidth));
|
|
543
|
-
|
|
544
|
-
lines.push(this.ruleLine(innerWidth));
|
|
545
|
-
lines.push(this.frameLine(this.theme.fg("dim", controls), innerWidth));
|
|
546
|
-
lines.push(this.borderLine(innerWidth, "bottom"));
|
|
547
|
-
return lines;
|
|
581
|
+
const height = modalHeight(this.tui, BRO_OVERLAY, 32);
|
|
582
|
+
this.rawArea = broOverlayArea(this.tui, width, height);
|
|
583
|
+
return renderFrame(this.theme, width, height, {
|
|
584
|
+
header: () => {
|
|
585
|
+
const scroll = this.scroller.indicator();
|
|
586
|
+
return (
|
|
587
|
+
this.theme.fg("accent", this.theme.bold(`Bro${this.sourceLabel ? ` · ${this.sourceLabel}` : ""}`)) +
|
|
588
|
+
this.theme.fg("dim", `${this.modelLabel ? ` · ${this.modelLabel}` : ""}${this.modeLabel ? ` · ${this.modeLabel}` : ""}${this.preferencesUsed ? " · prefs" : ""}${scroll ? ` · ${scroll}` : ""}`)
|
|
589
|
+
);
|
|
590
|
+
},
|
|
591
|
+
body: (rows, innerWidth) => this.scroller.view(this.markdown.render(innerWidth), rows),
|
|
592
|
+
notice: this.noticeText ? formatNotice(this.theme, this.noticeText, this.noticeTone) : undefined,
|
|
593
|
+
hints: this.controls(),
|
|
594
|
+
});
|
|
548
595
|
}
|
|
549
596
|
|
|
550
597
|
invalidate(): void {
|
|
551
598
|
this.markdown.invalidate();
|
|
552
599
|
}
|
|
553
600
|
|
|
554
|
-
// Pi 1.2+ fullscreen delivers the wheel here and no longer forwards raw wheel input to handleInput.
|
|
555
601
|
handleMouse(event: BroMouseEvent): BroMouseResult {
|
|
556
|
-
|
|
557
|
-
const delta = Math.trunc(event.wheelDelta ?? 0);
|
|
558
|
-
if (delta) this.scroll(delta);
|
|
559
|
-
return { handled: true, render: false };
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
private scroll(delta: number): void {
|
|
563
|
-
this.offset = Math.max(0, Math.min(this.offset + delta, this.maxOffset));
|
|
564
|
-
this.notice = "";
|
|
565
|
-
this.tui.requestRender();
|
|
602
|
+
return this.scroller.handleMouse(event);
|
|
566
603
|
}
|
|
567
604
|
|
|
568
605
|
handleInput(data: string): void {
|
|
@@ -571,25 +608,15 @@ export class BroModal implements Focusable {
|
|
|
571
608
|
return;
|
|
572
609
|
}
|
|
573
610
|
|
|
574
|
-
|
|
575
|
-
if (delta) {
|
|
576
|
-
this.scroll(delta);
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
611
|
+
if (this.scroller.handleKey(data, this.rawArea)) return;
|
|
579
612
|
|
|
580
613
|
if ((matchesKey(data, "c") || matchesKey(data, "shift+c")) && this.copyable && this.rawText) {
|
|
581
614
|
void copyToClipboard(this.rawText)
|
|
582
615
|
.then(() => {
|
|
583
|
-
|
|
584
|
-
this.notice = "Copied";
|
|
585
|
-
this.tui.requestRender();
|
|
586
|
-
}
|
|
616
|
+
this.setNotice("Copied", "success");
|
|
587
617
|
})
|
|
588
618
|
.catch((error) => {
|
|
589
|
-
|
|
590
|
-
this.notice = `Copy failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
591
|
-
this.tui.requestRender();
|
|
592
|
-
}
|
|
619
|
+
this.setNotice(`Copy failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
593
620
|
});
|
|
594
621
|
return;
|
|
595
622
|
}
|
|
@@ -609,17 +636,17 @@ export class BroModal implements Focusable {
|
|
|
609
636
|
}
|
|
610
637
|
|
|
611
638
|
if ((matchesKey(data, "o") || matchesKey(data, "shift+o")) && this.htmlPath && this.kind === "result") {
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
: `Could not open ${this.htmlPath}
|
|
615
|
-
|
|
639
|
+
const opened = openShowHtml(this.htmlPath);
|
|
640
|
+
this.setNotice(
|
|
641
|
+
opened ? "Opening diagram" : `Could not open ${this.htmlPath}`,
|
|
642
|
+
opened ? "info" : "error",
|
|
643
|
+
);
|
|
616
644
|
}
|
|
617
645
|
}
|
|
618
646
|
|
|
619
647
|
dispose(): void {
|
|
620
648
|
if (this.disposed) return;
|
|
621
649
|
this.disposed = true;
|
|
622
|
-
setRegularMouseReporting(this.tui, false);
|
|
623
650
|
this.onDispose();
|
|
624
651
|
}
|
|
625
652
|
}
|
|
@@ -650,13 +677,14 @@ export async function showBroModal(ctx: ExtensionCommandContext, options: BroMod
|
|
|
650
677
|
return;
|
|
651
678
|
}
|
|
652
679
|
|
|
653
|
-
await
|
|
680
|
+
await openBroOverlay<void>(
|
|
681
|
+
ctx,
|
|
654
682
|
(tui, theme, _keybindings, done) => {
|
|
655
683
|
let closed = false;
|
|
656
684
|
let controller: AbortController | undefined;
|
|
657
685
|
let current = options.result;
|
|
658
686
|
let inFlightMode: BroMode | undefined;
|
|
659
|
-
let execute: (source?: BroSource, mode?: BroMode, notice?: string) => void = () => {};
|
|
687
|
+
let execute: (source?: BroSource, mode?: BroMode, notice?: string, noticeTone?: NoticeTone) => void = () => {};
|
|
660
688
|
|
|
661
689
|
const close = () => {
|
|
662
690
|
if (closed) return;
|
|
@@ -686,15 +714,15 @@ export async function showBroModal(ctx: ExtensionCommandContext, options: BroMod
|
|
|
686
714
|
},
|
|
687
715
|
);
|
|
688
716
|
|
|
689
|
-
const present = (result: ModalResult, notice = "") => {
|
|
717
|
+
const present = (result: ModalResult, notice = "", noticeTone: NoticeTone = "info") => {
|
|
690
718
|
const display = result.htmlPath ? stripShowDiagramFence(result.text) : result.text;
|
|
691
|
-
modal.setResult(display, options.retryable ?? Boolean(options.run), notice, result.source?.label, result.text, result.model);
|
|
719
|
+
modal.setResult(display, options.retryable ?? Boolean(options.run), notice, result.source?.label, result.text, result.model, noticeTone);
|
|
692
720
|
modal.setHtmlPath(result.htmlPath ?? "");
|
|
693
721
|
modal.setMode(result.mode);
|
|
694
722
|
modal.setPreferences(Boolean(result.preferences));
|
|
695
723
|
};
|
|
696
724
|
|
|
697
|
-
execute = (source?: BroSource, mode?: BroMode, notice = "") => {
|
|
725
|
+
execute = (source?: BroSource, mode?: BroMode, notice = "", noticeTone: NoticeTone = "info") => {
|
|
698
726
|
if (!options.run || controller || closed) return;
|
|
699
727
|
const previous = current;
|
|
700
728
|
const nextController = new AbortController();
|
|
@@ -713,14 +741,14 @@ export async function showBroModal(ctx: ExtensionCommandContext, options: BroMod
|
|
|
713
741
|
if (closed || nextController.signal.aborted) return;
|
|
714
742
|
current = result;
|
|
715
743
|
options.onResult?.(result);
|
|
716
|
-
present(result, notice);
|
|
744
|
+
present(result, notice, noticeTone);
|
|
717
745
|
})
|
|
718
746
|
.catch((error) => {
|
|
719
747
|
if (closed || nextController.signal.aborted) return;
|
|
720
748
|
const message = error instanceof Error ? error.message : String(error);
|
|
721
749
|
if (previous) {
|
|
722
750
|
current = previous;
|
|
723
|
-
present(previous, `Retry failed: ${message}
|
|
751
|
+
present(previous, `Retry failed: ${message}`, "error");
|
|
724
752
|
} else {
|
|
725
753
|
modal.setMode(undefined);
|
|
726
754
|
modal.setError(message);
|
|
@@ -744,15 +772,6 @@ export async function showBroModal(ctx: ExtensionCommandContext, options: BroMod
|
|
|
744
772
|
|
|
745
773
|
return modal;
|
|
746
774
|
},
|
|
747
|
-
{
|
|
748
|
-
overlay: true,
|
|
749
|
-
overlayOptions: {
|
|
750
|
-
width: "78%",
|
|
751
|
-
minWidth: 48,
|
|
752
|
-
maxHeight: "78%",
|
|
753
|
-
anchor: "top-center",
|
|
754
|
-
margin: { top: 1, left: 2, right: 2 },
|
|
755
|
-
},
|
|
756
|
-
},
|
|
775
|
+
{ mouse: true },
|
|
757
776
|
);
|
|
758
777
|
}
|