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 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` or `/bro text` | Explain the latest completed assistant response. |
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. Each attempt is capped at
209
- 10 minutes. Cancelling the tool call aborts immediately and skips any
210
- pending retry wait.
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
- // explain/show and btw all print the prompt as a single --print argv value and read a stream-json
215
- // result off stdout; only sandbox flag, cwd, timeout, and (btw only) --conversation differ.
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: agyFailureMessage(action, { code, killed: false, stderr }) };
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 > MAX_TEXT_LENGTH && start < turns.length - 1) {
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 > MAX_TEXT_LENGTH) text = `${text.slice(0, MAX_TEXT_LENGTH)}\n[… transcript truncated …]`;
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 Error(outcome.message);
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.") throw error;
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.mode !== "tui") {
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.mode !== "tui") {
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.terminal?.rows ?? process.stdout.rows ?? 30;
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.mode !== "tui") {
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.terminal?.rows ?? process.stdout.rows ?? 30;
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
- this.bodyHeight = Math.max(1, dialogHeight - 7);
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(composer, innerWidth));
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
- let context = captureShowTranscript(ctx, BTW_CONTEXT_TURNS)?.text;
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.mode !== "tui") {
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.mode !== "tui") {
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.2",
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"; }