pi-bro 0.19.3 → 0.19.5

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,33 @@
2
2
 
3
3
  All notable changes to pi-bro are documented here.
4
4
 
5
+ ## [0.19.5] - 2026-10-01
6
+
7
+ ### Changed
8
+
9
+ - Remove the always-true backend-support predicate and unreachable doctor/config branches; retain backend-specific effort and permission validation (#74).
10
+ - Move helper, modal, advisor, and Pi SDK regression checks into named Node tests with shared isolated compilation, leaving shell smoke tests focused on host RPC integration and avoiding duplicate helper execution in PiG (#74).
11
+ - Consolidate redundant support, prompt, and BTW assertions; replace prose greps with package-documentation checks and document surviving coverage (#74).
12
+
13
+ ### Fixed
14
+
15
+ - Make the advisor throttle cancellation test actually queue an emission and advance controlled time beyond its deadline; verified by removing cleanup and observing failure (#74).
16
+
17
+ ### Documentation
18
+
19
+ - Explicitly archive the seven pre-conversation-only decomposition captures as historical semantic examples and record before/after test organization and timings (#74).
20
+
21
+ ## [0.19.4] - 2026-10-01
22
+
23
+ ### Fixed
24
+
25
+ - 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).
26
+ - 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).
27
+
28
+ ### Documentation
29
+
30
+ - Distinguish host deadlines from CLI-reported failures and document unverified shutdown/reload cancellation, POSIX process-group cleanup, and Windows direct-child limits (#72).
31
+
5
32
  ## [0.19.3] - 2026-09-30
6
33
 
7
34
  ### 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;
@@ -286,6 +286,7 @@ async function executeArgvPrint(
286
286
  }
287
287
 
288
288
  const lines = createInterface({ input: child.stdout!, crlfDelay: Infinity });
289
+ void attempt.closed.then(() => lines.close());
289
290
  try {
290
291
  for await (const line of lines) {
291
292
  if (!line.trim() || attempt.causeOf()) continue;
@@ -510,12 +511,6 @@ async function executeAdvisorStdin(
510
511
  }
511
512
 
512
513
 
513
- // Every backend supports every feature. Grok's "restricted" access is a prompt instruction, not
514
- // enforcement (see GROK_RESTRICTED_PREFIX); Claude's restricted btw runs tool-less.
515
- export function backendSupports(_backend: "agy" | "claude" | "grok" | "codex" | "muse", _feature: BackendFeature): boolean {
516
- return true;
517
- }
518
-
519
514
  type ClaudeEvent = {
520
515
  type?: unknown;
521
516
  subtype?: unknown;
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";
@@ -25,7 +26,6 @@ import {
25
26
  GROK_EFFORTS,
26
27
  CODEX_EFFORTS,
27
28
  MUSE_EFFORTS,
28
- backendSupports,
29
29
  execute as executeBackend,
30
30
  parseBtwAgyLine,
31
31
  type AgySelection,
@@ -111,7 +111,6 @@ export const MUSE_MODELS = [
111
111
  { id: "muse-spark-1.3-contributor", label: "Muse Spark 1.3 Contributor" },
112
112
  { id: "muse-spark-1.3", label: "Muse Spark 1.3" },
113
113
  ] as const;
114
- export { backendSupports as supportsBackend };
115
114
  type AgyModelFamily = {
116
115
  id: string;
117
116
  label: string;
@@ -131,6 +130,7 @@ export function setRegularMouseReporting(tui: Pick<TuiLike, "mode" | "terminal">
131
130
  }
132
131
 
133
132
  const COMMANDS = [
133
+ { value: "simplify", label: "simplify", description: "Explain the latest completed assistant reply (same as /bro)" },
134
134
  { value: "text", label: "text", description: "Explain pasted text, or the latest reply when text is omitted" },
135
135
  { value: "file", label: "file", description: "Explain a local document" },
136
136
  { value: "url", label: "url", description: "Explain a public webpage" },
@@ -1323,10 +1323,6 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1323
1323
  continue;
1324
1324
  }
1325
1325
  if (backend === "grok") {
1326
- if (!backendSupports("grok", capability)) {
1327
- fail(label, `\`${capability}\` is not supported on the Grok backend for this capability. Run \`/bro config\` to give it another backend.`);
1328
- continue;
1329
- }
1330
1326
  if (!isGrokEffort(pair.effort)) {
1331
1327
  fail(label, `\`${pair.effort}\` is unsupported for grok \`${pair.model}\`. Run \`/bro config\` to fix this.`);
1332
1328
  continue;
@@ -1340,10 +1336,6 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1340
1336
  continue;
1341
1337
  }
1342
1338
  if (backend === "codex") {
1343
- if (!backendSupports("codex", capability)) {
1344
- fail(label, `\`${capability}\` is not supported on the Codex backend for this capability. Run \`/bro config\` to give it another backend.`);
1345
- continue;
1346
- }
1347
1339
  if (!isCodexEffort(pair.effort)) {
1348
1340
  fail(label, `\`${pair.effort}\` is unsupported for codex \`${pair.model}\`. Run \`/bro config\` to fix this.`);
1349
1341
  continue;
@@ -1357,10 +1349,6 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1357
1349
  continue;
1358
1350
  }
1359
1351
  if (backend === "muse") {
1360
- if (!backendSupports("muse", capability)) {
1361
- fail(label, `\`${capability}\` is not supported on the Muse backend for this capability. Run \`/bro config\` to give it another backend.`);
1362
- continue;
1363
- }
1364
1352
  if (!isMuseEffort(pair.effort)) {
1365
1353
  fail(label, `\`${pair.effort}\` is unsupported for muse \`${pair.model}\`. Run \`/bro config\` to fix this.`);
1366
1354
  continue;
@@ -1818,6 +1806,14 @@ export type AdvisorActivityCallback = (label: string, timestamp: number) => void
1818
1806
  // "Transport" for why stdin rather than --print). This function keeps its exact existing
1819
1807
  // signature/throw contract -- it is called directly by tests and by runAdvisorWithRetries below,
1820
1808
  // which owns the 3-attempt retry/backoff policy the backend itself never performs.
1809
+ export class TerminalConsultError extends Error {
1810
+ readonly status: "failure" | "cancelled" | "timeout";
1811
+ constructor(status: "failure" | "cancelled" | "timeout", message: string) {
1812
+ super(message);
1813
+ this.status = status;
1814
+ }
1815
+ }
1816
+
1821
1817
  export async function runAdvisorConsultation(
1822
1818
  prompt: string,
1823
1819
  selection: BackendSelection,
@@ -1825,6 +1821,7 @@ export async function runAdvisorConsultation(
1825
1821
  signal: AbortSignal,
1826
1822
  killEscalationMs = 5_000,
1827
1823
  onActivity?: AdvisorActivityCallback,
1824
+ deadlineMs?: number,
1828
1825
  ): Promise<string> {
1829
1826
  const outcome = await executeBackend(
1830
1827
  { feature: "advisor", prompt, access: "workspace-full", cwd },
@@ -1835,10 +1832,10 @@ export async function runAdvisorConsultation(
1835
1832
  if (progress.kind === "activity") onActivity(progress.label, progress.timestamp);
1836
1833
  }
1837
1834
  : undefined,
1838
- { killEscalationMs },
1835
+ { killEscalationMs, deadlineMs },
1839
1836
  );
1840
1837
  if (outcome.status === "success") return outcome.text;
1841
- throw new Error(outcome.message);
1838
+ throw new TerminalConsultError(outcome.status, outcome.message);
1842
1839
  }
1843
1840
 
1844
1841
  function advisorDelay(ms: number, signal: AbortSignal, onTick?: (remainingMs: number) => void): Promise<void> {
@@ -1992,7 +1989,8 @@ export async function runAdvisorWithRetries(
1992
1989
  } catch (error) {
1993
1990
  if (progressTimer) clearInterval(progressTimer);
1994
1991
  stopActivityThrottle();
1995
- if (signal.aborted || errorMessage(error) === "Canceled.") throw error;
1992
+ if (signal.aborted || errorMessage(error) === "Canceled." ||
1993
+ (error instanceof TerminalConsultError && error.status !== "failure")) throw error;
1996
1994
  lastError = error;
1997
1995
  if (attempt === delays.length) break;
1998
1996
  const delay = delays[attempt]!;
@@ -2265,7 +2263,7 @@ export function createConfigModal(
2265
2263
  : backend === "muse"
2266
2264
  ? `${MUSE_OPTION_PREFIX}${override.model}`
2267
2265
  : (resolved.family?.id ?? override.model);
2268
- rows.effort.currentValue = backendSupports(backend, capability) ? effortDisplay(resolved, backend) : "unsupported backend";
2266
+ rows.effort.currentValue = effortDisplay(resolved, backend);
2269
2267
  rows.effort.values =
2270
2268
  backend === "claude"
2271
2269
  ? ["default", ...CLAUDE_EFFORTS]
@@ -2502,7 +2500,7 @@ export function createConfigModal(
2502
2500
  }
2503
2501
 
2504
2502
  export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
2505
- if (ctx.mode !== "tui") {
2503
+ if (!hasBroCustomUi(ctx)) {
2506
2504
  ctx.ui.notify("Use /bro config in Pi's interactive UI.", "warning");
2507
2505
  return;
2508
2506
  }
@@ -2619,7 +2617,7 @@ export function createAdvisorSteerModal(
2619
2617
  }
2620
2618
 
2621
2619
  export async function showAdvisorSteerModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
2622
- if (ctx.mode !== "tui") {
2620
+ if (!hasBroCustomUi(ctx)) {
2623
2621
  ctx.ui.notify("Use /bro advisor-steer in Pi's interactive UI.", "warning");
2624
2622
  return;
2625
2623
  }
@@ -2655,7 +2653,7 @@ Quick reference. The README is the full user guide: https://github.com/tranhoang
2655
2653
 
2656
2654
  ## Explain and show
2657
2655
 
2658
- - \`/bro\` — explain the latest completed assistant reply
2656
+ - \`/bro\` or \`/bro simplify\` — explain the latest completed assistant reply
2659
2657
  - \`/bro text [text]\` — explain pasted text, or the latest reply when text is omitted
2660
2658
  - \`/bro file <path>\` — explain a workspace \`.md\`, \`.markdown\`, \`.txt\`, \`.pdf\`, or \`.docx\` file
2661
2659
  - \`/bro url <url>\` — explain one public webpage
@@ -2816,7 +2814,7 @@ class BroModal implements Focusable {
2816
2814
  render(width: number): string[] {
2817
2815
  const dialogWidth = Math.max(24, width);
2818
2816
  const innerWidth = Math.max(22, dialogWidth - 2);
2819
- const terminalRows = this.tui.terminal?.rows ?? process.stdout.rows ?? 30;
2817
+ const terminalRows = broModalRows(this.tui, process.stdout.rows);
2820
2818
  const dialogHeight = Math.min(32, Math.max(7, Math.floor(terminalRows * 0.78)));
2821
2819
  this.bodyHeight = Math.max(1, dialogHeight - 6);
2822
2820
 
@@ -2924,7 +2922,7 @@ interface BroModalOptions {
2924
2922
  }
2925
2923
 
2926
2924
  async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptions): Promise<void> {
2927
- if (ctx.mode !== "tui") {
2925
+ if (!hasBroCustomUi(ctx)) {
2928
2926
  if (options.run && !options.result && options.text === undefined) {
2929
2927
  const result = await options.run(new AbortController().signal);
2930
2928
  options.onResult?.(result);
@@ -3189,6 +3187,7 @@ class BtwModal implements Focusable {
3189
3187
  }
3190
3188
 
3191
3189
  setNotice(notice: string): void {
3190
+ if (this.disposed) return;
3192
3191
  this.notice = notice;
3193
3192
  this.tui.requestRender();
3194
3193
  }
@@ -3253,9 +3252,16 @@ class BtwModal implements Focusable {
3253
3252
  render(width: number): string[] {
3254
3253
  const dialogWidth = Math.max(24, width);
3255
3254
  const innerWidth = Math.max(22, dialogWidth - 2);
3256
- const terminalRows = this.tui.terminal?.rows ?? process.stdout.rows ?? 30;
3255
+ const terminalRows = broModalRows(this.tui, process.stdout.rows);
3257
3256
  const dialogHeight = Math.min(34, Math.max(8, Math.floor(terminalRows * 0.82)));
3258
- this.bodyHeight = Math.max(1, dialogHeight - 7);
3257
+ const footer = [
3258
+ ...wrapTextWithAnsi(this.theme.fg("accent", this.theme.bold("Your message to Bro")), innerWidth),
3259
+ this.input.render(innerWidth)[0] ?? "",
3260
+ ...wrapTextWithAnsi(this.running ? "Bro is thinking… · Esc: cancel response" : "Enter: send message · Esc: close panel", innerWidth),
3261
+ ...wrapTextWithAnsi("Commands: /mode · /copy · /copy-all · /insert · /insert-all · /clear · /retry", innerWidth),
3262
+ ...(this.notice ? wrapTextWithAnsi(this.theme.fg("accent", this.notice), innerWidth) : []),
3263
+ ];
3264
+ this.bodyHeight = Math.max(1, dialogHeight - 5 - footer.length);
3259
3265
 
3260
3266
  const rendered = this.markdown.render(innerWidth);
3261
3267
  this.maxOffset = Math.max(0, rendered.length - this.bodyHeight);
@@ -3268,12 +3274,6 @@ class BtwModal implements Focusable {
3268
3274
  const mode = this.theme.fg("dim", " · ") + (this.full ? this.theme.fg("accent", this.theme.bold(btwModeLabel(true))) : this.theme.fg("dim", btwModeLabel(false)));
3269
3275
  const header = this.theme.fg("accent", this.theme.bold("Bro · btw")) + model + mode + this.theme.fg("dim", scroll);
3270
3276
 
3271
- const composer = this.input.render(innerWidth)[0] ?? "";
3272
-
3273
- const controls = this.running
3274
- ? this.theme.fg("dim", "Thinking… · Esc cancel")
3275
- : this.theme.fg("dim", "Enter ask · Esc close · /mode · /copy · /copy-all · /insert · /insert-all · /clear · /retry");
3276
-
3277
3277
  const lines = [
3278
3278
  this.borderLine(innerWidth, "top"),
3279
3279
  this.frameLine(header, innerWidth),
@@ -3282,8 +3282,7 @@ class BtwModal implements Focusable {
3282
3282
  for (const line of visible) lines.push(this.frameLine(line, innerWidth));
3283
3283
  for (let i = visible.length; i < this.bodyHeight; i++) lines.push(this.frameLine("", innerWidth));
3284
3284
  lines.push(this.ruleLine(innerWidth));
3285
- lines.push(this.frameLine(composer, innerWidth));
3286
- lines.push(this.frameLine(this.notice ? this.theme.fg("accent", this.notice) : controls, innerWidth));
3285
+ for (const line of footer) lines.push(this.frameLine(line, innerWidth));
3287
3286
  lines.push(this.borderLine(innerWidth, "bottom"));
3288
3287
  return lines;
3289
3288
  }
@@ -3425,12 +3424,17 @@ async function openBtwModal(
3425
3424
  }
3426
3425
  };
3427
3426
 
3428
- const insert = (all: boolean) => {
3427
+ const insert = async (all: boolean) => {
3429
3428
  const text = all ? transcript() : (thread.turns.at(-1)?.answer ?? "");
3430
3429
  if (!text.trim()) {
3431
3430
  modal.setNotice("Nothing to insert yet.");
3432
3431
  return;
3433
3432
  }
3433
+ if (!canBroInsertIntoEditor(ctx)) {
3434
+ const result = await insertBroDesktopText(ctx, text);
3435
+ 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.");
3436
+ return;
3437
+ }
3434
3438
  if (ctx.ui.getEditorText().trim()) {
3435
3439
  modal.setNotice("Main editor has a draft. Edit or clear it first, then insert again.");
3436
3440
  return;
@@ -3629,6 +3633,11 @@ export default async function bro(pi: ExtensionAPI) {
3629
3633
  let action = parts[0] ?? "";
3630
3634
  let value = raw.slice(raw.split(/\s+/, 1)[0]?.length ?? 0).trim();
3631
3635
 
3636
+ if (action === "simplify") {
3637
+ if (value) { ctx.ui.notify("Use /bro simplify for the latest reply, or /bro text <text> for pasted text.", "warning"); return; }
3638
+ action = "";
3639
+ }
3640
+
3632
3641
  // Removed commands must never fall through to a paid text explanation.
3633
3642
  if (action === "model" || action === "effort") {
3634
3643
  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");
@@ -3750,7 +3759,7 @@ export default async function bro(pi: ExtensionAPI) {
3750
3759
  const settings = await readSettings();
3751
3760
  let selected = parseBroMode(requested);
3752
3761
  if (!selected) {
3753
- if (ctx.mode !== "tui") {
3762
+ if (!hasBroCustomUi(ctx)) {
3754
3763
  ctx.ui.notify("Use /bro mode <brief|balanced|faithful> outside Pi's interactive UI.", "warning");
3755
3764
  return;
3756
3765
  }
@@ -3770,7 +3779,7 @@ export default async function bro(pi: ExtensionAPI) {
3770
3779
  }
3771
3780
 
3772
3781
  if (action === "btw") {
3773
- if (ctx.mode !== "tui") {
3782
+ if (!hasBroCustomUi(ctx)) {
3774
3783
  ctx.ui.notify("Use /bro btw in Pi's interactive UI.", "warning");
3775
3784
  return;
3776
3785
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bro",
3
- "version": "0.19.3",
3
+ "version": "0.19.5",
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 helpers.test.mjs 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"; }