pi-bro 0.19.3 → 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 +11 -0
- package/README.md +7 -4
- package/backend.ts +2 -1
- package/bro.ts +45 -22
- package/package.json +3 -2
- package/ui-capabilities.ts +35 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
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
|
+
|
|
5
16
|
## [0.19.3] - 2026-09-30
|
|
6
17
|
|
|
7
18
|
### 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;
|
|
@@ -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;
|
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" },
|
|
@@ -1818,6 +1820,14 @@ export type AdvisorActivityCallback = (label: string, timestamp: number) => void
|
|
|
1818
1820
|
// "Transport" for why stdin rather than --print). This function keeps its exact existing
|
|
1819
1821
|
// signature/throw contract -- it is called directly by tests and by runAdvisorWithRetries below,
|
|
1820
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
|
+
|
|
1821
1831
|
export async function runAdvisorConsultation(
|
|
1822
1832
|
prompt: string,
|
|
1823
1833
|
selection: BackendSelection,
|
|
@@ -1825,6 +1835,7 @@ export async function runAdvisorConsultation(
|
|
|
1825
1835
|
signal: AbortSignal,
|
|
1826
1836
|
killEscalationMs = 5_000,
|
|
1827
1837
|
onActivity?: AdvisorActivityCallback,
|
|
1838
|
+
deadlineMs?: number,
|
|
1828
1839
|
): Promise<string> {
|
|
1829
1840
|
const outcome = await executeBackend(
|
|
1830
1841
|
{ feature: "advisor", prompt, access: "workspace-full", cwd },
|
|
@@ -1835,10 +1846,10 @@ export async function runAdvisorConsultation(
|
|
|
1835
1846
|
if (progress.kind === "activity") onActivity(progress.label, progress.timestamp);
|
|
1836
1847
|
}
|
|
1837
1848
|
: undefined,
|
|
1838
|
-
{ killEscalationMs },
|
|
1849
|
+
{ killEscalationMs, deadlineMs },
|
|
1839
1850
|
);
|
|
1840
1851
|
if (outcome.status === "success") return outcome.text;
|
|
1841
|
-
throw new
|
|
1852
|
+
throw new TerminalConsultError(outcome.status, outcome.message);
|
|
1842
1853
|
}
|
|
1843
1854
|
|
|
1844
1855
|
function advisorDelay(ms: number, signal: AbortSignal, onTick?: (remainingMs: number) => void): Promise<void> {
|
|
@@ -1992,7 +2003,8 @@ export async function runAdvisorWithRetries(
|
|
|
1992
2003
|
} catch (error) {
|
|
1993
2004
|
if (progressTimer) clearInterval(progressTimer);
|
|
1994
2005
|
stopActivityThrottle();
|
|
1995
|
-
if (signal.aborted || errorMessage(error) === "Canceled."
|
|
2006
|
+
if (signal.aborted || errorMessage(error) === "Canceled." ||
|
|
2007
|
+
(error instanceof TerminalConsultError && error.status !== "failure")) throw error;
|
|
1996
2008
|
lastError = error;
|
|
1997
2009
|
if (attempt === delays.length) break;
|
|
1998
2010
|
const delay = delays[attempt]!;
|
|
@@ -2502,7 +2514,7 @@ export function createConfigModal(
|
|
|
2502
2514
|
}
|
|
2503
2515
|
|
|
2504
2516
|
export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
|
|
2505
|
-
if (ctx
|
|
2517
|
+
if (!hasBroCustomUi(ctx)) {
|
|
2506
2518
|
ctx.ui.notify("Use /bro config in Pi's interactive UI.", "warning");
|
|
2507
2519
|
return;
|
|
2508
2520
|
}
|
|
@@ -2619,7 +2631,7 @@ export function createAdvisorSteerModal(
|
|
|
2619
2631
|
}
|
|
2620
2632
|
|
|
2621
2633
|
export async function showAdvisorSteerModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
|
|
2622
|
-
if (ctx
|
|
2634
|
+
if (!hasBroCustomUi(ctx)) {
|
|
2623
2635
|
ctx.ui.notify("Use /bro advisor-steer in Pi's interactive UI.", "warning");
|
|
2624
2636
|
return;
|
|
2625
2637
|
}
|
|
@@ -2655,7 +2667,7 @@ Quick reference. The README is the full user guide: https://github.com/tranhoang
|
|
|
2655
2667
|
|
|
2656
2668
|
## Explain and show
|
|
2657
2669
|
|
|
2658
|
-
- \`/bro\` — explain the latest completed assistant reply
|
|
2670
|
+
- \`/bro\` or \`/bro simplify\` — explain the latest completed assistant reply
|
|
2659
2671
|
- \`/bro text [text]\` — explain pasted text, or the latest reply when text is omitted
|
|
2660
2672
|
- \`/bro file <path>\` — explain a workspace \`.md\`, \`.markdown\`, \`.txt\`, \`.pdf\`, or \`.docx\` file
|
|
2661
2673
|
- \`/bro url <url>\` — explain one public webpage
|
|
@@ -2816,7 +2828,7 @@ class BroModal implements Focusable {
|
|
|
2816
2828
|
render(width: number): string[] {
|
|
2817
2829
|
const dialogWidth = Math.max(24, width);
|
|
2818
2830
|
const innerWidth = Math.max(22, dialogWidth - 2);
|
|
2819
|
-
const terminalRows = this.tui
|
|
2831
|
+
const terminalRows = broModalRows(this.tui, process.stdout.rows);
|
|
2820
2832
|
const dialogHeight = Math.min(32, Math.max(7, Math.floor(terminalRows * 0.78)));
|
|
2821
2833
|
this.bodyHeight = Math.max(1, dialogHeight - 6);
|
|
2822
2834
|
|
|
@@ -2924,7 +2936,7 @@ interface BroModalOptions {
|
|
|
2924
2936
|
}
|
|
2925
2937
|
|
|
2926
2938
|
async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptions): Promise<void> {
|
|
2927
|
-
if (ctx
|
|
2939
|
+
if (!hasBroCustomUi(ctx)) {
|
|
2928
2940
|
if (options.run && !options.result && options.text === undefined) {
|
|
2929
2941
|
const result = await options.run(new AbortController().signal);
|
|
2930
2942
|
options.onResult?.(result);
|
|
@@ -3189,6 +3201,7 @@ class BtwModal implements Focusable {
|
|
|
3189
3201
|
}
|
|
3190
3202
|
|
|
3191
3203
|
setNotice(notice: string): void {
|
|
3204
|
+
if (this.disposed) return;
|
|
3192
3205
|
this.notice = notice;
|
|
3193
3206
|
this.tui.requestRender();
|
|
3194
3207
|
}
|
|
@@ -3253,9 +3266,16 @@ class BtwModal implements Focusable {
|
|
|
3253
3266
|
render(width: number): string[] {
|
|
3254
3267
|
const dialogWidth = Math.max(24, width);
|
|
3255
3268
|
const innerWidth = Math.max(22, dialogWidth - 2);
|
|
3256
|
-
const terminalRows = this.tui
|
|
3269
|
+
const terminalRows = broModalRows(this.tui, process.stdout.rows);
|
|
3257
3270
|
const dialogHeight = Math.min(34, Math.max(8, Math.floor(terminalRows * 0.82)));
|
|
3258
|
-
|
|
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);
|
|
3259
3279
|
|
|
3260
3280
|
const rendered = this.markdown.render(innerWidth);
|
|
3261
3281
|
this.maxOffset = Math.max(0, rendered.length - this.bodyHeight);
|
|
@@ -3268,12 +3288,6 @@ class BtwModal implements Focusable {
|
|
|
3268
3288
|
const mode = this.theme.fg("dim", " · ") + (this.full ? this.theme.fg("accent", this.theme.bold(btwModeLabel(true))) : this.theme.fg("dim", btwModeLabel(false)));
|
|
3269
3289
|
const header = this.theme.fg("accent", this.theme.bold("Bro · btw")) + model + mode + this.theme.fg("dim", scroll);
|
|
3270
3290
|
|
|
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
3291
|
const lines = [
|
|
3278
3292
|
this.borderLine(innerWidth, "top"),
|
|
3279
3293
|
this.frameLine(header, innerWidth),
|
|
@@ -3282,8 +3296,7 @@ class BtwModal implements Focusable {
|
|
|
3282
3296
|
for (const line of visible) lines.push(this.frameLine(line, innerWidth));
|
|
3283
3297
|
for (let i = visible.length; i < this.bodyHeight; i++) lines.push(this.frameLine("", innerWidth));
|
|
3284
3298
|
lines.push(this.ruleLine(innerWidth));
|
|
3285
|
-
lines.push(this.frameLine(
|
|
3286
|
-
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));
|
|
3287
3300
|
lines.push(this.borderLine(innerWidth, "bottom"));
|
|
3288
3301
|
return lines;
|
|
3289
3302
|
}
|
|
@@ -3425,12 +3438,17 @@ async function openBtwModal(
|
|
|
3425
3438
|
}
|
|
3426
3439
|
};
|
|
3427
3440
|
|
|
3428
|
-
const insert = (all: boolean) => {
|
|
3441
|
+
const insert = async (all: boolean) => {
|
|
3429
3442
|
const text = all ? transcript() : (thread.turns.at(-1)?.answer ?? "");
|
|
3430
3443
|
if (!text.trim()) {
|
|
3431
3444
|
modal.setNotice("Nothing to insert yet.");
|
|
3432
3445
|
return;
|
|
3433
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
|
+
}
|
|
3434
3452
|
if (ctx.ui.getEditorText().trim()) {
|
|
3435
3453
|
modal.setNotice("Main editor has a draft. Edit or clear it first, then insert again.");
|
|
3436
3454
|
return;
|
|
@@ -3629,6 +3647,11 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3629
3647
|
let action = parts[0] ?? "";
|
|
3630
3648
|
let value = raw.slice(raw.split(/\s+/, 1)[0]?.length ?? 0).trim();
|
|
3631
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
|
+
|
|
3632
3655
|
// Removed commands must never fall through to a paid text explanation.
|
|
3633
3656
|
if (action === "model" || action === "effort") {
|
|
3634
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");
|
|
@@ -3750,7 +3773,7 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3750
3773
|
const settings = await readSettings();
|
|
3751
3774
|
let selected = parseBroMode(requested);
|
|
3752
3775
|
if (!selected) {
|
|
3753
|
-
if (ctx
|
|
3776
|
+
if (!hasBroCustomUi(ctx)) {
|
|
3754
3777
|
ctx.ui.notify("Use /bro mode <brief|balanced|faithful> outside Pi's interactive UI.", "warning");
|
|
3755
3778
|
return;
|
|
3756
3779
|
}
|
|
@@ -3770,7 +3793,7 @@ export default async function bro(pi: ExtensionAPI) {
|
|
|
3770
3793
|
}
|
|
3771
3794
|
|
|
3772
3795
|
if (action === "btw") {
|
|
3773
|
-
if (ctx
|
|
3796
|
+
if (!hasBroCustomUi(ctx)) {
|
|
3774
3797
|
ctx.ui.notify("Use /bro btw in Pi's interactive UI.", "warning");
|
|
3775
3798
|
return;
|
|
3776
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"; }
|