pi-bro 0.23.1 → 0.25.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 +39 -0
- package/README.md +40 -25
- package/docs/README.md +32 -14
- package/docs/elevate.md +125 -0
- package/docs/examples.md +268 -0
- package/docs/features/advisor.md +70 -0
- package/docs/features/btw.md +51 -0
- package/docs/features/explain.md +92 -0
- package/docs/features/show.md +154 -0
- package/docs/getting-started.md +47 -0
- package/docs/guided-review.md +8 -12
- package/docs/pig-compatibility.md +1 -0
- package/docs/reference/commands.md +50 -0
- package/docs/reference/data-and-permissions.md +130 -0
- package/docs/reference/settings.md +108 -0
- package/docs/reference/troubleshooting.md +55 -0
- package/docs/user-guide.md +49 -881
- package/package.json +13 -3
- package/src/advisor.ts +3 -3
- package/src/backend.ts +65 -42
- package/src/bro.ts +25 -0
- package/src/btw.ts +100 -116
- package/src/config-ui.ts +71 -44
- package/src/doctor.ts +21 -24
- package/src/elevate-engine.ts +214 -0
- package/src/elevate.ts +597 -0
- package/src/explain-modal.ts +199 -174
- package/src/modal-kit.ts +291 -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
|
|
|
@@ -333,26 +395,32 @@ Quick reference. Full user guide: https://github.com/tranhoangnguyen03/pi-bro/bl
|
|
|
333
395
|
## Guided Review
|
|
334
396
|
|
|
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
|
-
-
|
|
337
|
-
- Enter opens
|
|
338
|
-
- Copy finding
|
|
398
|
+
- Reviews save automatically. Reopen the same PR without a model call; bare \`/bro guided-review\` lists saved reviews (\`resume\` is an alias). Captured code stays unchanged.
|
|
399
|
+
- Enter opens items or activates focused actions. Tab changes focus; Esc goes back, then closes from Contents. Close saves; Stop and close cancels active work first.
|
|
400
|
+
- Copy finding uses the clipboard, not GitHub. Regenerate requires confirmation and reviews the same capture. For untrusted PRs, prefer Claude/Muse: file-only tools, no shell/writes/web. Codex/Grok allow shell in a read-only sandbox; Agy's sandbox permits project writes. All three show an advisory. Choose a review override in /bro config. Repository text can mislead; citations are not proof.
|
|
401
|
+
|
|
402
|
+
## Elevate
|
|
403
|
+
|
|
404
|
+
- \`/bro elevate [request]\` — improve a task before sending it. Ctrl+Shift+E uses the draft. Choose Messages/Full context; Space/Enter ticks findings; Tab focuses actions.
|
|
405
|
+
- Enter adds a line; Ctrl+S or Ctrl+Enter discovers gaps. Use labelled buttons for actions.
|
|
406
|
+
- Shares BTW settings; see docs/elevate.md.
|
|
339
407
|
|
|
340
408
|
## Side conversation
|
|
341
409
|
|
|
342
410
|
- \`/bro btw [question]\` — open a side conversation seeded with recent main-session context. It starts conversation-only; reopening keeps the thread and its mode.
|
|
343
|
-
-
|
|
411
|
+
- Enter asks; empty Enter or \`/retry\` re-asks. \`/mode\` toggles conversation-only/full workspace access, retaining the thread. \`/copy\`, \`/copy-all\` copy; \`/insert\`, \`/insert-all\` fill an empty main editor without submitting. \`/clear\` resets; Esc closes.
|
|
344
412
|
|
|
345
413
|
## Advisor
|
|
346
414
|
|
|
347
415
|
- \`bro_advisor\` — a tool the executor agent may call for a second opinion from a fresh backend process with real, auto-approved workspace access; it is told to advise, not edit, but that is not enforced
|
|
348
416
|
- \`/bro advisor\` — whether \`bro_advisor\` is available right now
|
|
349
417
|
- \`/bro advisor-steer\` — edit the session's persistent steering brief (**Ctrl+S** save, **Ctrl+K** clear, **Ctrl+C** copy, **Esc** close)
|
|
350
|
-
- Standing advisor steering
|
|
418
|
+
- Standing advisor steering sets cross-session priorities; session steering takes precedence.
|
|
351
419
|
|
|
352
420
|
## Configure and check
|
|
353
421
|
|
|
354
|
-
- \`/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.
|
|
422
|
+
- \`/bro preferences\` — tell Bro about yourself and how you like answers; added to explain, show, btw, Elevate and Guided Review prompts, never the advisor (**Ctrl+S** save, **Ctrl+K** delete, **Ctrl+C** copy, **Esc** close)
|
|
423
|
+
- \`/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
424
|
- \`/bro mode [brief|balanced|faithful]\` — view or choose the explanation mode
|
|
357
425
|
- \`/bro doctor\` — check settings, preferences, and every selected backend without running a model turn
|
|
358
426
|
|
|
@@ -375,7 +443,7 @@ The mode decides how much of the source to keep; your preferences decide who it'
|
|
|
375
443
|
|
|
376
444
|
## Controls
|
|
377
445
|
|
|
378
|
-
- **Mouse wheel / trackpad**, **↑ /
|
|
446
|
+
- **Mouse wheel / trackpad**, **↑ / ↓**, **PgUp / PgDn** — scroll
|
|
379
447
|
- **C** — copy the full explanation
|
|
380
448
|
- **R** — repeat the current action (an explanation keeps its mode)
|
|
381
449
|
- **M** — re-simplify in the next mode (brief → balanced → faithful); not saved
|
|
@@ -395,10 +463,13 @@ export class BroModal implements Focusable {
|
|
|
395
463
|
private rawText = "";
|
|
396
464
|
private sourceLabel = "";
|
|
397
465
|
private modelLabel = "";
|
|
398
|
-
private
|
|
399
|
-
private
|
|
400
|
-
private
|
|
401
|
-
private
|
|
466
|
+
private noticeText = "";
|
|
467
|
+
private noticeTone: NoticeTone = "info";
|
|
468
|
+
private rawArea: Area | undefined;
|
|
469
|
+
private readonly scroller = new Scroller(() => {
|
|
470
|
+
this.noticeText = "";
|
|
471
|
+
this.tui.requestRender();
|
|
472
|
+
});
|
|
402
473
|
private copyable = false;
|
|
403
474
|
private retryable = false;
|
|
404
475
|
private disposed = false;
|
|
@@ -416,9 +487,7 @@ export class BroModal implements Focusable {
|
|
|
416
487
|
private readonly onDispose: () => void,
|
|
417
488
|
private readonly retryLabel: string,
|
|
418
489
|
private readonly onSwitchMode: () => void = () => {},
|
|
419
|
-
) {
|
|
420
|
-
setRegularMouseReporting(this.tui, true);
|
|
421
|
-
}
|
|
490
|
+
) {}
|
|
422
491
|
|
|
423
492
|
setLoading(text = LOADING_TEXT): void {
|
|
424
493
|
this.setContent("loading", `**${text}**`, "", false, false);
|
|
@@ -428,8 +497,23 @@ export class BroModal implements Focusable {
|
|
|
428
497
|
this.setContent("streaming", text, "", false, false);
|
|
429
498
|
}
|
|
430
499
|
|
|
431
|
-
|
|
432
|
-
this.
|
|
500
|
+
get notice(): string {
|
|
501
|
+
return this.noticeText;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
set notice(text: string) {
|
|
505
|
+
this.setNotice(text);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
setResult(text: string, retryable: boolean, notice = "", sourceLabel = "", rawText = text, modelLabel = "", noticeTone: NoticeTone = "info"): void {
|
|
509
|
+
this.setContent("result", text, rawText, true, retryable, notice, sourceLabel, modelLabel, noticeTone);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
setNotice(text: string, tone: NoticeTone = "info"): void {
|
|
513
|
+
if (this.disposed) return;
|
|
514
|
+
this.noticeText = text;
|
|
515
|
+
this.noticeTone = tone;
|
|
516
|
+
this.tui.requestRender();
|
|
433
517
|
}
|
|
434
518
|
|
|
435
519
|
setHtmlPath(path: string): void {
|
|
@@ -464,39 +548,25 @@ export class BroModal implements Focusable {
|
|
|
464
548
|
notice = "",
|
|
465
549
|
sourceLabel = "",
|
|
466
550
|
modelLabel = "",
|
|
551
|
+
noticeTone: NoticeTone = "info",
|
|
467
552
|
): void {
|
|
468
553
|
this.kind = kind;
|
|
469
554
|
if (kind !== "result") this.htmlPath = "";
|
|
470
555
|
this.rawText = rawText;
|
|
471
556
|
this.copyable = copyable;
|
|
472
557
|
this.retryable = retryable;
|
|
473
|
-
this.
|
|
558
|
+
this.noticeText = notice;
|
|
559
|
+
this.noticeTone = noticeTone;
|
|
474
560
|
this.sourceLabel = stripVTControlCharacters(sourceLabel)
|
|
475
561
|
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
476
562
|
.replace(/\s+/g, " ")
|
|
477
563
|
.trim();
|
|
478
564
|
this.modelLabel = modelLabel;
|
|
479
|
-
if (kind !== "streaming") this.
|
|
565
|
+
if (kind !== "streaming") this.scroller.toStart();
|
|
480
566
|
this.markdown.setText(text);
|
|
481
567
|
this.tui.requestRender();
|
|
482
568
|
}
|
|
483
569
|
|
|
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
570
|
private canSwitchMode(): boolean {
|
|
501
571
|
return Boolean(this.modeLabel) && (this.kind === "loading" || this.kind === "streaming" || this.kind === "result");
|
|
502
572
|
}
|
|
@@ -514,55 +584,28 @@ export class BroModal implements Focusable {
|
|
|
514
584
|
}
|
|
515
585
|
|
|
516
586
|
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;
|
|
587
|
+
const height = modalHeight(this.tui, BRO_OVERLAY, 32);
|
|
588
|
+
this.rawArea = broOverlayArea(this.tui, width, height);
|
|
589
|
+
return renderFrame(this.theme, width, height, {
|
|
590
|
+
header: () => {
|
|
591
|
+
const scroll = this.scroller.indicator();
|
|
592
|
+
return (
|
|
593
|
+
this.theme.fg("accent", this.theme.bold(`Bro${this.sourceLabel ? ` · ${this.sourceLabel}` : ""}`)) +
|
|
594
|
+
this.theme.fg("dim", `${this.modelLabel ? ` · ${this.modelLabel}` : ""}${this.modeLabel ? ` · ${this.modeLabel}` : ""}${this.preferencesUsed ? " · prefs" : ""}${scroll ? ` · ${scroll}` : ""}`)
|
|
595
|
+
);
|
|
596
|
+
},
|
|
597
|
+
body: (rows, innerWidth) => this.scroller.view(this.markdown.render(innerWidth), rows),
|
|
598
|
+
notice: this.noticeText ? formatNotice(this.theme, this.noticeText, this.noticeTone) : undefined,
|
|
599
|
+
hints: this.controls(),
|
|
600
|
+
});
|
|
548
601
|
}
|
|
549
602
|
|
|
550
603
|
invalidate(): void {
|
|
551
604
|
this.markdown.invalidate();
|
|
552
605
|
}
|
|
553
606
|
|
|
554
|
-
// Pi 1.2+ fullscreen delivers the wheel here and no longer forwards raw wheel input to handleInput.
|
|
555
607
|
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();
|
|
608
|
+
return this.scroller.handleMouse(event);
|
|
566
609
|
}
|
|
567
610
|
|
|
568
611
|
handleInput(data: string): void {
|
|
@@ -571,25 +614,15 @@ export class BroModal implements Focusable {
|
|
|
571
614
|
return;
|
|
572
615
|
}
|
|
573
616
|
|
|
574
|
-
|
|
575
|
-
if (delta) {
|
|
576
|
-
this.scroll(delta);
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
617
|
+
if (this.scroller.handleKey(data, this.rawArea)) return;
|
|
579
618
|
|
|
580
619
|
if ((matchesKey(data, "c") || matchesKey(data, "shift+c")) && this.copyable && this.rawText) {
|
|
581
620
|
void copyToClipboard(this.rawText)
|
|
582
621
|
.then(() => {
|
|
583
|
-
|
|
584
|
-
this.notice = "Copied";
|
|
585
|
-
this.tui.requestRender();
|
|
586
|
-
}
|
|
622
|
+
this.setNotice("Copied", "success");
|
|
587
623
|
})
|
|
588
624
|
.catch((error) => {
|
|
589
|
-
|
|
590
|
-
this.notice = `Copy failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
591
|
-
this.tui.requestRender();
|
|
592
|
-
}
|
|
625
|
+
this.setNotice(`Copy failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
593
626
|
});
|
|
594
627
|
return;
|
|
595
628
|
}
|
|
@@ -609,17 +642,17 @@ export class BroModal implements Focusable {
|
|
|
609
642
|
}
|
|
610
643
|
|
|
611
644
|
if ((matchesKey(data, "o") || matchesKey(data, "shift+o")) && this.htmlPath && this.kind === "result") {
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
: `Could not open ${this.htmlPath}
|
|
615
|
-
|
|
645
|
+
const opened = openShowHtml(this.htmlPath);
|
|
646
|
+
this.setNotice(
|
|
647
|
+
opened ? "Opening diagram" : `Could not open ${this.htmlPath}`,
|
|
648
|
+
opened ? "info" : "error",
|
|
649
|
+
);
|
|
616
650
|
}
|
|
617
651
|
}
|
|
618
652
|
|
|
619
653
|
dispose(): void {
|
|
620
654
|
if (this.disposed) return;
|
|
621
655
|
this.disposed = true;
|
|
622
|
-
setRegularMouseReporting(this.tui, false);
|
|
623
656
|
this.onDispose();
|
|
624
657
|
}
|
|
625
658
|
}
|
|
@@ -650,13 +683,14 @@ export async function showBroModal(ctx: ExtensionCommandContext, options: BroMod
|
|
|
650
683
|
return;
|
|
651
684
|
}
|
|
652
685
|
|
|
653
|
-
await
|
|
686
|
+
await openBroOverlay<void>(
|
|
687
|
+
ctx,
|
|
654
688
|
(tui, theme, _keybindings, done) => {
|
|
655
689
|
let closed = false;
|
|
656
690
|
let controller: AbortController | undefined;
|
|
657
691
|
let current = options.result;
|
|
658
692
|
let inFlightMode: BroMode | undefined;
|
|
659
|
-
let execute: (source?: BroSource, mode?: BroMode, notice?: string) => void = () => {};
|
|
693
|
+
let execute: (source?: BroSource, mode?: BroMode, notice?: string, noticeTone?: NoticeTone) => void = () => {};
|
|
660
694
|
|
|
661
695
|
const close = () => {
|
|
662
696
|
if (closed) return;
|
|
@@ -686,15 +720,15 @@ export async function showBroModal(ctx: ExtensionCommandContext, options: BroMod
|
|
|
686
720
|
},
|
|
687
721
|
);
|
|
688
722
|
|
|
689
|
-
const present = (result: ModalResult, notice = "") => {
|
|
723
|
+
const present = (result: ModalResult, notice = "", noticeTone: NoticeTone = "info") => {
|
|
690
724
|
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);
|
|
725
|
+
modal.setResult(display, options.retryable ?? Boolean(options.run), notice, result.source?.label, result.text, result.model, noticeTone);
|
|
692
726
|
modal.setHtmlPath(result.htmlPath ?? "");
|
|
693
727
|
modal.setMode(result.mode);
|
|
694
728
|
modal.setPreferences(Boolean(result.preferences));
|
|
695
729
|
};
|
|
696
730
|
|
|
697
|
-
execute = (source?: BroSource, mode?: BroMode, notice = "") => {
|
|
731
|
+
execute = (source?: BroSource, mode?: BroMode, notice = "", noticeTone: NoticeTone = "info") => {
|
|
698
732
|
if (!options.run || controller || closed) return;
|
|
699
733
|
const previous = current;
|
|
700
734
|
const nextController = new AbortController();
|
|
@@ -713,14 +747,14 @@ export async function showBroModal(ctx: ExtensionCommandContext, options: BroMod
|
|
|
713
747
|
if (closed || nextController.signal.aborted) return;
|
|
714
748
|
current = result;
|
|
715
749
|
options.onResult?.(result);
|
|
716
|
-
present(result, notice);
|
|
750
|
+
present(result, notice, noticeTone);
|
|
717
751
|
})
|
|
718
752
|
.catch((error) => {
|
|
719
753
|
if (closed || nextController.signal.aborted) return;
|
|
720
754
|
const message = error instanceof Error ? error.message : String(error);
|
|
721
755
|
if (previous) {
|
|
722
756
|
current = previous;
|
|
723
|
-
present(previous, `Retry failed: ${message}
|
|
757
|
+
present(previous, `Retry failed: ${message}`, "error");
|
|
724
758
|
} else {
|
|
725
759
|
modal.setMode(undefined);
|
|
726
760
|
modal.setError(message);
|
|
@@ -744,15 +778,6 @@ export async function showBroModal(ctx: ExtensionCommandContext, options: BroMod
|
|
|
744
778
|
|
|
745
779
|
return modal;
|
|
746
780
|
},
|
|
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
|
-
},
|
|
781
|
+
{ mouse: true },
|
|
757
782
|
);
|
|
758
783
|
}
|