pi-bro 0.19.2 → 0.19.4
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 +18 -0
- package/README.md +7 -4
- package/backend.ts +17 -7
- package/bro.ts +65 -29
- package/package.json +3 -2
- package/ui-capabilities.ts +35 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-bro are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.19.4] - 2026-10-01
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Preserve advisor timeout and cancellation status through the consultation wrapper. Host deadlines and cancellation stop after one attempt without backoff; ordinary invocation failures retain the existing three-attempt policy (#72).
|
|
10
|
+
- Bound benchmark call and usage-preflight cleanup when descendants retain output pipes, reusing backend process-group termination and escalation. Ignore late output after a stop without adding benchmark retries (#72).
|
|
11
|
+
|
|
12
|
+
### Documentation
|
|
13
|
+
|
|
14
|
+
- Distinguish host deadlines from CLI-reported failures and document unverified shutdown/reload cancellation, POSIX process-group cleanup, and Windows direct-child limits (#72).
|
|
15
|
+
|
|
16
|
+
## [0.19.3] - 2026-09-30
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Send large Agy explain, Show, and BTW prompts over stdin instead of exceeding argument-size limits. Older Agy versions report an upgrade hint rather than silently falling back (#73).
|
|
21
|
+
- Retain newest turns and message tails when bounding Show and BTW context, preserving JSON message framing (#73).
|
|
22
|
+
|
|
5
23
|
## [0.19.2] - 2026-09-30
|
|
6
24
|
|
|
7
25
|
### Fixed
|
package/README.md
CHANGED
|
@@ -64,7 +64,7 @@ text directly captures a new source the same way.
|
|
|
64
64
|
|
|
65
65
|
| Command | Description |
|
|
66
66
|
| --- | --- |
|
|
67
|
-
| `/bro
|
|
67
|
+
| `/bro`, `/bro simplify`, or `/bro text` | Explain the latest completed assistant response. |
|
|
68
68
|
| `/bro text <text>` | Explain pasted text. |
|
|
69
69
|
| `/bro <input>` | Explain it directly: a lone URL runs the webpage reader, an existing workspace file with a supported extension runs the document reader, and anything else is pasted text. A quoted path with spaces is routed too when the file exists. |
|
|
70
70
|
| `/bro file <path>` | Explain a workspace-local `.md`, `.markdown`, `.txt`, `.pdf`, or `.docx` file. |
|
|
@@ -205,9 +205,12 @@ presence, and backend compatibility (for Agy, a minimum CLI version with an
|
|
|
205
205
|
more evidence" is a normal result, not a failure), Bro retries with the
|
|
206
206
|
identical snapshot, steering, and question: once after 5 seconds, once more
|
|
207
207
|
after 10 seconds, then returns the backend's own diagnostic — including a
|
|
208
|
-
context-length error, verbatim — as the failure.
|
|
209
|
-
|
|
210
|
-
|
|
208
|
+
context-length error, verbatim — as the failure. Host-imposed deadlines and
|
|
209
|
+
cancellation are terminal: neither starts another attempt or a backoff wait.
|
|
210
|
+
The advisor CLI is asked to stop at 10 minutes; Bro's host deadline is 610
|
|
211
|
+
seconds plus bounded process cleanup. A CLI-reported error before that host
|
|
212
|
+
deadline remains an invocation failure and can be retried.
|
|
213
|
+
Cancelling the tool call skips any pending retry wait.
|
|
211
214
|
- **Progress and provenance**: while an attempt is running, Bro shows the last
|
|
212
215
|
thing the advisor actually reported — a tool name or a user-facing response
|
|
213
216
|
line, never hidden reasoning — and how long ago it arrived, e.g. `last
|
package/backend.ts
CHANGED
|
@@ -123,7 +123,7 @@ const DEFAULT_KILL_ESCALATION_MS = 5_000;
|
|
|
123
123
|
// protocol failure it signals the whole POSIX process group (SIGTERM, then SIGKILL after
|
|
124
124
|
// `killEscalationMs` if the child or a misbehaving grandchild ignores it). Windows only ever
|
|
125
125
|
// reaches the immediate child directly -- there is no process-tree guarantee there.
|
|
126
|
-
function beginAttempt(child: ChildProcess, signal: AbortSignal, deadlineMs: number, killEscalationMs: number): Attempt {
|
|
126
|
+
export function beginAttempt(child: ChildProcess, signal: AbortSignal, deadlineMs: number, killEscalationMs: number): Attempt {
|
|
127
127
|
let cause: StopCause | undefined;
|
|
128
128
|
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
129
129
|
let finishClose: (value: { code: number | null; exitSignal: NodeJS.Signals | null }) => void;
|
|
@@ -211,8 +211,8 @@ export function parseBtwAgyLine(line: string): { delta?: string; result?: string
|
|
|
211
211
|
return { conversationId };
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
//
|
|
215
|
-
//
|
|
214
|
+
// Explain/show and BTW use argv for small prompts and stdin NDJSON for large
|
|
215
|
+
// prompts; sandbox, cwd, timeout, and continuation semantics stay unchanged.
|
|
216
216
|
async function executeArgvPrint(
|
|
217
217
|
request: BackendRequest,
|
|
218
218
|
selection: AgySelection,
|
|
@@ -221,6 +221,9 @@ async function executeArgvPrint(
|
|
|
221
221
|
killEscalationMs: number,
|
|
222
222
|
deadlineMsOverride: number | undefined,
|
|
223
223
|
): Promise<BackendOutcome> {
|
|
224
|
+
// Keep legacy CLI compatibility for small prompts; large inputs use the advisor's
|
|
225
|
+
// stdin protocol rather than risking Linux's per-argument byte limit.
|
|
226
|
+
const stdinPrompt = Buffer.byteLength(request.prompt, "utf8") >= 120_000;
|
|
224
227
|
const isBtw = request.feature === "btw";
|
|
225
228
|
const full = isBtw && request.access === "workspace-full";
|
|
226
229
|
const deadlineMs = deadlineMsOverride ?? (isBtw ? (full ? 610_000 : 130_000) : 125_000);
|
|
@@ -251,12 +254,11 @@ async function executeArgvPrint(
|
|
|
251
254
|
"--print-timeout",
|
|
252
255
|
printTimeout,
|
|
253
256
|
...(request.continuation ? ["--conversation", request.continuation.id] : []),
|
|
254
|
-
"--print",
|
|
255
|
-
request.prompt,
|
|
257
|
+
...(stdinPrompt ? ["--input-format", "stream-json"] : ["--print", request.prompt]),
|
|
256
258
|
],
|
|
257
259
|
{
|
|
258
260
|
cwd: full ? request.cwd : runDirectory,
|
|
259
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
261
|
+
stdio: [stdinPrompt ? "pipe" : "ignore", "pipe", "pipe"],
|
|
260
262
|
windowsHide: true,
|
|
261
263
|
detached: process.platform !== "win32",
|
|
262
264
|
},
|
|
@@ -278,7 +280,13 @@ async function executeArgvPrint(
|
|
|
278
280
|
processError = error;
|
|
279
281
|
});
|
|
280
282
|
|
|
283
|
+
if (stdinPrompt) {
|
|
284
|
+
child.stdin?.on("error", () => { /* Report early exits through the close/error path. */ });
|
|
285
|
+
child.stdin?.end(`${JSON.stringify({ event: "user", message: { content: request.prompt } })}\n`);
|
|
286
|
+
}
|
|
287
|
+
|
|
281
288
|
const lines = createInterface({ input: child.stdout!, crlfDelay: Infinity });
|
|
289
|
+
void attempt.closed.then(() => lines.close());
|
|
282
290
|
try {
|
|
283
291
|
for await (const line of lines) {
|
|
284
292
|
if (!line.trim() || attempt.causeOf()) continue;
|
|
@@ -327,7 +335,9 @@ async function executeArgvPrint(
|
|
|
327
335
|
if (exitSignal || code === null) {
|
|
328
336
|
return { status: "failure", message: unexpectedSignalMessage(exitSignal), partialText: partial || undefined };
|
|
329
337
|
}
|
|
330
|
-
if (code !== 0) return { status: "failure", message:
|
|
338
|
+
if (code !== 0) return { status: "failure", message: stdinPrompt && advisorFlagErrorHint(stderr)
|
|
339
|
+
? withDoctor("Large prompts require Agy 1.1.15+ with stdin support. Run `agy update`. " + stderr.trim())
|
|
340
|
+
: agyFailureMessage(action, { code, killed: false, stderr }) };
|
|
331
341
|
|
|
332
342
|
const text = final.trim();
|
|
333
343
|
if (!text) return { status: "failure", message: withDoctor(stderr.trim() || emptyTextMessage) };
|
package/bro.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { hasBroCustomUi, broModalRows, canBroInsertIntoEditor, insertBroDesktopText } from "./ui-capabilities.ts";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { lookup } from "node:dns/promises";
|
|
4
5
|
import { mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
@@ -9,7 +10,7 @@ import { tmpdir } from "node:os";
|
|
|
9
10
|
import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
10
11
|
import { stripVTControlCharacters } from "node:util";
|
|
11
12
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { Container, Editor, Input, Markdown, SettingsList, SelectList, Text, matchesKey, truncateToWidth, visibleWidth, type Component, type EditorTheme, type Focusable, type SelectItem, type SettingItem, type TUI } from "@earendil-works/pi-tui";
|
|
13
|
+
import { Container, Editor, Input, Markdown, SettingsList, SelectList, Text, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type EditorTheme, type Focusable, type SelectItem, type SettingItem, type TUI } from "@earendil-works/pi-tui";
|
|
13
14
|
import { convertToLlm, copyToClipboard, getAgentDir, getMarkdownTheme, getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
14
15
|
import { Defuddle } from "defuddle/node";
|
|
15
16
|
import { parseHTML } from "linkedom";
|
|
@@ -131,6 +132,7 @@ export function setRegularMouseReporting(tui: Pick<TuiLike, "mode" | "terminal">
|
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
const COMMANDS = [
|
|
135
|
+
{ value: "simplify", label: "simplify", description: "Explain the latest completed assistant reply (same as /bro)" },
|
|
134
136
|
{ value: "text", label: "text", description: "Explain pasted text, or the latest reply when text is omitted" },
|
|
135
137
|
{ value: "file", label: "file", description: "Explain a local document" },
|
|
136
138
|
{ value: "url", label: "url", description: "Explain a public webpage" },
|
|
@@ -1475,7 +1477,8 @@ function serializeShowTurns(turns: readonly ShowTurn[]): string {
|
|
|
1475
1477
|
return turns.flatMap((turn) => turn.entries).join("\n\n");
|
|
1476
1478
|
}
|
|
1477
1479
|
|
|
1478
|
-
export function captureShowTranscript(ctx: ExtensionCommandContext, turnsRequested: number): BroSource | undefined {
|
|
1480
|
+
export function captureShowTranscript(ctx: ExtensionCommandContext, turnsRequested: number, maxLength = MAX_TEXT_LENGTH): BroSource | undefined {
|
|
1481
|
+
if (!Number.isSafeInteger(maxLength) || maxLength < 128) throw new Error("Transcript limit must be an integer of at least 128 characters.");
|
|
1479
1482
|
const turns: ShowTurn[] = [];
|
|
1480
1483
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
1481
1484
|
if (entry.type !== "message") continue;
|
|
@@ -1503,7 +1506,7 @@ export function captureShowTranscript(ctx: ExtensionCommandContext, turnsRequest
|
|
|
1503
1506
|
if (seen === 0) return undefined;
|
|
1504
1507
|
|
|
1505
1508
|
let text = serializeShowTurns(turns.slice(start));
|
|
1506
|
-
while (text.length >
|
|
1509
|
+
while (text.length > maxLength && start < turns.length - 1) {
|
|
1507
1510
|
let next = turns.length;
|
|
1508
1511
|
for (let index = start + 1; index < turns.length; index += 1) {
|
|
1509
1512
|
if (turns[index]!.startsTurn) {
|
|
@@ -1515,7 +1518,22 @@ export function captureShowTranscript(ctx: ExtensionCommandContext, turnsRequest
|
|
|
1515
1518
|
start = next;
|
|
1516
1519
|
text = serializeShowTurns(turns.slice(start));
|
|
1517
1520
|
}
|
|
1518
|
-
if (text.length >
|
|
1521
|
+
if (text.length > maxLength) {
|
|
1522
|
+
// Prefer the newest complete messages even within an oversized single turn.
|
|
1523
|
+
const entries = turns.slice(start).flatMap(turn => turn.entries);
|
|
1524
|
+
const notice = "[… earlier messages truncated …]\n";
|
|
1525
|
+
while (entries.length > 1 && entries.join("\n\n").length + notice.length > maxLength) entries.shift();
|
|
1526
|
+
text = entries.join("\n\n");
|
|
1527
|
+
if (text.length + notice.length > maxLength) {
|
|
1528
|
+
const newline = text.indexOf("\n");
|
|
1529
|
+
const header = text.slice(0, newline);
|
|
1530
|
+
const content = JSON.parse(text.slice(newline + 1)) as string;
|
|
1531
|
+
const marker = "[… earlier text truncated …] ";
|
|
1532
|
+
let tail = content.slice(-(maxLength - header.length - marker.length - 8));
|
|
1533
|
+
while (`${header}\n${JSON.stringify(marker + tail)}`.length > maxLength) tail = tail.slice(Math.max(1, Math.floor(tail.length / 10)));
|
|
1534
|
+
text = `${header}\n${JSON.stringify(marker + tail)}`;
|
|
1535
|
+
} else text = notice + text;
|
|
1536
|
+
}
|
|
1519
1537
|
const kept = turns.slice(start).filter((turn) => turn.startsTurn).length;
|
|
1520
1538
|
return { text: text.trim(), label: `last ${Math.max(1, kept)} turn${kept > 1 ? "s" : ""} · conversation only` };
|
|
1521
1539
|
}
|
|
@@ -1802,6 +1820,14 @@ export type AdvisorActivityCallback = (label: string, timestamp: number) => void
|
|
|
1802
1820
|
// "Transport" for why stdin rather than --print). This function keeps its exact existing
|
|
1803
1821
|
// signature/throw contract -- it is called directly by tests and by runAdvisorWithRetries below,
|
|
1804
1822
|
// which owns the 3-attempt retry/backoff policy the backend itself never performs.
|
|
1823
|
+
export class TerminalConsultError extends Error {
|
|
1824
|
+
readonly status: "failure" | "cancelled" | "timeout";
|
|
1825
|
+
constructor(status: "failure" | "cancelled" | "timeout", message: string) {
|
|
1826
|
+
super(message);
|
|
1827
|
+
this.status = status;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1805
1831
|
export async function runAdvisorConsultation(
|
|
1806
1832
|
prompt: string,
|
|
1807
1833
|
selection: BackendSelection,
|
|
@@ -1809,6 +1835,7 @@ export async function runAdvisorConsultation(
|
|
|
1809
1835
|
signal: AbortSignal,
|
|
1810
1836
|
killEscalationMs = 5_000,
|
|
1811
1837
|
onActivity?: AdvisorActivityCallback,
|
|
1838
|
+
deadlineMs?: number,
|
|
1812
1839
|
): Promise<string> {
|
|
1813
1840
|
const outcome = await executeBackend(
|
|
1814
1841
|
{ feature: "advisor", prompt, access: "workspace-full", cwd },
|
|
@@ -1819,10 +1846,10 @@ export async function runAdvisorConsultation(
|
|
|
1819
1846
|
if (progress.kind === "activity") onActivity(progress.label, progress.timestamp);
|
|
1820
1847
|
}
|
|
1821
1848
|
: undefined,
|
|
1822
|
-
{ killEscalationMs },
|
|
1849
|
+
{ killEscalationMs, deadlineMs },
|
|
1823
1850
|
);
|
|
1824
1851
|
if (outcome.status === "success") return outcome.text;
|
|
1825
|
-
throw new
|
|
1852
|
+
throw new TerminalConsultError(outcome.status, outcome.message);
|
|
1826
1853
|
}
|
|
1827
1854
|
|
|
1828
1855
|
function advisorDelay(ms: number, signal: AbortSignal, onTick?: (remainingMs: number) => void): Promise<void> {
|
|
@@ -1976,7 +2003,8 @@ export async function runAdvisorWithRetries(
|
|
|
1976
2003
|
} catch (error) {
|
|
1977
2004
|
if (progressTimer) clearInterval(progressTimer);
|
|
1978
2005
|
stopActivityThrottle();
|
|
1979
|
-
if (signal.aborted || errorMessage(error) === "Canceled."
|
|
2006
|
+
if (signal.aborted || errorMessage(error) === "Canceled." ||
|
|
2007
|
+
(error instanceof TerminalConsultError && error.status !== "failure")) throw error;
|
|
1980
2008
|
lastError = error;
|
|
1981
2009
|
if (attempt === delays.length) break;
|
|
1982
2010
|
const delay = delays[attempt]!;
|
|
@@ -2486,7 +2514,7 @@ export function createConfigModal(
|
|
|
2486
2514
|
}
|
|
2487
2515
|
|
|
2488
2516
|
export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
|
|
2489
|
-
if (ctx
|
|
2517
|
+
if (!hasBroCustomUi(ctx)) {
|
|
2490
2518
|
ctx.ui.notify("Use /bro config in Pi's interactive UI.", "warning");
|
|
2491
2519
|
return;
|
|
2492
2520
|
}
|
|
@@ -2603,7 +2631,7 @@ export function createAdvisorSteerModal(
|
|
|
2603
2631
|
}
|
|
2604
2632
|
|
|
2605
2633
|
export async function showAdvisorSteerModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
|
|
2606
|
-
if (ctx
|
|
2634
|
+
if (!hasBroCustomUi(ctx)) {
|
|
2607
2635
|
ctx.ui.notify("Use /bro advisor-steer in Pi's interactive UI.", "warning");
|
|
2608
2636
|
return;
|
|
2609
2637
|
}
|
|
@@ -2639,7 +2667,7 @@ Quick reference. The README is the full user guide: https://github.com/tranhoang
|
|
|
2639
2667
|
|
|
2640
2668
|
## Explain and show
|
|
2641
2669
|
|
|
2642
|
-
- \`/bro\` — explain the latest completed assistant reply
|
|
2670
|
+
- \`/bro\` or \`/bro simplify\` — explain the latest completed assistant reply
|
|
2643
2671
|
- \`/bro text [text]\` — explain pasted text, or the latest reply when text is omitted
|
|
2644
2672
|
- \`/bro file <path>\` — explain a workspace \`.md\`, \`.markdown\`, \`.txt\`, \`.pdf\`, or \`.docx\` file
|
|
2645
2673
|
- \`/bro url <url>\` — explain one public webpage
|
|
@@ -2800,7 +2828,7 @@ class BroModal implements Focusable {
|
|
|
2800
2828
|
render(width: number): string[] {
|
|
2801
2829
|
const dialogWidth = Math.max(24, width);
|
|
2802
2830
|
const innerWidth = Math.max(22, dialogWidth - 2);
|
|
2803
|
-
const terminalRows = this.tui
|
|
2831
|
+
const terminalRows = broModalRows(this.tui, process.stdout.rows);
|
|
2804
2832
|
const dialogHeight = Math.min(32, Math.max(7, Math.floor(terminalRows * 0.78)));
|
|
2805
2833
|
this.bodyHeight = Math.max(1, dialogHeight - 6);
|
|
2806
2834
|
|
|
@@ -2908,7 +2936,7 @@ interface BroModalOptions {
|
|
|
2908
2936
|
}
|
|
2909
2937
|
|
|
2910
2938
|
async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptions): Promise<void> {
|
|
2911
|
-
if (ctx
|
|
2939
|
+
if (!hasBroCustomUi(ctx)) {
|
|
2912
2940
|
if (options.run && !options.result && options.text === undefined) {
|
|
2913
2941
|
const result = await options.run(new AbortController().signal);
|
|
2914
2942
|
options.onResult?.(result);
|
|
@@ -3173,6 +3201,7 @@ class BtwModal implements Focusable {
|
|
|
3173
3201
|
}
|
|
3174
3202
|
|
|
3175
3203
|
setNotice(notice: string): void {
|
|
3204
|
+
if (this.disposed) return;
|
|
3176
3205
|
this.notice = notice;
|
|
3177
3206
|
this.tui.requestRender();
|
|
3178
3207
|
}
|
|
@@ -3237,9 +3266,16 @@ class BtwModal implements Focusable {
|
|
|
3237
3266
|
render(width: number): string[] {
|
|
3238
3267
|
const dialogWidth = Math.max(24, width);
|
|
3239
3268
|
const innerWidth = Math.max(22, dialogWidth - 2);
|
|
3240
|
-
const terminalRows = this.tui
|
|
3269
|
+
const terminalRows = broModalRows(this.tui, process.stdout.rows);
|
|
3241
3270
|
const dialogHeight = Math.min(34, Math.max(8, Math.floor(terminalRows * 0.82)));
|
|
3242
|
-
|
|
3271
|
+
const footer = [
|
|
3272
|
+
...wrapTextWithAnsi(this.theme.fg("accent", this.theme.bold("Your message to Bro")), innerWidth),
|
|
3273
|
+
this.input.render(innerWidth)[0] ?? "",
|
|
3274
|
+
...wrapTextWithAnsi(this.running ? "Bro is thinking… · Esc: cancel response" : "Enter: send message · Esc: close panel", innerWidth),
|
|
3275
|
+
...wrapTextWithAnsi("Commands: /mode · /copy · /copy-all · /insert · /insert-all · /clear · /retry", innerWidth),
|
|
3276
|
+
...(this.notice ? wrapTextWithAnsi(this.theme.fg("accent", this.notice), innerWidth) : []),
|
|
3277
|
+
];
|
|
3278
|
+
this.bodyHeight = Math.max(1, dialogHeight - 5 - footer.length);
|
|
3243
3279
|
|
|
3244
3280
|
const rendered = this.markdown.render(innerWidth);
|
|
3245
3281
|
this.maxOffset = Math.max(0, rendered.length - this.bodyHeight);
|
|
@@ -3252,12 +3288,6 @@ class BtwModal implements Focusable {
|
|
|
3252
3288
|
const mode = this.theme.fg("dim", " · ") + (this.full ? this.theme.fg("accent", this.theme.bold(btwModeLabel(true))) : this.theme.fg("dim", btwModeLabel(false)));
|
|
3253
3289
|
const header = this.theme.fg("accent", this.theme.bold("Bro · btw")) + model + mode + this.theme.fg("dim", scroll);
|
|
3254
3290
|
|
|
3255
|
-
const composer = this.input.render(innerWidth)[0] ?? "";
|
|
3256
|
-
|
|
3257
|
-
const controls = this.running
|
|
3258
|
-
? this.theme.fg("dim", "Thinking… · Esc cancel")
|
|
3259
|
-
: this.theme.fg("dim", "Enter ask · Esc close · /mode · /copy · /copy-all · /insert · /insert-all · /clear · /retry");
|
|
3260
|
-
|
|
3261
3291
|
const lines = [
|
|
3262
3292
|
this.borderLine(innerWidth, "top"),
|
|
3263
3293
|
this.frameLine(header, innerWidth),
|
|
@@ -3266,8 +3296,7 @@ class BtwModal implements Focusable {
|
|
|
3266
3296
|
for (const line of visible) lines.push(this.frameLine(line, innerWidth));
|
|
3267
3297
|
for (let i = visible.length; i < this.bodyHeight; i++) lines.push(this.frameLine("", innerWidth));
|
|
3268
3298
|
lines.push(this.ruleLine(innerWidth));
|
|
3269
|
-
lines.push(this.frameLine(
|
|
3270
|
-
lines.push(this.frameLine(this.notice ? this.theme.fg("accent", this.notice) : controls, innerWidth));
|
|
3299
|
+
for (const line of footer) lines.push(this.frameLine(line, innerWidth));
|
|
3271
3300
|
lines.push(this.borderLine(innerWidth, "bottom"));
|
|
3272
3301
|
return lines;
|
|
3273
3302
|
}
|
|
@@ -3322,10 +3351,7 @@ async function openBtwModal(
|
|
|
3322
3351
|
}
|
|
3323
3352
|
if (thread.turns.length === 0) {
|
|
3324
3353
|
thread.conversationId = undefined;
|
|
3325
|
-
|
|
3326
|
-
if (context && context.length > BTW_CONTEXT_MAX) {
|
|
3327
|
-
context = `${context.slice(0, BTW_CONTEXT_MAX)}\n[… context truncated …]`;
|
|
3328
|
-
}
|
|
3354
|
+
const context = captureShowTranscript(ctx, BTW_CONTEXT_TURNS, BTW_CONTEXT_MAX)?.text;
|
|
3329
3355
|
thread.context = context;
|
|
3330
3356
|
}
|
|
3331
3357
|
// Resume natively when possible; otherwise seed the fresh native session with the main-session
|
|
@@ -3412,12 +3438,17 @@ async function openBtwModal(
|
|
|
3412
3438
|
}
|
|
3413
3439
|
};
|
|
3414
3440
|
|
|
3415
|
-
const insert = (all: boolean) => {
|
|
3441
|
+
const insert = async (all: boolean) => {
|
|
3416
3442
|
const text = all ? transcript() : (thread.turns.at(-1)?.answer ?? "");
|
|
3417
3443
|
if (!text.trim()) {
|
|
3418
3444
|
modal.setNotice("Nothing to insert yet.");
|
|
3419
3445
|
return;
|
|
3420
3446
|
}
|
|
3447
|
+
if (!canBroInsertIntoEditor(ctx)) {
|
|
3448
|
+
const result = await insertBroDesktopText(ctx, text);
|
|
3449
|
+
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.");
|
|
3450
|
+
return;
|
|
3451
|
+
}
|
|
3421
3452
|
if (ctx.ui.getEditorText().trim()) {
|
|
3422
3453
|
modal.setNotice("Main editor has a draft. Edit or clear it first, then insert again.");
|
|
3423
3454
|
return;
|
|
@@ -3616,6 +3647,11 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3616
3647
|
let action = parts[0] ?? "";
|
|
3617
3648
|
let value = raw.slice(raw.split(/\s+/, 1)[0]?.length ?? 0).trim();
|
|
3618
3649
|
|
|
3650
|
+
if (action === "simplify") {
|
|
3651
|
+
if (value) { ctx.ui.notify("Use /bro simplify for the latest reply, or /bro text <text> for pasted text.", "warning"); return; }
|
|
3652
|
+
action = "";
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3619
3655
|
// Removed commands must never fall through to a paid text explanation.
|
|
3620
3656
|
if (action === "model" || action === "effort") {
|
|
3621
3657
|
ctx.ui.notify(`/bro ${action} was removed. Use /bro config to choose the shared default and per-capability ${action}; use /bro text <text> to explain text.`, "warning");
|
|
@@ -3737,7 +3773,7 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3737
3773
|
const settings = await readSettings();
|
|
3738
3774
|
let selected = parseBroMode(requested);
|
|
3739
3775
|
if (!selected) {
|
|
3740
|
-
if (ctx
|
|
3776
|
+
if (!hasBroCustomUi(ctx)) {
|
|
3741
3777
|
ctx.ui.notify("Use /bro mode <brief|balanced|faithful> outside Pi's interactive UI.", "warning");
|
|
3742
3778
|
return;
|
|
3743
3779
|
}
|
|
@@ -3757,7 +3793,7 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3757
3793
|
}
|
|
3758
3794
|
|
|
3759
3795
|
if (action === "btw") {
|
|
3760
|
-
if (ctx
|
|
3796
|
+
if (!hasBroCustomUi(ctx)) {
|
|
3761
3797
|
ctx.ui.notify("Use /bro btw in Pi's interactive UI.", "warning");
|
|
3762
3798
|
return;
|
|
3763
3799
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-bro",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.4",
|
|
4
4
|
"description": "An Earendil Pi extension that explains pasted text, assistant responses, local documents, public webpages, and recent session turns (as shapes) in a context-isolated window, opens a sandboxed side conversation with /bro btw, and provides a second-opinion advisor tool for executor agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
],
|
|
38
38
|
"files": [
|
|
39
39
|
"bro.ts",
|
|
40
|
+
"ui-capabilities.ts",
|
|
40
41
|
"backend.ts",
|
|
41
42
|
"prompt.ts",
|
|
42
43
|
"README.md",
|
|
@@ -53,7 +54,7 @@
|
|
|
53
54
|
},
|
|
54
55
|
"scripts": {
|
|
55
56
|
"typecheck": "tsc --noEmit",
|
|
56
|
-
"test": "npm run typecheck && node --test prompt.test.ts backend.test.ts claude.test.ts grok.test.ts codex.test.ts muse.test.ts settings.test.ts show-html.test.ts smoke-rpc.test.ts benchmark/*.test.ts && sh ./smoke-test.sh",
|
|
57
|
+
"test": "npm run typecheck && node --test prompt.test.ts backend.test.ts claude.test.ts grok.test.ts codex.test.ts muse.test.ts settings.test.ts show-html.test.ts smoke-rpc.test.ts ui-capabilities.test.ts benchmark/*.test.ts && sh ./smoke-test.sh",
|
|
57
58
|
"benchmark:dry-run": "node benchmark/run.ts dry-run",
|
|
58
59
|
"benchmark:run": "node benchmark/run.ts run",
|
|
59
60
|
"benchmark:report": "node benchmark/run.ts report",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Local structural contract. This does not extend, patch, or impersonate the upstream Pi API. */
|
|
2
|
+
type UiContext = { mode: string; ui: unknown };
|
|
3
|
+
type DesktopUi = { getDesktopUiCapabilities?: () => unknown };
|
|
4
|
+
|
|
5
|
+
export function hasBroCustomUi(ctx: UiContext): boolean {
|
|
6
|
+
if (ctx.mode === "tui") return true;
|
|
7
|
+
if (ctx.mode !== "rpc" || !ctx.ui || typeof ctx.ui !== "object") return false;
|
|
8
|
+
const query = (ctx.ui as DesktopUi).getDesktopUiCapabilities;
|
|
9
|
+
if (typeof query !== "function") return false;
|
|
10
|
+
try {
|
|
11
|
+
const value = query.call(ctx.ui);
|
|
12
|
+
if (!value || typeof value !== "object") return false;
|
|
13
|
+
const capability = value as { version?: unknown; customTui?: unknown; viewport?: unknown };
|
|
14
|
+
return capability.version === 1 && capability.customTui === true && capability.viewport === true;
|
|
15
|
+
} catch { return false; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Read a virtual terminal's actual viewport in desktop mode; preserve terminal behavior elsewhere. */
|
|
19
|
+
export function broModalRows(tui: unknown, terminalRows: number | undefined): number {
|
|
20
|
+
const rows = tui && typeof tui === "object" ? (tui as { terminal?: { rows?: unknown } }).terminal?.rows : undefined;
|
|
21
|
+
const candidate = typeof rows === "number" && Number.isFinite(rows) && rows > 0 ? rows : terminalRows;
|
|
22
|
+
return typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0 ? Math.floor(candidate) : 30;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function insertBroDesktopText(ctx: UiContext, text: string): Promise<"inserted" | "not_empty" | "unavailable"> {
|
|
26
|
+
if (!hasBroCustomUi(ctx) || ctx.mode !== "rpc") return "unavailable";
|
|
27
|
+
const ui = ctx.ui as DesktopUi & { insertEditorTextIfEmpty?: (text: string) => Promise<"inserted" | "not_empty" | "unavailable"> };
|
|
28
|
+
try {
|
|
29
|
+
const caps = ui.getDesktopUiCapabilities?.() as { atomicEditorInsert?: boolean } | undefined;
|
|
30
|
+
return caps?.atomicEditorInsert === true && ui.insertEditorTextIfEmpty ? await ui.insertEditorTextIfEmpty(text) : "unavailable";
|
|
31
|
+
} catch { return "unavailable"; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Only terminal mode uses synchronous insertion; Desktop uses insertBroDesktopText instead. */
|
|
35
|
+
export function canBroInsertIntoEditor(ctx: UiContext): boolean { return ctx.mode === "tui"; }
|