@trim21/personal-pi-extensions 0.0.160 → 0.0.161

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.160",
3
+ "version": "0.0.161",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -177,7 +177,7 @@ export class GhError extends Error {
177
177
  /** Run `gh` and return stdout. On non-zero exit, throws a `GhError` carrying the toolcall input and raw command. */
178
178
  export async function ghExec(
179
179
  args: string[],
180
- ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
180
+ ctx: { cwd?: string; signal?: AbortSignal; input?: unknown; timeout?: number },
181
181
  ): Promise<string> {
182
182
  const result = await runGh(args, ctx);
183
183
  if (result.code !== 0) {
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Agent discovery for the `spawn_agent` tool.
3
+ *
4
+ * Subagents are defined as markdown files in `~/.pi/agent/agents/*.md`
5
+ * (user-level only; project-local agents are intentionally not supported).
6
+ * Each file carries YAML frontmatter plus a system-prompt body:
7
+ *
8
+ * ---
9
+ * name: scout
10
+ * description: Fast codebase recon
11
+ * tools:
12
+ * - read
13
+ * - grep
14
+ * - find
15
+ * - ls
16
+ * model: claude-haiku-4-5 # optional
17
+ * thinkingLevel: high # optional; applied as "model:high"
18
+ * ---
19
+ * System prompt for the agent goes here.
20
+ *
21
+ * Frontmatter is validated with a typebox schema; files that fail validation
22
+ * (missing name/description, wrong field types) are skipped. If `tools` is
23
+ * omitted, the subagent runs with the read-only default toolset from the
24
+ * spawn-agent config (read/grep/find/ls) unless overridden there.
25
+ */
26
+
27
+ import { readdirSync, readFileSync } from "node:fs";
28
+ import { join } from "node:path";
29
+
30
+ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
31
+ import { Type } from "typebox";
32
+ import { Value } from "typebox/value";
33
+
34
+ /** Valid thinking levels, mirroring pi's ThinkingLevel type. */
35
+ const THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
36
+
37
+ const agentFrontmatterSchema = Type.Object({
38
+ name: Type.String({ minLength: 1 }),
39
+ description: Type.String({ minLength: 1 }),
40
+ tools: Type.Optional(Type.Array(Type.String())),
41
+ model: Type.Optional(Type.String()),
42
+ thinkingLevel: Type.Optional(Type.Union(THINKING_LEVELS.map((level) => Type.Literal(level)))),
43
+ });
44
+
45
+ function parseAgentFrontmatter(frontmatter: unknown) {
46
+ try {
47
+ return Value.Parse(agentFrontmatterSchema, frontmatter);
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ export interface AgentConfig {
54
+ name: string;
55
+ description: string;
56
+ /** Toolset from the frontmatter; undefined means "use the config default". */
57
+ tools?: string[];
58
+ model?: string;
59
+ /** Thinking level, applied as a ":level" suffix on the model id. */
60
+ thinkingLevel?: (typeof THINKING_LEVELS)[number];
61
+ systemPrompt: string;
62
+ filePath: string;
63
+ }
64
+
65
+ export function discoverAgents(dir = join(getAgentDir(), "agents")): AgentConfig[] {
66
+ let entries;
67
+ try {
68
+ entries = readdirSync(dir, { withFileTypes: true });
69
+ } catch {
70
+ return [];
71
+ }
72
+
73
+ const agents: AgentConfig[] = [];
74
+ for (const entry of entries) {
75
+ if (!entry.name.endsWith(".md")) continue;
76
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
77
+
78
+ const filePath = join(dir, entry.name);
79
+ let content: string;
80
+ try {
81
+ content = readFileSync(filePath, "utf8");
82
+ } catch {
83
+ continue;
84
+ }
85
+
86
+ const { frontmatter, body } = parseFrontmatter(content);
87
+ const fm = parseAgentFrontmatter(frontmatter);
88
+ if (!fm) continue; // missing name/description or wrong field types → not an agent
89
+
90
+ agents.push({
91
+ name: fm.name,
92
+ description: fm.description,
93
+ tools: fm.tools,
94
+ model: fm.model,
95
+ thinkingLevel: fm.thinkingLevel,
96
+ systemPrompt: body,
97
+ filePath,
98
+ });
99
+ }
100
+ return agents;
101
+ }
102
+
103
+ export function formatAgentList(agents: AgentConfig[]): string {
104
+ if (agents.length === 0) return "none";
105
+ return agents.map((a) => `${a.name}: ${a.description}`).join("; ");
106
+ }
@@ -0,0 +1,452 @@
1
+ /**
2
+ * spawn_agent tool — delegate a task to a subagent running in a separate pi
3
+ * process with an isolated context window.
4
+ *
5
+ * The subagent definition comes from `~/.pi/agent/agents/*.md` (markdown with
6
+ * YAML frontmatter, see spawn-agent-agents.ts). The extension discovers the
7
+ * available subagent types once at startup and appends them to the system
8
+ * prompt on every agent start (same pattern as the bwrap extension), so the
9
+ * model always knows which `agent` names it can pass to the tool. Execution
10
+ * is blocking: the tool awaits the subagent process until it exits and
11
+ * returns its final output to the parent model. Progress is streamed through
12
+ * `onUpdate`, the same channel the built-in bash tool uses for live output.
13
+ *
14
+ * Security default: without an explicit `tools:` in the frontmatter, the
15
+ * subagent only gets read-only tools (read/grep/find/ls) — no bash/write/edit.
16
+ */
17
+
18
+ import { spawn } from "node:child_process";
19
+ import { existsSync } from "node:fs";
20
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
21
+ import { tmpdir } from "node:os";
22
+ import { basename, dirname, join } from "node:path";
23
+
24
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
25
+ import type { Message } from "@earendil-works/pi-ai";
26
+ import {
27
+ type ExtensionAPI,
28
+ getMarkdownTheme,
29
+ truncateTail,
30
+ withFileMutationQueue,
31
+ } from "@earendil-works/pi-coding-agent";
32
+ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
33
+ import { Type } from "typebox";
34
+
35
+ import { type AgentConfig, discoverAgents, formatAgentList } from "./spawn-agent-agents.js";
36
+
37
+ // ── constants ────────────────────────────────────────────────────────────────
38
+
39
+ /** Subagent output returned to the parent model is capped at 50KB. */
40
+ const MAX_OUTPUT_BYTES = 50 * 1024;
41
+ /** Read-only toolset used when an agent does not declare `tools`. */
42
+ const DEFAULT_TOOLS = ["read", "grep", "find", "ls"];
43
+
44
+ // ── schema ───────────────────────────────────────────────────────────────────
45
+
46
+ const spawnAgentSchema = Type.Object({
47
+ agent: Type.String({
48
+ description:
49
+ "Name of the subagent type to invoke. Choose one of the available subagent types listed in your system prompt.",
50
+ }),
51
+ task: Type.String({ description: "Task to delegate to the subagent" }),
52
+ });
53
+
54
+ // ── result types ─────────────────────────────────────────────────────────────
55
+
56
+ interface UsageStats {
57
+ input: number;
58
+ output: number;
59
+ cacheRead: number;
60
+ cacheWrite: number;
61
+ cost: number;
62
+ contextTokens: number;
63
+ turns: number;
64
+ }
65
+
66
+ interface SubagentDetails {
67
+ agent: string;
68
+ task: string;
69
+ exitCode: number;
70
+ messages: Message[];
71
+ stderr: string;
72
+ usage: UsageStats;
73
+ model?: string;
74
+ stopReason?: string;
75
+ errorMessage?: string;
76
+ }
77
+
78
+ // ── helpers ──────────────────────────────────────────────────────────────────
79
+
80
+ function getFinalOutput(messages: Message[]): string {
81
+ for (let i = messages.length - 1; i >= 0; i--) {
82
+ const msg = messages[i];
83
+ if (msg.role === "assistant") {
84
+ for (const part of msg.content) {
85
+ if (part.type === "text") return part.text;
86
+ }
87
+ }
88
+ }
89
+ return "";
90
+ }
91
+
92
+ function formatTokens(count: number): string {
93
+ if (count < 1000) return count.toString();
94
+ if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
95
+ if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
96
+ return `${(count / 1_000_000).toFixed(1)}M`;
97
+ }
98
+
99
+ function formatUsageStats(usage: UsageStats, model?: string): string {
100
+ const parts: string[] = [];
101
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
102
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
103
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
104
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
105
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
106
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
107
+ if (usage.contextTokens > 0) parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
108
+ if (model) parts.push(model);
109
+ return parts.join(" ");
110
+ }
111
+
112
+ /**
113
+ * Resolve how to spawn the subagent process. Running through the current
114
+ * entry script (when available) keeps model/tool/extension config identical
115
+ * to the parent; otherwise fall back to the `pi` binary on PATH.
116
+ */
117
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
118
+ const currentScript = process.argv[1];
119
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
120
+ if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
121
+ return { command: process.execPath, args: [currentScript, ...args] };
122
+ }
123
+ const execName = basename(process.execPath).toLowerCase();
124
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
125
+ if (!isGenericRuntime) return { command: process.execPath, args };
126
+ return { command: "pi", args };
127
+ }
128
+
129
+ async function writePromptToTempFile(agentName: string, prompt: string): Promise<string> {
130
+ const dir = await mkdtemp(join(tmpdir(), "pi-spawn-agent-"));
131
+ const safeName = agentName.replaceAll(/[^\w.-]+/g, "_");
132
+ const filePath = join(dir, `prompt-${safeName}.md`);
133
+ await withFileMutationQueue(filePath, async () => {
134
+ await writeFile(filePath, prompt, { encoding: "utf8", mode: 0o600 });
135
+ });
136
+ return filePath;
137
+ }
138
+
139
+ export function buildSubagentArgs(
140
+ agent: AgentConfig,
141
+ task: string,
142
+ systemPromptPath: string | undefined,
143
+ ): string[] {
144
+ // --mode json: emit events as JSON lines; -p: single-shot answer;
145
+ // --no-session: ephemeral, do not persist. --no-extensions keeps the
146
+ // subagent clean (no recursive spawn_agent, no sandbox surprises).
147
+ const args: string[] = ["--mode", "json", "-p", "--no-session", "--no-extensions"];
148
+ // Thinking level rides on the model shorthand ("model:level"); it cannot be
149
+ // set without a model, so a level without a model is ignored.
150
+ const model =
151
+ agent.model !== undefined && agent.thinkingLevel !== undefined
152
+ ? `${agent.model}:${agent.thinkingLevel}`
153
+ : agent.model;
154
+ if (model) args.push("--model", model);
155
+ // Read-only default unless the agent explicitly declares a toolset.
156
+ const tools = agent.tools ?? DEFAULT_TOOLS;
157
+ args.push("--tools", tools.join(","));
158
+ if (systemPromptPath) args.push("--append-system-prompt", systemPromptPath);
159
+ args.push(`Task: ${task}`);
160
+ return args;
161
+ }
162
+
163
+ // ── subagent runner ──────────────────────────────────────────────────────────
164
+
165
+ type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
166
+
167
+ export async function runAgent(
168
+ agent: AgentConfig,
169
+ task: string,
170
+ cwd: string,
171
+ signal: AbortSignal | undefined,
172
+ onUpdate: OnUpdateCallback | undefined,
173
+ ): Promise<SubagentDetails> {
174
+ const result: SubagentDetails = {
175
+ agent: agent.name,
176
+ task,
177
+ exitCode: 0,
178
+ messages: [],
179
+ stderr: "",
180
+ usage: {
181
+ input: 0,
182
+ output: 0,
183
+ cacheRead: 0,
184
+ cacheWrite: 0,
185
+ cost: 0,
186
+ contextTokens: 0,
187
+ turns: 0,
188
+ },
189
+ model: agent.model,
190
+ };
191
+
192
+ let tmpPromptPath: string | null = null;
193
+ try {
194
+ if (agent.systemPrompt.trim()) {
195
+ tmpPromptPath = await writePromptToTempFile(agent.name, agent.systemPrompt);
196
+ }
197
+ const args = buildSubagentArgs(agent, task, tmpPromptPath ?? undefined);
198
+ const invocation = getPiInvocation(args);
199
+ const proc = spawn(invocation.command, invocation.args, {
200
+ cwd,
201
+ shell: false,
202
+ stdio: ["ignore", "pipe", "pipe"],
203
+ // Mark the child as a subagent so extensions running inside it (e.g.
204
+ // bwrap's subagent policy) can recognize and treat it accordingly.
205
+ env: { ...process.env, PI_SUBAGENT_CHILD: "1" },
206
+ });
207
+
208
+ const emitUpdate = () => {
209
+ onUpdate?.({
210
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
211
+ details: { ...result },
212
+ });
213
+ };
214
+
215
+ let buffer = "";
216
+
217
+ const processLine = (line: string) => {
218
+ if (!line.trim()) return;
219
+ let event: unknown;
220
+ try {
221
+ event = JSON.parse(line);
222
+ } catch {
223
+ return; // not a JSON event line
224
+ }
225
+ if (!isRecord(event)) return;
226
+
227
+ if (event.type === "message_end" && isRecord(event.message)) {
228
+ const msg = event.message as unknown as Message;
229
+ result.messages.push(msg);
230
+ if (msg.role === "assistant") {
231
+ result.usage.turns++;
232
+ const usage: Record<string, unknown> = isRecord(msg.usage) ? msg.usage : {};
233
+ result.usage.input += num(usage.input);
234
+ result.usage.output += num(usage.output);
235
+ result.usage.cacheRead += num(usage.cacheRead);
236
+ result.usage.cacheWrite += num(usage.cacheWrite);
237
+ result.usage.cost += num(isRecord(usage.cost) ? usage.cost.total : undefined);
238
+ result.usage.contextTokens = num(usage.totalTokens);
239
+ if (!result.model && typeof msg.model === "string") result.model = msg.model;
240
+ if (typeof msg.stopReason === "string") result.stopReason = msg.stopReason;
241
+ if (typeof msg.errorMessage === "string") result.errorMessage = msg.errorMessage;
242
+ }
243
+ emitUpdate();
244
+ } else if (event.type === "tool_result_end" && isRecord(event.message)) {
245
+ result.messages.push(event.message as unknown as Message);
246
+ emitUpdate();
247
+ }
248
+ };
249
+
250
+ proc.stdout.on("data", (data: Buffer) => {
251
+ buffer += data.toString();
252
+ const lines = buffer.split("\n");
253
+ buffer = lines.pop() ?? "";
254
+ for (const line of lines) processLine(line);
255
+ });
256
+
257
+ proc.stderr.on("data", (data: Buffer) => {
258
+ result.stderr += data.toString();
259
+ });
260
+
261
+ const exitCode = await new Promise<number>((resolve) => {
262
+ proc.on("close", (code) => {
263
+ if (buffer.trim()) processLine(buffer);
264
+ resolve(code ?? 0);
265
+ });
266
+ proc.on("error", () => resolve(1));
267
+
268
+ const kill = () => {
269
+ proc.kill("SIGTERM");
270
+ setTimeout(() => {
271
+ if (!proc.killed) proc.kill("SIGKILL");
272
+ }, 5000);
273
+ };
274
+ if (signal) {
275
+ if (signal.aborted) kill();
276
+ else signal.addEventListener("abort", kill, { once: true });
277
+ }
278
+ });
279
+
280
+ result.exitCode = exitCode;
281
+ return result;
282
+ } finally {
283
+ if (tmpPromptPath) {
284
+ try {
285
+ await rm(tmpPromptPath, { force: true });
286
+ await rm(dirname(tmpPromptPath), { recursive: true, force: true });
287
+ } catch {
288
+ // best-effort cleanup
289
+ }
290
+ }
291
+ }
292
+ }
293
+
294
+ function isRecord(v: unknown): v is Record<string, unknown> {
295
+ return typeof v === "object" && v !== null;
296
+ }
297
+
298
+ function num(v: unknown): number {
299
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
300
+ }
301
+
302
+ /** Session entry customType used to mark the injected subagent list. */
303
+ export function formatAgentListSection(agents: AgentConfig[]): string {
304
+ const lines = agents.map((a) => `- \`${a.name}\`: ${a.description}`);
305
+ return [
306
+ "## Available subagents",
307
+ "",
308
+ "You can delegate tasks to the following subagent types by calling the `spawn_agent` tool with their name in the `agent` parameter:",
309
+ "",
310
+ ...lines,
311
+ ].join("\n");
312
+ }
313
+
314
+ // ── extension ────────────────────────────────────────────────────────────────
315
+
316
+ export default function spawnAgent(pi: ExtensionAPI) {
317
+ // Discover the available subagent types once at extension startup. The
318
+ // extension owns this discovery: the model never has to guess agent names
319
+ // or read the agent directory itself. Editing ~/.pi/agent/agents/*.md
320
+ // requires /reload to take effect.
321
+ const agents = discoverAgents();
322
+ const agentListSection = agents.length > 0 ? formatAgentListSection(agents) : null;
323
+
324
+ if (agentListSection) {
325
+ // Same pattern as the bwrap extension: append the list to the system
326
+ // prompt on every agent start. The system prompt is rebuilt each turn
327
+ // anyway, so a persistent per-session injection would add no value.
328
+ pi.on("before_agent_start", (event) => {
329
+ return { systemPrompt: `${event.systemPrompt}\n\n${agentListSection}` };
330
+ });
331
+ }
332
+
333
+ pi.registerTool<typeof spawnAgentSchema, SubagentDetails>({
334
+ name: "spawn_agent",
335
+ label: "spawn_agent",
336
+ description: [
337
+ "Delegate a task to a subagent running in a separate pi process with an isolated context window.",
338
+ "The `agent` parameter must be one of the available subagent types listed in the system prompt.",
339
+ `Subagents run read-only (${DEFAULT_TOOLS.join(", ")}) unless the agent declares an explicit toolset.`,
340
+ ].join(" "),
341
+ parameters: spawnAgentSchema,
342
+
343
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
344
+ const agent = agents.find((a) => a.name === params.agent);
345
+ if (!agent) {
346
+ return {
347
+ content: [
348
+ {
349
+ type: "text",
350
+ text: `Unknown agent "${params.agent}". Available agents: ${formatAgentList(agents)}`,
351
+ },
352
+ ],
353
+ details: {
354
+ agent: params.agent,
355
+ task: params.task,
356
+ exitCode: 1,
357
+ messages: [],
358
+ stderr: "",
359
+ usage: {
360
+ input: 0,
361
+ output: 0,
362
+ cacheRead: 0,
363
+ cacheWrite: 0,
364
+ cost: 0,
365
+ contextTokens: 0,
366
+ turns: 0,
367
+ },
368
+ },
369
+ isError: true,
370
+ };
371
+ }
372
+
373
+ const result = await runAgent(agent, params.task, ctx.cwd, signal, onUpdate);
374
+
375
+ const isError =
376
+ result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
377
+ if (isError) {
378
+ const reason =
379
+ result.stopReason ?? (result.exitCode === 0 ? "failed" : `exit ${result.exitCode}`);
380
+ const message =
381
+ result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
382
+ return {
383
+ content: [
384
+ { type: "text", text: `Subagent "${result.agent}" failed (${reason}): ${message}` },
385
+ ],
386
+ details: result,
387
+ isError: true,
388
+ };
389
+ }
390
+
391
+ const output = getFinalOutput(result.messages) || "(no output)";
392
+ const truncation = truncateTail(output, { maxBytes: MAX_OUTPUT_BYTES });
393
+ const text = truncation.truncated
394
+ ? `${truncation.content}\n\n[Output truncated to ${formatTokens(truncation.content.length)} bytes. Full result preserved in tool details.]`
395
+ : output;
396
+ return { content: [{ type: "text", text }], details: result };
397
+ },
398
+
399
+ renderCall(args, theme) {
400
+ const preview = args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task;
401
+ let text = theme.fg("toolTitle", theme.bold("spawn_agent ")) + theme.fg("accent", args.agent);
402
+ text += `\n ${theme.fg("dim", preview)}`;
403
+ return new Text(text, 0, 0);
404
+ },
405
+
406
+ renderResult(result, { expanded }, theme) {
407
+ const details = result.details;
408
+ const isError =
409
+ details.exitCode !== 0 ||
410
+ details.stopReason === "error" ||
411
+ details.stopReason === "aborted";
412
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
413
+ const finalOutput = getFinalOutput(details.messages);
414
+ const usageStr = formatUsageStats(details.usage, details.model);
415
+
416
+ if (expanded) {
417
+ const container = new Container();
418
+ const header = `${icon} ${theme.fg("toolTitle", theme.bold(details.agent))}${
419
+ details.stopReason ? ` ${theme.fg("error", `[${details.stopReason}]`)}` : ""
420
+ }`;
421
+ container.addChild(new Text(header, 0, 0));
422
+ if (isError && details.errorMessage) {
423
+ container.addChild(new Text(theme.fg("error", `Error: ${details.errorMessage}`), 0, 0));
424
+ }
425
+ container.addChild(new Spacer(1));
426
+ container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
427
+ container.addChild(new Text(theme.fg("dim", details.task), 0, 0));
428
+ if (finalOutput) {
429
+ container.addChild(new Spacer(1));
430
+ container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
431
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, getMarkdownTheme()));
432
+ }
433
+ if (usageStr) {
434
+ container.addChild(new Spacer(1));
435
+ container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
436
+ }
437
+ return container;
438
+ }
439
+
440
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold(details.agent))}`;
441
+ if (isError && details.errorMessage) {
442
+ text += `\n${theme.fg("error", `Error: ${details.errorMessage}`)}`;
443
+ } else if (finalOutput) {
444
+ text += `\n${theme.fg("toolOutput", finalOutput.split("\n").slice(0, 5).join("\n"))}`;
445
+ } else {
446
+ text += `\n${theme.fg("muted", "(no output)")}`;
447
+ }
448
+ if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
449
+ return new Text(text, 0, 0);
450
+ },
451
+ });
452
+ }