@gotgenes/pi-subagents 6.12.0 → 6.13.0

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.
@@ -1,16 +1,13 @@
1
- import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
2
- import { join } from "node:path";
3
-
4
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
2
  import type { AgentSpawnConfig } from "../agent-manager.js";
6
- import {
7
- AgentTypeRegistry,
8
- BUILTIN_TOOL_NAMES,
9
- } from "../agent-types.js";
3
+ import { AgentTypeRegistry } from "../agent-types.js";
10
4
  import type { ModelRegistry } from "../model-resolver.js";
11
5
  import type { AgentConfig, AgentRecord } from "../types.js";
12
6
  import type { AgentActivityTracker } from "./agent-activity-tracker.js";
13
- import { formatDuration, getDisplayName } from "./agent-widget.js";
7
+ import { createAgentConfigEditor } from "./agent-config-editor.js";
8
+ import { createAgentCreationWizard } from "./agent-creation-wizard.js";
9
+ import type { AgentFileOps } from "./agent-file-ops.js";
10
+ import { formatDuration, getDisplayName } from "./display.js";
14
11
 
15
12
  // ---- Deps interface ----
16
13
 
@@ -48,6 +45,7 @@ export interface AgentMenuDeps {
48
45
  getModelLabel: (type: string, registry?: ModelRegistry) => string;
49
46
  /** Settings manager — owns in-memory values and persistence. */
50
47
  settings: AgentMenuSettings;
48
+ fileOps: AgentFileOps;
51
49
  personalAgentsDir: string;
52
50
  projectAgentsDir: string;
53
51
  }
@@ -61,15 +59,20 @@ export interface AgentMenuDeps {
61
59
  * Returns a function suitable for `pi.registerCommand("agents", { handler })`.
62
60
  */
63
61
  export function createAgentsMenuHandler(deps: AgentMenuDeps) {
64
- function findAgentFile(
65
- name: string,
66
- ): { path: string; location: "project" | "personal" } | undefined {
67
- const projectPath = join(deps.projectAgentsDir, `${name}.md`);
68
- if (existsSync(projectPath)) return { path: projectPath, location: "project" };
69
- const personalPath = join(deps.personalAgentsDir, `${name}.md`);
70
- if (existsSync(personalPath)) return { path: personalPath, location: "personal" };
71
- return undefined;
72
- }
62
+ const editor = createAgentConfigEditor({
63
+ fileOps: deps.fileOps,
64
+ registry: deps.registry,
65
+ personalAgentsDir: deps.personalAgentsDir,
66
+ projectAgentsDir: deps.projectAgentsDir,
67
+ });
68
+
69
+ const wizard = createAgentCreationWizard({
70
+ fileOps: deps.fileOps,
71
+ manager: deps.manager,
72
+ registry: deps.registry,
73
+ personalAgentsDir: deps.personalAgentsDir,
74
+ projectAgentsDir: deps.projectAgentsDir,
75
+ });
73
76
 
74
77
  async function showAgentsMenu(ctx: ExtensionContext) {
75
78
  deps.registry.reload();
@@ -118,7 +121,7 @@ export function createAgentsMenuHandler(deps: AgentMenuDeps) {
118
121
  await showAllAgentsList(ctx);
119
122
  await showAgentsMenu(ctx);
120
123
  } else if (choice === "Create new agent") {
121
- await showCreateWizard(ctx);
124
+ await wizard.showCreateWizard(ctx);
122
125
  } else if (choice === "Settings") {
123
126
  await showSettings(ctx);
124
127
  await showAgentsMenu(ctx);
@@ -174,7 +177,7 @@ export function createAgentsMenuHandler(deps: AgentMenuDeps) {
174
177
  .replace(/^[•◦✕\s]+/, "")
175
178
  .trim();
176
179
  if (deps.registry.resolveType(agentName) != null) {
177
- await showAgentDetail(ctx, agentName);
180
+ await editor.showAgentDetail(ctx, agentName);
178
181
  await showAllAgentsList(ctx);
179
182
  }
180
183
  }
@@ -233,381 +236,6 @@ export function createAgentsMenuHandler(deps: AgentMenuDeps) {
233
236
  );
234
237
  }
235
238
 
236
- async function showAgentDetail(ctx: ExtensionContext, name: string) {
237
- if (deps.registry.resolveType(name) == null) {
238
- ctx.ui.notify(`Agent config not found for "${name}".`, "warning");
239
- return;
240
- }
241
- const cfg = deps.registry.resolveAgentConfig(name);
242
-
243
- const file = findAgentFile(name);
244
- const isDefault = cfg.isDefault === true;
245
- const disabled = cfg.enabled === false;
246
-
247
- let menuOptions: string[];
248
- if (disabled && file) {
249
- menuOptions = isDefault
250
- ? ["Enable", "Edit", "Reset to default", "Delete", "Back"]
251
- : ["Enable", "Edit", "Delete", "Back"];
252
- } else if (isDefault && !file) {
253
- menuOptions = ["Eject (export as .md)", "Disable", "Back"];
254
- } else if (isDefault && file) {
255
- menuOptions = ["Edit", "Disable", "Reset to default", "Delete", "Back"];
256
- } else {
257
- menuOptions = ["Edit", "Disable", "Delete", "Back"];
258
- }
259
-
260
- const choice = await ctx.ui.select(name, menuOptions);
261
- if (!choice || choice === "Back") return;
262
-
263
- if (choice === "Edit" && file) {
264
- const content = readFileSync(file.path, "utf-8");
265
- const edited = await ctx.ui.editor(`Edit ${name}`, content);
266
- if (edited !== undefined && edited !== content) {
267
- const { writeFileSync } = await import("node:fs");
268
- writeFileSync(file.path, edited, "utf-8");
269
- deps.registry.reload();
270
- ctx.ui.notify(`Updated ${file.path}`, "info");
271
- }
272
- } else if (choice === "Delete") {
273
- if (file) {
274
- const confirmed = await ctx.ui.confirm(
275
- "Delete agent",
276
- `Delete ${name} from ${file.location} (${file.path})?`,
277
- );
278
- if (confirmed) {
279
- unlinkSync(file.path);
280
- deps.registry.reload();
281
- ctx.ui.notify(`Deleted ${file.path}`, "info");
282
- }
283
- }
284
- } else if (choice === "Reset to default" && file) {
285
- const confirmed = await ctx.ui.confirm(
286
- "Reset to default",
287
- `Delete override ${file.path} and restore embedded default?`,
288
- );
289
- if (confirmed) {
290
- unlinkSync(file.path);
291
- deps.registry.reload();
292
- ctx.ui.notify(`Restored default ${name}`, "info");
293
- }
294
- } else if (choice.startsWith("Eject")) {
295
- await ejectAgent(ctx, name, cfg);
296
- } else if (choice === "Disable") {
297
- await disableAgent(ctx, name);
298
- } else if (choice === "Enable") {
299
- await enableAgent(ctx, name);
300
- }
301
- }
302
-
303
- async function ejectAgent(ctx: ExtensionContext, name: string, cfg: AgentConfig) {
304
- const location = await ctx.ui.select("Choose location", [
305
- "Project (.pi/agents/)",
306
- `Personal (${deps.personalAgentsDir})`,
307
- ]);
308
- if (!location) return;
309
-
310
- const targetDir = location.startsWith("Project")
311
- ? deps.projectAgentsDir
312
- : deps.personalAgentsDir;
313
- mkdirSync(targetDir, { recursive: true });
314
-
315
- const targetPath = join(targetDir, `${name}.md`);
316
- if (existsSync(targetPath)) {
317
- const overwrite = await ctx.ui.confirm(
318
- "Overwrite",
319
- `${targetPath} already exists. Overwrite?`,
320
- );
321
- if (!overwrite) return;
322
- }
323
-
324
- const fmFields: string[] = [];
325
- fmFields.push(`description: ${cfg.description}`);
326
- if (cfg.displayName) fmFields.push(`display_name: ${cfg.displayName}`);
327
- fmFields.push(`tools: ${cfg.builtinToolNames?.join(", ") || "all"}`);
328
- if (cfg.model) fmFields.push(`model: ${cfg.model}`);
329
- if (cfg.thinking) fmFields.push(`thinking: ${cfg.thinking}`);
330
- if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`);
331
- fmFields.push(`prompt_mode: ${cfg.promptMode}`);
332
- if (cfg.extensions === false) fmFields.push("extensions: false");
333
- else if (Array.isArray(cfg.extensions))
334
- fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
335
- if (cfg.skills === false) fmFields.push("skills: false");
336
- else if (Array.isArray(cfg.skills))
337
- fmFields.push(`skills: ${cfg.skills.join(", ")}`);
338
- if (cfg.disallowedTools?.length)
339
- fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
340
- if (cfg.inheritContext) fmFields.push("inherit_context: true");
341
- if (cfg.runInBackground) fmFields.push("run_in_background: true");
342
- if (cfg.isolated) fmFields.push("isolated: true");
343
- if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`);
344
- if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`);
345
-
346
- const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
347
-
348
- const { writeFileSync } = await import("node:fs");
349
- writeFileSync(targetPath, content, "utf-8");
350
- deps.registry.reload();
351
- ctx.ui.notify(`Ejected ${name} to ${targetPath}`, "info");
352
- }
353
-
354
- async function disableAgent(ctx: ExtensionContext, name: string) {
355
- const file = findAgentFile(name);
356
- if (file) {
357
- const content = readFileSync(file.path, "utf-8");
358
- if (content.includes("\nenabled: false\n")) {
359
- ctx.ui.notify(`${name} is already disabled.`, "info");
360
- return;
361
- }
362
- const updated = content.replace(/^---\n/, "---\nenabled: false\n");
363
- const { writeFileSync } = await import("node:fs");
364
- writeFileSync(file.path, updated, "utf-8");
365
- deps.registry.reload();
366
- ctx.ui.notify(`Disabled ${name} (${file.path})`, "info");
367
- return;
368
- }
369
-
370
- const location = await ctx.ui.select("Choose location", [
371
- "Project (.pi/agents/)",
372
- `Personal (${deps.personalAgentsDir})`,
373
- ]);
374
- if (!location) return;
375
-
376
- const targetDir = location.startsWith("Project")
377
- ? deps.projectAgentsDir
378
- : deps.personalAgentsDir;
379
- mkdirSync(targetDir, { recursive: true });
380
-
381
- const targetPath = join(targetDir, `${name}.md`);
382
- const { writeFileSync } = await import("node:fs");
383
- writeFileSync(targetPath, "---\nenabled: false\n---\n", "utf-8");
384
- deps.registry.reload();
385
- ctx.ui.notify(`Disabled ${name} (${targetPath})`, "info");
386
- }
387
-
388
- async function enableAgent(ctx: ExtensionContext, name: string) {
389
- const file = findAgentFile(name);
390
- if (!file) return;
391
-
392
- const content = readFileSync(file.path, "utf-8");
393
- const updated = content.replace(/^(---\n)enabled: false\n/, "$1");
394
- const { writeFileSync } = await import("node:fs");
395
-
396
- if (updated.trim() === "---\n---" || updated.trim() === "---\n---\n") {
397
- unlinkSync(file.path);
398
- deps.registry.reload();
399
- ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info");
400
- } else {
401
- writeFileSync(file.path, updated, "utf-8");
402
- deps.registry.reload();
403
- ctx.ui.notify(`Enabled ${name} (${file.path})`, "info");
404
- }
405
- }
406
-
407
- async function showCreateWizard(ctx: ExtensionContext) {
408
- const location = await ctx.ui.select("Choose location", [
409
- "Project (.pi/agents/)",
410
- `Personal (${deps.personalAgentsDir})`,
411
- ]);
412
- if (!location) return;
413
-
414
- const targetDir = location.startsWith("Project")
415
- ? deps.projectAgentsDir
416
- : deps.personalAgentsDir;
417
-
418
- const method = await ctx.ui.select("Creation method", [
419
- "Generate with Claude (recommended)",
420
- "Manual configuration",
421
- ]);
422
- if (!method) return;
423
-
424
- if (method.startsWith("Generate")) {
425
- await showGenerateWizard(ctx, targetDir);
426
- } else {
427
- await showManualWizard(ctx, targetDir);
428
- }
429
- }
430
-
431
- async function showGenerateWizard(ctx: ExtensionContext, targetDir: string) {
432
- const description = await ctx.ui.input("Describe what this agent should do");
433
- if (!description) return;
434
-
435
- const name = await ctx.ui.input("Agent name (filename, no spaces)");
436
- if (!name) return;
437
-
438
- mkdirSync(targetDir, { recursive: true });
439
-
440
- const targetPath = join(targetDir, `${name}.md`);
441
- if (existsSync(targetPath)) {
442
- const overwrite = await ctx.ui.confirm(
443
- "Overwrite",
444
- `${targetPath} already exists. Overwrite?`,
445
- );
446
- if (!overwrite) return;
447
- }
448
-
449
- ctx.ui.notify("Generating agent definition...", "info");
450
-
451
- const generatePrompt = `Create a custom pi sub-agent definition file based on this description: "${description}"
452
-
453
- Write a markdown file to: ${targetPath}
454
-
455
- The file format is a markdown file with YAML frontmatter and a system prompt body:
456
-
457
- \`\`\`markdown
458
- ---
459
- description: <one-line description shown in UI>
460
- tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
461
- model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5-20251001". Omit to inherit parent model>
462
- thinking: <optional thinking level: off, minimal, low, medium, high, xhigh. Omit to inherit>
463
- max_turns: <optional max agentic turns. 0 or omit for unlimited (default)>
464
- prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace>
465
- extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
466
- skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
467
- disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
468
- inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
469
- run_in_background: <true to run in background by default. Default: false>
470
- isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
471
- memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>
472
- isolation: <"worktree" to run in isolated git worktree. Omit for normal>
473
- ---
474
-
475
- <system prompt body — instructions for the agent>
476
- \`\`\`
477
-
478
- Guidelines for choosing settings:
479
- - For read-only tasks (review, analysis): tools: read, bash, grep, find, ls
480
- - For code modification tasks: include edit, write
481
- - Use prompt_mode: append if the agent should keep the default system prompt and add specialization on top
482
- - Use prompt_mode: replace for fully custom agents with their own personality/instructions
483
- - Set inherit_context: true if the agent needs to know what was discussed in the parent conversation
484
- - Set isolated: true if the agent should NOT have access to MCP servers or other extensions
485
- - Only include frontmatter fields that differ from defaults — omit fields where the default is fine
486
-
487
- Write the file using the write tool. Only write the file, nothing else.`;
488
-
489
- const record = await deps.manager.spawnAndWait(
490
- ctx,
491
- "general-purpose",
492
- generatePrompt,
493
- {
494
- description: `Generate ${name} agent`,
495
- maxTurns: 5,
496
- },
497
- );
498
-
499
- if (record.status === "error") {
500
- ctx.ui.notify(`Generation failed: ${record.error}`, "warning");
501
- return;
502
- }
503
-
504
- deps.registry.reload();
505
-
506
- if (existsSync(targetPath)) {
507
- ctx.ui.notify(`Created ${targetPath}`, "info");
508
- } else {
509
- ctx.ui.notify(
510
- "Agent generation completed but file was not created. Check the agent output.",
511
- "warning",
512
- );
513
- }
514
- }
515
-
516
- async function showManualWizard(ctx: ExtensionContext, targetDir: string) {
517
- const name = await ctx.ui.input("Agent name (filename, no spaces)");
518
- if (!name) return;
519
-
520
- const description = await ctx.ui.input("Description (one line)");
521
- if (!description) return;
522
-
523
- const toolChoice = await ctx.ui.select("Tools", [
524
- "all",
525
- "none",
526
- "read-only (read, bash, grep, find, ls)",
527
- "custom...",
528
- ]);
529
- if (!toolChoice) return;
530
-
531
- let tools: string;
532
- if (toolChoice === "all") {
533
- tools = BUILTIN_TOOL_NAMES.join(", ");
534
- } else if (toolChoice === "none") {
535
- tools = "none";
536
- } else if (toolChoice.startsWith("read-only")) {
537
- tools = "read, bash, grep, find, ls";
538
- } else {
539
- const customTools = await ctx.ui.input(
540
- "Tools (comma-separated)",
541
- BUILTIN_TOOL_NAMES.join(", "),
542
- );
543
- if (!customTools) return;
544
- tools = customTools;
545
- }
546
-
547
- const modelChoice = await ctx.ui.select("Model", [
548
- "inherit (parent model)",
549
- "haiku",
550
- "sonnet",
551
- "opus",
552
- "custom...",
553
- ]);
554
- if (!modelChoice) return;
555
-
556
- let modelLine = "";
557
- if (modelChoice === "haiku")
558
- modelLine = "\nmodel: anthropic/claude-haiku-4-5-20251001";
559
- else if (modelChoice === "sonnet")
560
- modelLine = "\nmodel: anthropic/claude-sonnet-4-6";
561
- else if (modelChoice === "opus")
562
- modelLine = "\nmodel: anthropic/claude-opus-4-6";
563
- else if (modelChoice === "custom...") {
564
- const customModel = await ctx.ui.input("Model (provider/modelId)");
565
- if (customModel) modelLine = `\nmodel: ${customModel}`;
566
- }
567
-
568
- const thinkingChoice = await ctx.ui.select("Thinking level", [
569
- "inherit",
570
- "off",
571
- "minimal",
572
- "low",
573
- "medium",
574
- "high",
575
- "xhigh",
576
- ]);
577
- if (!thinkingChoice) return;
578
-
579
- let thinkingLine = "";
580
- if (thinkingChoice !== "inherit") thinkingLine = `\nthinking: ${thinkingChoice}`;
581
-
582
- const systemPrompt = await ctx.ui.editor("System prompt", "");
583
- if (systemPrompt === undefined) return;
584
-
585
- const content = `---
586
- description: ${description}
587
- tools: ${tools}${modelLine}${thinkingLine}
588
- prompt_mode: replace
589
- ---
590
-
591
- ${systemPrompt}
592
- `;
593
-
594
- mkdirSync(targetDir, { recursive: true });
595
- const targetPath = join(targetDir, `${name}.md`);
596
-
597
- if (existsSync(targetPath)) {
598
- const overwrite = await ctx.ui.confirm(
599
- "Overwrite",
600
- `${targetPath} already exists. Overwrite?`,
601
- );
602
- if (!overwrite) return;
603
- }
604
-
605
- const { writeFileSync } = await import("node:fs");
606
- writeFileSync(targetPath, content, "utf-8");
607
- deps.registry.reload();
608
- ctx.ui.notify(`Created ${targetPath}`, "info");
609
- }
610
-
611
239
  async function showSettings(ctx: ExtensionContext) {
612
240
  const choice = await ctx.ui.select("Settings", [
613
241
  `Max concurrency (current: ${deps.settings.maxConcurrent})`,
@@ -7,40 +7,29 @@
7
7
 
8
8
  import { truncateToWidth } from "@earendil-works/pi-tui";
9
9
  import type { AgentManager } from "../agent-manager.js";
10
- import { type AgentConfigLookup, AgentTypeRegistry } from "../agent-types.js";
11
- import type { AgentInvocation, SubagentType } from "../types.js";
10
+ import { AgentTypeRegistry } from "../agent-types.js";
11
+ import type { SubagentType } from "../types.js";
12
12
  import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
13
  import type { AgentActivityTracker } from "./agent-activity-tracker.js";
14
+ import {
15
+ describeActivity,
16
+ ERROR_STATUSES,
17
+ formatMs,
18
+ formatSessionTokens,
19
+ formatTurns,
20
+ getDisplayName,
21
+ getPromptModeLabel,
22
+ SPINNER,
23
+ type Theme,
24
+ } from "./display.js";
14
25
 
15
26
  // ---- Constants ----
16
27
 
17
28
  /** Maximum number of rendered lines before overflow collapse kicks in. */
18
29
  const MAX_WIDGET_LINES = 12;
19
30
 
20
- /** Braille spinner frames for animated running indicator. */
21
- export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
22
-
23
- /** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
24
- export const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
25
-
26
- /** Tool name → human-readable action for activity descriptions. */
27
- const TOOL_DISPLAY: Record<string, string> = {
28
- read: "reading",
29
- bash: "running command",
30
- edit: "editing",
31
- write: "writing",
32
- grep: "searching",
33
- find: "finding files",
34
- ls: "listing",
35
- };
36
-
37
31
  // ---- Types ----
38
32
 
39
- export type Theme = {
40
- fg(color: string, text: string): string;
41
- bold(text: string): string;
42
- };
43
-
44
33
  export type UICtx = {
45
34
  setStatus(key: string, text: string | undefined): void;
46
35
  setWidget(
@@ -50,147 +39,6 @@ export type UICtx = {
50
39
  ): void;
51
40
  };
52
41
 
53
- /** Metadata attached to Agent tool results for custom rendering. */
54
- export interface AgentDetails {
55
- displayName: string;
56
- description: string;
57
- subagentType: string;
58
- toolUses: number;
59
- tokens: string;
60
- durationMs: number;
61
- status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error" | "background";
62
- /** Human-readable description of what the agent is currently doing. */
63
- activity?: string;
64
- /** Current spinner frame index (for animated running indicator). */
65
- spinnerFrame?: number;
66
- /** Short model name if different from parent (e.g. "haiku", "sonnet"). */
67
- modelName?: string;
68
- /** Notable config tags (e.g. ["thinking: high", "isolated"]). */
69
- tags?: string[];
70
- /** Current turn count. */
71
- turnCount?: number;
72
- /** Effective max turns (undefined = unlimited). */
73
- maxTurns?: number;
74
- agentId?: string;
75
- error?: string;
76
- }
77
-
78
- // ---- Formatting helpers ----
79
-
80
- /** Format a token count compactly: "33.8k token", "1.2M token". */
81
- export function formatTokens(count: number): string {
82
- if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`;
83
- if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`;
84
- return `${count} token`;
85
- }
86
-
87
- /**
88
- * Token count with optional context-fill % and compaction-count annotations.
89
- * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
90
- * Compaction count rendered as `↻N` in dim.
91
- *
92
- * "12.3k token" — no annotations
93
- * "12.3k token (45%)" — percent only
94
- * "12.3k token (↻2)" — compactions only (e.g. right after compact)
95
- * "12.3k token (45% · ↻2)" — both
96
- */
97
- export function formatSessionTokens(
98
- tokens: number,
99
- percent: number | null,
100
- theme: Theme,
101
- compactions = 0,
102
- ): string {
103
- const tokenStr = formatTokens(tokens);
104
- const annot: string[] = [];
105
- if (percent !== null) {
106
- const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
107
- annot.push(theme.fg(color, `${Math.round(percent)}%`));
108
- }
109
- if (compactions > 0) {
110
- annot.push(theme.fg("dim", `↻${compactions}`));
111
- }
112
- if (annot.length === 0) return tokenStr;
113
- return `${tokenStr} (${annot.join(" · ")})`;
114
- }
115
-
116
- /** Format turn count with optional max limit: "⟳5≤30" or "⟳5". */
117
- export function formatTurns(turnCount: number, maxTurns?: number | null): string {
118
- return maxTurns != null ? `⟳${turnCount}≤${maxTurns}` : `⟳${turnCount}`;
119
- }
120
-
121
- /** Format milliseconds as human-readable duration. */
122
- export function formatMs(ms: number): string {
123
- return `${(ms / 1000).toFixed(1)}s`;
124
- }
125
-
126
- /** Format duration from start/completed timestamps. */
127
- export function formatDuration(startedAt: number, completedAt?: number): string {
128
- if (completedAt) return formatMs(completedAt - startedAt);
129
- return `${formatMs(Date.now() - startedAt)} (running)`;
130
- }
131
-
132
- /** Get display name for any agent type (built-in or custom). */
133
- export function getDisplayName(type: SubagentType, registry: AgentConfigLookup): string {
134
- const config = registry.resolveAgentConfig(type);
135
- return config.displayName ?? config.name;
136
- }
137
-
138
- /** Short label for prompt mode: "twin" for append, nothing for replace (the default). */
139
- export function getPromptModeLabel(type: SubagentType, registry: AgentConfigLookup): string | undefined {
140
- const config = registry.resolveAgentConfig(type);
141
- return config.promptMode === "append" ? "twin" : undefined;
142
- }
143
-
144
- /** Mode label is not included — callers add it where they want it. */
145
- export function buildInvocationTags(
146
- invocation: AgentInvocation | undefined,
147
- ): { modelName?: string; tags: string[] } {
148
- const tags: string[] = [];
149
- if (!invocation) return { tags };
150
- if (invocation.thinking) tags.push(`thinking: ${invocation.thinking}`);
151
- if (invocation.isolated) tags.push("isolated");
152
- if (invocation.isolation === "worktree") tags.push("worktree");
153
- if (invocation.inheritContext) tags.push("inherit context");
154
- if (invocation.runInBackground) tags.push("background");
155
- if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
156
- return { modelName: invocation.modelName, tags };
157
- }
158
-
159
- /** Truncate text to a single line, max `len` chars. */
160
- function truncateLine(text: string, len = 60): string {
161
- const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
162
- if (line.length <= len) return line;
163
- return line.slice(0, len) + "…";
164
- }
165
-
166
- /** Build a human-readable activity string from currently-running tools or response text. */
167
- export function describeActivity(activeTools: ReadonlyMap<string, string>, responseText?: string): string {
168
- if (activeTools.size > 0) {
169
- const groups = new Map<string, number>();
170
- for (const toolName of activeTools.values()) {
171
- const action = TOOL_DISPLAY[toolName] ?? toolName;
172
- groups.set(action, (groups.get(action) ?? 0) + 1);
173
- }
174
-
175
- const parts: string[] = [];
176
- for (const [action, count] of groups) {
177
- if (count > 1) {
178
- parts.push(`${action} ${count} ${action === "searching" ? "patterns" : "files"}`);
179
- } else {
180
- parts.push(action);
181
- }
182
- }
183
- return parts.join(", ") + "…";
184
- }
185
-
186
- // No tools active — show truncated response text if available
187
- if (responseText && responseText.trim().length > 0) {
188
- return truncateLine(responseText);
189
- }
190
-
191
- return "thinking…";
192
- }
193
-
194
42
  // ---- Widget manager ----
195
43
 
196
44
  export class AgentWidget {
@@ -12,7 +12,7 @@ import { extractText } from "../context.js";
12
12
  import type { AgentRecord } from "../types.js";
13
13
  import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
14
14
  import type { AgentActivityTracker } from "./agent-activity-tracker.js";
15
- import { buildInvocationTags, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel, type Theme } from "./agent-widget.js";
15
+ import { buildInvocationTags, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel, type Theme } from "./display.js";
16
16
 
17
17
  // ── Local message-shape types ───────────────────────────────────────────────
18
18
  // The Pi SDK does not export narrow types for all message content variants.