@yandy0725/pi-subagents 0.1.0 → 0.2.2

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
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.0",
6
+ "version": "0.2.2",
7
7
  "description": "Focused, in-process sub-agent core for pi — autonomous agents plus a typed API and lifecycle events",
8
8
  "license": "MIT",
9
9
  "repository": {
@@ -33,12 +33,10 @@
33
33
  ]
34
34
  },
35
35
  "peerDependencies": {
36
- "@earendil-works/pi-ai": ">=0.74.0",
37
- "@earendil-works/pi-coding-agent": ">=0.74.0",
38
- "@earendil-works/pi-tui": ">=0.74.0"
39
- },
40
- "dependencies": {
41
- "@sinclair/typebox": "^0.34.49"
36
+ "@earendil-works/pi-ai": ">=0.80.2",
37
+ "@earendil-works/pi-coding-agent": ">=0.80.2",
38
+ "@earendil-works/pi-tui": ">=0.80.2",
39
+ "@sinclair/typebox": "*"
42
40
  },
43
41
  "devDependencies": {
44
42
  "@biomejs/biome": "^2.5.0",
@@ -1,3 +1,4 @@
1
+ import type { EvictedSubagent } from "../lifecycle/subagent-manager";
1
2
  import type { SessionContext } from "../types";
2
3
 
3
4
  /**
@@ -12,6 +13,8 @@ export interface LifecycleManager {
12
13
  clearCompleted(): void;
13
14
  abortAll(): void;
14
15
  dispose(): void;
16
+ /** Repopulate evicted descriptors recovered from disk on session start. */
17
+ restoreEvicted(descriptors: readonly EvictedSubagent[]): void;
15
18
  }
16
19
 
17
20
  /** Narrow runtime interface — only the methods lifecycle handlers call. */
@@ -20,14 +23,20 @@ export interface LifecycleRuntime {
20
23
  clearSessionContext(): void;
21
24
  }
22
25
 
26
+ /** Recovers evicted subagent descriptors from the parent session file. */
27
+ export type RecoverEvicted = (parentSessionFile: string | undefined) => EvictedSubagent[];
28
+
23
29
  /**
24
30
  * Handles session lifecycle events.
25
31
  *
26
32
  * Constructor deps:
27
33
  * - `runtime` — owns session context state
28
- * - `manager` — manages agent lifecycle (clear, abort, dispose)
34
+ * - `manager` — manages agent lifecycle (clear, abort, dispose, restore)
29
35
  * - `disposeNotifications` — tears down the notification system on shutdown
30
36
  * - `unpublishService` — unpublishes the SubagentsService symbol on shutdown
37
+ * - `recoverEvicted` — rebuilds navigable descriptors from the parent session
38
+ * file so `/subagents:sessions` works after resume/fork (the extension is
39
+ * re-instantiated per session, so the in-memory evicted map starts empty)
31
40
  */
32
41
  export class SessionLifecycleHandler {
33
42
  constructor(
@@ -35,11 +44,16 @@ export class SessionLifecycleHandler {
35
44
  private readonly manager: LifecycleManager,
36
45
  private readonly disposeNotifications: () => void,
37
46
  private readonly unpublishService: () => void,
47
+ private readonly recoverEvicted: RecoverEvicted,
38
48
  ) {}
39
49
 
40
50
  handleSessionStart(_event: unknown, ctx: unknown): void {
41
- this.runtime.setSessionContext(ctx as SessionContext);
51
+ const sessionCtx = ctx as SessionContext;
52
+ this.runtime.setSessionContext(sessionCtx);
42
53
  this.manager.clearCompleted();
54
+ // Re-discover persisted subagent sessions from the parent session file so
55
+ // they remain viewable after resume/fork (the in-memory map starts empty).
56
+ this.manager.restoreEvicted(this.recoverEvicted(sessionCtx.sessionManager?.getSessionFile()));
43
57
  }
44
58
 
45
59
  handleSessionBeforeSwitch(): void {
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * Commands:
11
11
  */
12
12
 
13
- import { readFileSync } from "node:fs";
13
+ import { existsSync, readFileSync } from "node:fs";
14
14
  import {
15
15
  createAgentSession,
16
16
  DefaultResourceLoader,
@@ -37,6 +37,7 @@ import { detectEnv } from "./session/env";
37
37
 
38
38
  import { resolveModel } from "./session/model-resolver";
39
39
  import { buildAgentPrompt } from "./session/prompts";
40
+ import { recoverEvictedSubagents } from "./session/recover-subagents";
40
41
  import { deriveSubagentSessionDir } from "./session/session-dir";
41
42
  import { SettingsManager } from "./settings";
42
43
  import { AgentTool } from "./tools/agent-tool";
@@ -124,6 +125,7 @@ export default function (pi: ExtensionAPI) {
124
125
  manager,
125
126
  () => notifications.dispose(),
126
127
  unpublishSubagentsService,
128
+ (parentSessionFile) => recoverEvictedSubagents(parentSessionFile, (path) => readFileSync(path, "utf8"), existsSync),
127
129
  );
128
130
 
129
131
  pi.on("session_start", (event, ctx) => lifecycle.handleSessionStart(event, ctx));
@@ -36,6 +36,8 @@ export interface EvictedSubagent {
36
36
  readonly startedAt: number;
37
37
  readonly completedAt: number | undefined;
38
38
  readonly toolUses: number;
39
+ /** Short display model name (always shown; falls back to parent model when unset). */
40
+ readonly modelName?: string;
39
41
  readonly outputFile: string;
40
42
  }
41
43
 
@@ -132,7 +134,7 @@ export class SubagentManager {
132
134
  this.observer?.onSubagentStarted(agent);
133
135
  },
134
136
  onSessionCreated: options.observer?.onSessionCreated
135
- ? (agent) => options.observer!.onSessionCreated!(agent)
137
+ ? (agent) => options.observer?.onSessionCreated?.(agent)
136
138
  : undefined,
137
139
  onRunFinished: (agent) => {
138
140
  if (options.isBackground) {
@@ -237,6 +239,21 @@ export class SubagentManager {
237
239
  return [...this.evicted.values()].sort((a, b) => b.startedAt - a.startedAt);
238
240
  }
239
241
 
242
+ /**
243
+ * Repopulate evicted descriptors recovered from disk (the parent session's
244
+ * `subagents:record` entries) on session start/resume/fork — the in-memory
245
+ * map starts empty because the extension is re-instantiated per session.
246
+ *
247
+ * Descriptors whose id still has a live record are dropped: the live record
248
+ * (and its real-time transcript) wins over the stale on-disk descriptor.
249
+ */
250
+ restoreEvicted(descriptors: readonly EvictedSubagent[]): void {
251
+ for (const descriptor of descriptors) {
252
+ if (this.agents.has(descriptor.id)) continue; // live record wins
253
+ this.evicted.set(descriptor.id, descriptor);
254
+ }
255
+ }
256
+
240
257
  abort(id: string): boolean {
241
258
  const record = this.agents.get(id);
242
259
  if (!record) return false;
@@ -348,6 +365,7 @@ function toEvictedSubagent(record: Subagent, outputFile: string): EvictedSubagen
348
365
  startedAt: record.startedAt,
349
366
  completedAt: record.completedAt,
350
367
  toolUses: record.toolUses,
368
+ modelName: record.invocation?.modelName,
351
369
  outputFile,
352
370
  };
353
371
  }
@@ -122,7 +122,7 @@ export class SubagentState {
122
122
 
123
123
  /** Record a tool starting. Called by record-observer on tool_execution_start. */
124
124
  addActiveTool(toolName: string): void {
125
- this._activeTools.set(toolName + "_" + ++this._toolKeySeq, toolName);
125
+ this._activeTools.set(`${toolName}_${++this._toolKeySeq}`, toolName);
126
126
  }
127
127
 
128
128
  /** Remove one active tool by name (first match). Called by record-observer on tool_execution_end. */
@@ -129,6 +129,10 @@ export class Subagent {
129
129
  get maxTurns(): number | undefined {
130
130
  return this.execution.maxTurns;
131
131
  }
132
+ /** Short display model name (always shown; falls back to parent model when unset). */
133
+ get modelName(): string | undefined {
134
+ return this.invocation?.modelName;
135
+ }
132
136
 
133
137
  readonly abortController: AbortController;
134
138
  private _promise?: Promise<void>;
@@ -15,6 +15,8 @@ export interface NotificationDetails {
15
15
  outputFile?: string;
16
16
  error?: string;
17
17
  resultPreview: string;
18
+ /** Short display model name (always shown; falls back to parent model when unset). */
19
+ modelName?: string;
18
20
  }
19
21
 
20
22
  // ---- Pure helpers (exported for unit testing) ----
@@ -51,7 +53,7 @@ export function formatTaskNotification(record: Subagent, resultMaxLen: number):
51
53
 
52
54
  const resultPreview = record.result
53
55
  ? record.result.length > resultMaxLen
54
- ? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
56
+ ? `${record.result.slice(0, resultMaxLen)}\n...(truncated, use get_subagent_result for full output)`
55
57
  : record.result
56
58
  : "No output.";
57
59
 
@@ -87,9 +89,10 @@ export function buildNotificationDetails(record: Subagent, resultMaxLen: number)
87
89
  durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
88
90
  outputFile: record.outputFile,
89
91
  error: record.error,
92
+ modelName: record.modelName,
90
93
  resultPreview: record.result
91
94
  ? record.result.length > resultMaxLen
92
- ? record.result.slice(0, resultMaxLen) + "…"
95
+ ? `${record.result.slice(0, resultMaxLen)}…`
93
96
  : record.result
94
97
  : "No output.",
95
98
  };
@@ -36,26 +36,27 @@ export function createNotificationRenderer() {
36
36
 
37
37
  // Line 2: stats
38
38
  const parts: string[] = [];
39
+ if (d.modelName) parts.push(d.modelName);
39
40
  if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
40
41
  if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
41
42
  if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
42
43
  if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
43
44
  if (parts.length) {
44
- line += "\n " + parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
45
+ line += `\n ${parts.map((p) => theme.fg("dim", p)).join(` ${theme.fg("dim", "·")} `)}`;
45
46
  }
46
47
 
47
48
  // Line 3: result preview (collapsed) or full (expanded)
48
49
  if (expanded) {
49
50
  const lines = d.resultPreview.split("\n").slice(0, 30);
50
- for (const l of lines) line += "\n" + theme.fg("dim", ` ${l}`);
51
+ for (const l of lines) line += `\n${theme.fg("dim", ` ${l}`)}`;
51
52
  } else {
52
53
  const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
53
- line += "\n " + theme.fg("dim", `⎿ ${preview}`);
54
+ line += `\n ${theme.fg("dim", `⎿ ${preview}`)}`;
54
55
  }
55
56
 
56
57
  // Line 4: output file link (if present)
57
58
  if (d.outputFile) {
58
- line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`);
59
+ line += `\n ${theme.fg("muted", `transcript: ${d.outputFile}`)}`;
59
60
  }
60
61
 
61
62
  return new Text(line, 0, 0);
@@ -62,6 +62,9 @@ export class SubagentEventsObserver implements SubagentManagerObserver {
62
62
  error: record.error,
63
63
  startedAt: record.startedAt,
64
64
  completedAt: record.completedAt,
65
+ toolUses: record.toolUses,
66
+ modelName: record.modelName,
67
+ outputFile: record.outputFile,
65
68
  });
66
69
 
67
70
  // Skip notification if result was already consumed via get_subagent_result.
@@ -9,6 +9,7 @@ import type { ParentSnapshot } from "../lifecycle/parent-snapshot";
9
9
  import type { WorkspaceProvider } from "../lifecycle/workspace";
10
10
  import type { SpawnOptions, SubagentRecord, SubagentsService } from "../service/service";
11
11
  import type { ModelRegistry } from "../session/model-resolver";
12
+ import { resolveModelName } from "../tools/spawn-config";
12
13
  import type { SessionContext, Subagent } from "../types";
13
14
 
14
15
  /** Narrow interface for the SubagentManager — avoids coupling to the concrete class. */
@@ -56,6 +57,11 @@ export class SubagentsServiceAdapter implements SubagentsService {
56
57
  }
57
58
  model = resolved;
58
59
  }
60
+ // Always compute display model name, even when same as parent
61
+ const modelName = resolveModelName(
62
+ (model as { id?: string; name?: string; provider?: string } | undefined) ??
63
+ (this.runtime.currentCtx.model as { id?: string; name?: string; provider?: string } | undefined),
64
+ );
59
65
 
60
66
  const description = options?.description ?? prompt.slice(0, 80);
61
67
  const isBackground = !(options?.foreground ?? false);
@@ -69,6 +75,9 @@ export class SubagentsServiceAdapter implements SubagentsService {
69
75
  inheritContext: options?.inheritContext,
70
76
  bypassQueue: options?.bypassQueue,
71
77
  isBackground,
78
+ // Service API builds a display-minimal invocation (modelName only);
79
+ // the tool path additionally carries thinking/maxTurns/inherit tags.
80
+ invocation: modelName != null ? { modelName } : undefined,
72
81
  });
73
82
  }
74
83
 
@@ -126,6 +135,7 @@ export function toSubagentRecord(record: Subagent): SubagentRecord {
126
135
  if (record.result !== undefined) out.result = record.result;
127
136
  if (record.error !== undefined) out.error = record.error;
128
137
  if (record.completedAt !== undefined) out.completedAt = record.completedAt;
138
+ if (record.modelName !== undefined) out.modelName = record.modelName;
129
139
 
130
140
  return out;
131
141
  }
@@ -48,6 +48,8 @@ export interface SubagentRecord {
48
48
  completedAt?: number;
49
49
  lifetimeUsage: LifetimeUsage;
50
50
  compactionCount: number;
51
+ /** Short display model name (always shown; falls back to parent model when unset). */
52
+ modelName?: string;
51
53
  }
52
54
 
53
55
  /** Options for spawning an agent via the service. */
@@ -27,7 +27,7 @@ export function getAgentConversation(session: AgentSession): string {
27
27
  if (toolNames.length > 0) parts.push(`[Tool Calls]:\n${toolNames.map((n) => ` Tool: ${n}`).join("\n")}`);
28
28
  } else if (msg.role === "toolResult") {
29
29
  const text = extractText(msg.content);
30
- const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
30
+ const truncated = text.length > 200 ? `${text.slice(0, 200)}...` : text;
31
31
  parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
32
32
  }
33
33
  }
@@ -66,14 +66,14 @@ You are operating as a sub-agent invoked to handle a specific task.
66
66
  // placed verbatim (no wrapper tag) so it forms an identical byte prefix
67
67
  // with the parent session, maximising KV cache hits. The <active_agent>
68
68
  // tag and env block vary per call and are placed after the cached prefix.
69
- return identity + "\n\n" + bridge + "\n\n" + activeAgentTag + envBlock + customSection;
69
+ return `${identity}\n\n${bridge}\n\n${activeAgentTag}${envBlock}${customSection}`;
70
70
  }
71
71
 
72
72
  // "replace" mode — parent/genericBase prefix first for KV cache reuse, then
73
73
  // the active_agent tag, env block, and the config's full system prompt.
74
74
  // Unlike append mode, no <sub_agent_context> bridge or <agent_instructions>
75
75
  // wrapper is injected — the custom prompt retains full control.
76
- return identity + "\n\n" + activeAgentTag + envBlock + "\n\n" + config.systemPrompt;
76
+ return `${identity}\n\n${activeAgentTag}${envBlock}\n\n${config.systemPrompt}`;
77
77
  }
78
78
 
79
79
  /** Fallback base prompt when parent system prompt is unavailable (both modes). */
@@ -0,0 +1,109 @@
1
+ /**
2
+ * recover-subagents.ts — Rebuild navigable subagent descriptors from disk.
3
+ *
4
+ * When a session is resumed or forked, the in-memory `SubagentManager` starts
5
+ * empty (the extension is re-instantiated per session). The child sessions'
6
+ * transcripts still live on disk, and a `subagents:record` custom entry was
7
+ * appended to the *parent* session file on each completion — carrying the
8
+ * metadata (and, since this fix, the transcript `outputFile`) needed to label
9
+ * and source them. This module parses those entries back into `EvictedSubagent`
10
+ * descriptors so `/subagents:sessions` can show them again after resume/fork.
11
+ *
12
+ * Pure: takes the parent session file path, a `readFile` seam, and an optional
13
+ * `exists` seam (defaults to a readFile-based probe), returns descriptors. The
14
+ * default keeps the function free of direct `fs` calls; callers may pass
15
+ * `fs.existsSync` to avoid reading the whole file just to check existence.
16
+ */
17
+
18
+ import { join } from "node:path";
19
+ import { type CustomEntry, parseSessionEntries } from "@earendil-works/pi-coding-agent";
20
+ import type { EvictedSubagent } from "../lifecycle/subagent-manager";
21
+ import type { SubagentStatus } from "../lifecycle/subagent-state";
22
+ import type { SubagentType } from "../types";
23
+ import { deriveSubagentSessionDir } from "./session-dir";
24
+
25
+ /** The customType under which completed-subagent records are persisted. */
26
+ export const SUBAGENT_RECORD_CUSTOM_TYPE = "subagents:record";
27
+
28
+ /** Fields persisted on each `subagents:record` entry (see onSubagentCompleted). */
29
+ interface PersistedSubagentRecord {
30
+ id: string;
31
+ type: SubagentType;
32
+ description: string;
33
+ status: SubagentStatus;
34
+ result?: string;
35
+ error?: string;
36
+ startedAt: number;
37
+ completedAt: number | undefined;
38
+ toolUses?: number;
39
+ modelName?: string;
40
+ outputFile?: string;
41
+ }
42
+
43
+ /**
44
+ * Read the parent session file and rebuild `EvictedSubagent` descriptors from
45
+ * its `subagents:record` entries, most-recent-first. Records without an
46
+ * `outputFile` are dropped — their transcript is not navigable from disk.
47
+ *
48
+ * Returns `[]` when the parent session is not persisted (no file) or the file
49
+ * cannot be read, so callers can invoke unconditionally on session start.
50
+ */
51
+ export function recoverEvictedSubagents(
52
+ parentSessionFile: string | undefined,
53
+ readFile: (path: string) => string,
54
+ exists: (path: string) => boolean = (path) => {
55
+ try {
56
+ readFile(path);
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ },
62
+ ): EvictedSubagent[] {
63
+ if (!parentSessionFile) return [];
64
+ let entries: ReturnType<typeof parseSessionEntries>;
65
+ try {
66
+ entries = parseSessionEntries(readFile(parentSessionFile));
67
+ } catch {
68
+ return [];
69
+ }
70
+
71
+ const records = entries.filter(
72
+ (entry): entry is CustomEntry =>
73
+ entry.type === "custom" && (entry as CustomEntry).customType === SUBAGENT_RECORD_CUSTOM_TYPE,
74
+ );
75
+
76
+ const recovered: EvictedSubagent[] = [];
77
+ // Derive the tasks directory once for fallback outputFile construction.
78
+ // The empty cwd is harmless: when parentSessionFile is set, session-dir
79
+ // derives the path from it and ignores cwd entirely.
80
+ const tasksDir = deriveSubagentSessionDir(parentSessionFile, "");
81
+ for (const entry of records) {
82
+ const data = (entry.data ?? {}) as PersistedSubagentRecord;
83
+ // Try to determine outputFile: prefer the persisted one, else construct
84
+ // from the agent id and tasks directory (backwards compat for pre-fix records).
85
+ let outputFile: string | undefined = data.outputFile;
86
+ if (!outputFile) {
87
+ const constructed = join(tasksDir, `${data.id}.jsonl`);
88
+ // Confirm the session file is readable so the file-snapshot source
89
+ // works later. The `exists` seam defaults to a readFile probe (pure);
90
+ // callers may pass `fs.existsSync` to avoid reading the whole file.
91
+ if (!exists(constructed)) continue; // session file doesn't exist — not navigable
92
+ outputFile = constructed;
93
+ }
94
+ recovered.push({
95
+ id: data.id,
96
+ type: data.type,
97
+ description: data.description,
98
+ status: data.status,
99
+ startedAt: data.startedAt,
100
+ completedAt: data.completedAt,
101
+ toolUses: data.toolUses ?? 0,
102
+ modelName: data.modelName,
103
+ outputFile,
104
+ });
105
+ }
106
+ // Most-recent-first, matching listEvicted()'s ordering.
107
+ recovered.sort((a, b) => b.startedAt - a.startedAt);
108
+ return recovered;
109
+ }
@@ -192,7 +192,7 @@ Guidelines:
192
192
  const displayName = args.subagent_type ? getDisplayName(args.subagent_type as string, registry) : "Subagent";
193
193
  const desc = (args.description as string | undefined) ?? "";
194
194
  return new Text(
195
- "▸ " + theme.fg("toolTitle", theme.bold(displayName)) + (desc ? " " + theme.fg("muted", desc) : ""),
195
+ `▸ ${theme.fg("toolTitle", theme.bold(displayName))}${desc ? ` ${theme.fg("muted", desc)}` : ""}`,
196
196
  0,
197
197
  0,
198
198
  );
@@ -33,8 +33,8 @@ export function renderAgentResult(
33
33
  export function renderRunning(details: AgentDetails, theme: Theme): string {
34
34
  const frame = SPINNER[details.spinnerFrame ?? 0];
35
35
  const s = renderStats(details, theme);
36
- let line = theme.fg("accent", frame) + (s ? " " + s : "");
37
- line += "\n" + theme.fg("dim", ` ⎿ ${details.activity ?? "thinking\u2026"}`);
36
+ let line = theme.fg("accent", frame) + (s ? ` ${s}` : "");
37
+ line += `\n${theme.fg("dim", ` ⎿ ${details.activity ?? "thinking\u2026"}`)}`;
38
38
  return line;
39
39
  }
40
40
 
@@ -49,22 +49,22 @@ export function renderCompleted(details: AgentDetails, resultText: string, expan
49
49
  const isSteered = details.status === "steered";
50
50
  const icon = isSteered ? theme.fg("warning", "\u2713") : theme.fg("success", "\u2713");
51
51
  const s = renderStats(details, theme);
52
- let line = icon + (s ? " " + s : "");
53
- line += " " + theme.fg("dim", "\u00B7") + " " + theme.fg("dim", duration);
52
+ let line = icon + (s ? ` ${s}` : "");
53
+ line += ` ${theme.fg("dim", "\u00B7")} ${theme.fg("dim", duration)}`;
54
54
 
55
55
  if (expanded) {
56
56
  if (resultText) {
57
57
  const lines = resultText.split("\n").slice(0, 50);
58
58
  for (const l of lines) {
59
- line += "\n" + theme.fg("dim", ` ${l}`);
59
+ line += `\n${theme.fg("dim", ` ${l}`)}`;
60
60
  }
61
61
  if (resultText.split("\n").length > 50) {
62
- line += "\n" + theme.fg("muted", " ... (use get_subagent_result with verbose for full output)");
62
+ line += `\n${theme.fg("muted", " ... (use get_subagent_result with verbose for full output)")}`;
63
63
  }
64
64
  }
65
65
  } else {
66
66
  const doneText = isSteered ? "Wrapped up (turn limit)" : "Done";
67
- line += "\n" + theme.fg("dim", ` \u23BF ${doneText}`);
67
+ line += `\n${theme.fg("dim", ` \u23BF ${doneText}`)}`;
68
68
  }
69
69
  return line;
70
70
  }
@@ -72,20 +72,20 @@ export function renderCompleted(details: AgentDetails, resultText: string, expan
72
72
  /** Render stopped status: dim stop icon + stats + "Stopped". */
73
73
  export function renderStopped(details: AgentDetails, theme: Theme): string {
74
74
  const s = renderStats(details, theme);
75
- let line = theme.fg("dim", "\u25A0") + (s ? " " + s : "");
76
- line += "\n" + theme.fg("dim", " \u23BF Stopped");
75
+ let line = theme.fg("dim", "\u25A0") + (s ? ` ${s}` : "");
76
+ line += `\n${theme.fg("dim", " \u23BF Stopped")}`;
77
77
  return line;
78
78
  }
79
79
 
80
80
  /** Render error or aborted status: error icon + stats + status message. */
81
81
  export function renderFailed(details: AgentDetails, theme: Theme): string {
82
82
  const s = renderStats(details, theme);
83
- let line = theme.fg("error", "\u2717") + (s ? " " + s : "");
83
+ let line = theme.fg("error", "\u2717") + (s ? ` ${s}` : "");
84
84
 
85
85
  if (details.status === "error") {
86
- line += "\n" + theme.fg("error", ` \u23BF Error: ${details.error ?? "unknown"}`);
86
+ line += `\n${theme.fg("error", ` \u23BF Error: ${details.error ?? "unknown"}`)}`;
87
87
  } else {
88
- line += "\n" + theme.fg("warning", " \u23BF Aborted (max turns exceeded)");
88
+ line += `\n${theme.fg("warning", " \u23BF Aborted (max turns exceeded)")}`;
89
89
  }
90
90
  return line;
91
91
  }
@@ -105,5 +105,5 @@ export function renderStats(details: AgentDetails, theme: Theme): string {
105
105
  }
106
106
  if (details.toolUses > 0) parts.push(`${details.toolUses} tool use${details.toolUses === 1 ? "" : "s"}`);
107
107
  if (details.tokens) parts.push(details.tokens);
108
- return parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "\u00B7") + " ");
108
+ return parts.map((p) => theme.fg("dim", p)).join(` ${theme.fg("dim", "\u00B7")} `);
109
109
  }
@@ -15,9 +15,20 @@ import { resolveInvocationModel } from "../session/model-resolver";
15
15
  import type { AgentInvocation, SubagentType, ThinkingLevel } from "../types";
16
16
  import { type AgentDetails, buildInvocationTags, getDisplayName, getPromptModeLabel } from "../ui/display";
17
17
 
18
+ /** Derive a short display model name from a model object (or undefined). */
19
+ export function resolveModelName(
20
+ model: { id?: string; name?: string; provider?: string } | undefined,
21
+ ): string | undefined {
22
+ if (!model) return undefined;
23
+ // Primary: provider/id complete format
24
+ if (model.provider && model.id) return `${model.provider}/${model.id}`;
25
+ // Fallback: raw name or id, no transformation
26
+ return model.name ?? model.id ?? undefined;
27
+ }
28
+
18
29
  /** Model info extracted from the parent session context. */
19
30
  export interface ModelInfo {
20
- parentModel: { id: string; name?: string } | undefined;
31
+ parentModel: { id: string; name?: string; provider?: string } | undefined;
21
32
  modelRegistry: unknown;
22
33
  }
23
34
 
@@ -103,13 +114,8 @@ export function resolveSpawnConfig(
103
114
  const inheritContext = resolvedConfig.inheritContext;
104
115
  const runInBackground = resolvedConfig.runInBackground;
105
116
 
106
- // Compute display model name (only shown when different from parent)
107
- const parentModelId = modelInfo.parentModel?.id;
108
- const effectiveModelId = model?.id;
109
- const modelName =
110
- effectiveModelId && effectiveModelId !== parentModelId
111
- ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
112
- : undefined;
117
+ // Compute display model name always shown, even when same as parent.
118
+ const modelName = resolveModelName(model ?? modelInfo.parentModel);
113
119
 
114
120
  const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? settings.defaultMaxTurns);
115
121
 
package/src/types.ts CHANGED
@@ -63,7 +63,7 @@ export interface AgentConfig extends AgentIdentity, AgentPromptConfig {
63
63
  }
64
64
 
65
65
  export interface AgentInvocation {
66
- /** Short display name, e.g. "haiku" only set when different from parent. */
66
+ /** Short display model name (always shown; falls back to parent model when unset). */
67
67
  modelName?: string;
68
68
  thinking?: ThinkingLevel;
69
69
  maxTurns?: number;
@@ -180,6 +180,7 @@ export class AgentWidget implements SubagentManagerObserver {
180
180
  activeTools: record.activeTools,
181
181
  responseText: record.responseText,
182
182
  contextPercent: record.getContextPercent(),
183
+ modelName: record.invocation?.modelName,
183
184
  };
184
185
  }
185
186
 
@@ -202,12 +203,12 @@ export class AgentWidget implements SubagentManagerObserver {
202
203
  */
203
204
  private clearWidget(backgroundAgents: readonly AgentSummary[]): void {
204
205
  if (this.widgetRegistered) {
205
- this.uiCtx!.setWidget("agents", undefined);
206
+ this.uiCtx?.setWidget("agents", undefined);
206
207
  this.widgetRegistered = false;
207
208
  this.tui = undefined;
208
209
  }
209
210
  if (this.lastStatusText !== undefined) {
210
- this.uiCtx!.setStatus("subagents", undefined);
211
+ this.uiCtx?.setStatus("subagents", undefined);
211
212
  this.lastStatusText = undefined;
212
213
  }
213
214
  if (this.widgetInterval) {
@@ -233,7 +234,7 @@ export class AgentWidget implements SubagentManagerObserver {
233
234
  newStatusText = `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`;
234
235
  }
235
236
  if (newStatusText !== this.lastStatusText) {
236
- this.uiCtx!.setStatus("subagents", newStatusText);
237
+ this.uiCtx?.setStatus("subagents", newStatusText);
237
238
  this.lastStatusText = newStatusText;
238
239
  }
239
240
  }
package/src/ui/display.ts CHANGED
@@ -28,7 +28,7 @@ export interface AgentDetails {
28
28
  activity?: string;
29
29
  /** Current spinner frame index (for animated running indicator). */
30
30
  spinnerFrame?: number;
31
- /** Short model name if different from parent (e.g. "haiku", "sonnet"). */
31
+ /** Short display model name (always shown; falls back to parent model when unset). */
32
32
  modelName?: string;
33
33
  /** Notable config tags (e.g. ["thinking: high", "inherit context"]). */
34
34
  tags?: string[];
@@ -142,7 +142,7 @@ function truncateLine(text: string, len = 60): string {
142
142
  .find((l) => l.trim())
143
143
  ?.trim() ?? "";
144
144
  if (line.length <= len) return line;
145
- return line.slice(0, len) + "…";
145
+ return `${line.slice(0, len)}…`;
146
146
  }
147
147
 
148
148
  /** Build a human-readable activity string from currently-running tools or response text. */
@@ -162,7 +162,7 @@ export function describeActivity(activeTools: ReadonlyMap<string, string>, respo
162
162
  parts.push(action);
163
163
  }
164
164
  }
165
- return parts.join(", ") + "…";
165
+ return `${parts.join(", ")}…`;
166
166
  }
167
167
 
168
168
  // No tools active — show truncated response text if available
@@ -38,6 +38,8 @@ export interface NavigableSubagent {
38
38
  readonly activeTools: ReadonlyMap<string, string>;
39
39
  readonly responseText: string;
40
40
  readonly agentMessages: readonly SessionMessage[];
41
+ /** Short display model name (always shown; falls back to parent model when unset). */
42
+ readonly modelName?: string;
41
43
  isSessionReady(): boolean;
42
44
  subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined;
43
45
  getToolDefinition(name: string): ToolDefinition | undefined;
@@ -51,7 +53,7 @@ export interface NavigableSubagent {
51
53
  */
52
54
  export type NavigationEntry =
53
55
  | { readonly kind: "live"; readonly label: string; readonly record: NavigableSubagent }
54
- | { readonly kind: "evicted"; readonly label: string; readonly outputFile: string };
56
+ | { readonly kind: "evicted"; readonly label: string; readonly outputFile: string; readonly modelName?: string };
55
57
 
56
58
  /** The fields `buildLabel` reads — shared by a live record and an evicted descriptor. */
57
59
  interface LabelFields {
@@ -61,6 +63,7 @@ interface LabelFields {
61
63
  readonly startedAt: number;
62
64
  readonly completedAt: number | undefined;
63
65
  readonly toolUses: number;
66
+ readonly modelName?: string;
64
67
  }
65
68
 
66
69
  /** Running-agent streaming state, surfaced by a live source. */
@@ -100,6 +103,7 @@ export function listNavigableAgents(
100
103
  (descriptor): NavigationEntry => ({
101
104
  kind: "evicted",
102
105
  outputFile: descriptor.outputFile,
106
+ modelName: descriptor.modelName,
103
107
  label: buildLabel(descriptor, registry, true),
104
108
  }),
105
109
  );
@@ -142,6 +146,7 @@ export function liveSource(record: NavigableSubagent): TranscriptSource {
142
146
  function buildLabel(fields: LabelFields, registry: AgentConfigLookup, evicted = false): string {
143
147
  const name = getDisplayName(fields.type, registry);
144
148
  const duration = formatDuration(fields.startedAt, fields.completedAt);
149
+ const modelTag = fields.modelName ? ` · model:${fields.modelName}` : "";
145
150
  const marker = evicted ? " · evicted (snapshot)" : "";
146
- return `${name} (${fields.description}) · ${fields.toolUses} tools · ${fields.status} · ${duration}${marker}`;
151
+ return `${name} (${fields.description}) · ${fields.toolUses} tools · ${fields.status} · ${duration}${modelTag}${marker}`;
147
152
  }
@@ -92,6 +92,8 @@ export interface TranscriptOverlayOptions {
92
92
  done: (result: undefined) => void;
93
93
  cwd: string;
94
94
  markdownTheme: MarkdownTheme;
95
+ /** Short model name to show in the header, or undefined to omit. */
96
+ modelName?: string;
95
97
  }
96
98
 
97
99
  /**
@@ -117,15 +119,23 @@ export class SessionNavigatorHandler {
117
119
  if (!entry) return;
118
120
 
119
121
  let source: TranscriptSource;
122
+ let modelName: string | undefined;
120
123
  try {
121
- source = entry.kind === "live" ? liveSource(entry.record) : fileSnapshotSource(entry.outputFile, readFile);
124
+ if (entry.kind === "live") {
125
+ source = liveSource(entry.record);
126
+ modelName = entry.record.modelName;
127
+ } else {
128
+ source = fileSnapshotSource(entry.outputFile, readFile);
129
+ modelName = entry.modelName;
130
+ }
122
131
  } catch {
123
132
  ui.notify("Could not read the session transcript file.", "error");
124
133
  return;
125
134
  }
126
135
  const markdownTheme = getMarkdownTheme();
127
136
  await ui.custom<undefined>(
128
- (tui, theme, _keybindings, done) => new TranscriptOverlay({ tui, theme, source, done, cwd, markdownTheme }),
137
+ (tui, theme, _keybindings, done) =>
138
+ new TranscriptOverlay({ tui, theme, source, done, cwd, markdownTheme, modelName }),
129
139
  {
130
140
  overlay: true,
131
141
  overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
@@ -134,14 +144,34 @@ export class SessionNavigatorHandler {
134
144
  }
135
145
  }
136
146
 
147
+ /**
148
+ * Minimum interval between two component rebuilds for a live (streaming) source.
149
+ *
150
+ * A running agent emits many events per second (text deltas, tool calls). Without
151
+ * throttling, each event rebuilt the whole component tree (markdown parsing
152
+ * included) and re-rendered it — starving the event loop so keystrokes (arrows,
153
+ * `q`) stopped responding. Coalescing bursts into at most one rebuild per tick
154
+ * keeps the overlay responsive while still streaming.
155
+ */
156
+ const REBUILD_THROTTLE_MS = 120;
157
+
137
158
  /**
138
159
  * Read-only scrollable transcript overlay.
139
160
  *
140
- * Caches a `Container` of Pi's per-entry components and rebuilds it only when the
141
- * source changes (live agents) — each paint reuses the cached tree, so markdown
142
- * highlighting does not re-run per frame. This class owns scroll state, chrome,
143
- * and the running-agent streaming indicator; the component mapping lives in
144
- * `buildTranscriptComponents`.
161
+ * Two caches keep it responsive even for long, live-streaming transcripts:
162
+ *
163
+ * - `content` (a `Container` of Pi's per-entry components) is rebuilt only when
164
+ * the source changes, and rebuilds are *throttled* a burst of streaming
165
+ * events coalesces into at most one rebuild per `REBUILD_THROTTLE_MS`, so
166
+ * markdown highlighting never re-runs on every token.
167
+ * - `renderedLines` caches the laid-out, width-wrapped lines. `render()` and
168
+ * `handleInput()` read the cache (O(1) slice / length) instead of
169
+ * re-rendering the whole container on every frame and every keystroke.
170
+ *
171
+ * The cache is invalidated (`linesDirty`) whenever the component tree changes,
172
+ * and recomputed lazily at the current width. This class owns scroll state,
173
+ * chrome, and the running-agent streaming indicator; the component mapping lives
174
+ * in `buildTranscriptComponents`.
145
175
  */
146
176
  export class TranscriptOverlay implements Component {
147
177
  private scrollOffset = 0;
@@ -155,21 +185,36 @@ export class TranscriptOverlay implements Component {
155
185
  private readonly done: (result: undefined) => void;
156
186
  private readonly cwd: string;
157
187
  private readonly markdownTheme: MarkdownTheme;
188
+ private readonly modelName?: string;
158
189
  private content: Container;
159
190
 
160
- constructor({ tui, theme, source, done, cwd, markdownTheme }: TranscriptOverlayOptions) {
191
+ /** Throttle bookkeeping for coalescing live-source rebuilds. */
192
+ private rebuildTimer: ReturnType<typeof setTimeout> | undefined;
193
+ private lastRebuildAt = 0;
194
+
195
+ /** Cached laid-out lines + the width they were computed at; recomputed lazily when `linesDirty`. */
196
+ private renderedLines: string[] = [];
197
+ private renderedWidth = -1;
198
+ private linesDirty = true;
199
+
200
+ constructor({ tui, theme, source, done, cwd, markdownTheme, modelName }: TranscriptOverlayOptions) {
161
201
  this.tui = tui;
162
202
  this.theme = theme;
163
203
  this.source = source;
164
204
  this.done = done;
165
205
  this.cwd = cwd;
166
206
  this.markdownTheme = markdownTheme;
207
+ this.modelName = modelName;
167
208
  this.content = this.rebuild();
168
- this.unsubscribe = source.subscribe(() => {
169
- if (this.closed) return;
170
- this.content = this.rebuild();
171
- this.tui.requestRender();
172
- });
209
+ // Seed `lastRebuildAt` far in the past so the first source-change event
210
+ // always rebuilds immediately (leading-edge throttle). The constructor
211
+ // already built `content` from the snapshot at construction time, but
212
+ // the source may have accumulated events between construction and
213
+ // subscription — that first rebuild surfaces them without delay.
214
+ // Subsequent events inside the throttle window are coalesced into a
215
+ // single trailing rebuild.
216
+ this.lastRebuildAt = 0;
217
+ this.unsubscribe = source.subscribe(() => this.scheduleRebuild());
173
218
  }
174
219
 
175
220
  // fallow-ignore-next-line unused-class-member
@@ -180,29 +225,41 @@ export class TranscriptOverlay implements Component {
180
225
  return;
181
226
  }
182
227
 
183
- const totalLines = this.buildContentLines(this.innerWidth()).length;
228
+ const totalLines = this.getRenderedLines(this.innerWidth()).length;
184
229
  const viewportHeight = this.viewportHeight();
185
230
  const maxScroll = Math.max(0, totalLines - viewportHeight);
231
+ let scrolled = false;
186
232
 
187
233
  if (matchesKey(data, "up") || matchesKey(data, "k")) {
188
234
  this.scrollOffset = Math.max(0, this.scrollOffset - 1);
189
- this.autoScroll = this.scrollOffset >= maxScroll;
235
+ // Streaming may add lines between handleInput and render,
236
+ // making maxScroll larger; unconditionally disable autoScroll
237
+ // so render() does not reset scrollOffset back to the bottom.
238
+ this.autoScroll = false;
239
+ scrolled = true;
190
240
  } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
191
241
  this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
192
- this.autoScroll = this.scrollOffset >= maxScroll;
242
+ this.autoScroll = false;
243
+ scrolled = true;
193
244
  } else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
194
245
  this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
195
246
  this.autoScroll = false;
247
+ scrolled = true;
196
248
  } else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
197
249
  this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
198
- this.autoScroll = this.scrollOffset >= maxScroll;
250
+ this.autoScroll = false;
251
+ scrolled = true;
199
252
  } else if (matchesKey(data, "home")) {
200
253
  this.scrollOffset = 0;
201
254
  this.autoScroll = false;
255
+ scrolled = true;
202
256
  } else if (matchesKey(data, "end")) {
203
257
  this.scrollOffset = maxScroll;
204
258
  this.autoScroll = true;
259
+ scrolled = true;
205
260
  }
261
+
262
+ if (scrolled) this.tui.requestRender();
206
263
  }
207
264
 
208
265
  render(width: number): string[] {
@@ -213,16 +270,17 @@ export class TranscriptOverlay implements Component {
213
270
 
214
271
  const pad = (s: string, len: number): string => s + " ".repeat(Math.max(0, len - visibleWidth(s)));
215
272
  const row = (content: string): string =>
216
- th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW) + " " + th.fg("border", "│");
273
+ `${th.fg("border", "│")} ${truncateToWidth(pad(content, innerW), innerW)} ${th.fg("border", "│")}`;
217
274
  const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
218
275
  const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
219
276
  const hrMid = row(th.fg("dim", "─".repeat(innerW)));
220
277
 
221
278
  lines.push(hrTop);
222
- lines.push(row(th.bold("Subagent session")));
279
+ const title = this.modelName ? `Subagent session · ${this.modelName}` : "Subagent session";
280
+ lines.push(row(th.bold(title)));
223
281
  lines.push(hrMid);
224
282
 
225
- const contentLines = this.buildContentLines(innerW);
283
+ const contentLines = this.getRenderedLines(innerW);
226
284
  const viewportHeight = this.viewportHeight();
227
285
  const maxScroll = Math.max(0, contentLines.length - viewportHeight);
228
286
  if (this.autoScroll) this.scrollOffset = maxScroll;
@@ -236,7 +294,7 @@ export class TranscriptOverlay implements Component {
236
294
  ? "100%"
237
295
  : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
238
296
  const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
239
- const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
297
+ const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · end follow · q close");
240
298
  const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
241
299
  lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
242
300
  lines.push(hrBot);
@@ -247,11 +305,17 @@ export class TranscriptOverlay implements Component {
247
305
  // fallow-ignore-next-line unused-class-member
248
306
  invalidate(): void {
249
307
  this.content.invalidate();
308
+ this.linesDirty = true;
309
+ this.renderedWidth = -1;
250
310
  }
251
311
 
252
312
  // fallow-ignore-next-line unused-class-member
253
313
  dispose(): void {
254
314
  this.closed = true;
315
+ if (this.rebuildTimer) {
316
+ clearTimeout(this.rebuildTimer);
317
+ this.rebuildTimer = undefined;
318
+ }
255
319
  if (this.unsubscribe) {
256
320
  this.unsubscribe();
257
321
  this.unsubscribe = undefined;
@@ -269,6 +333,50 @@ export class TranscriptOverlay implements Component {
269
333
  return Math.max(MIN_VIEWPORT, maxRows - CHROME_LINES);
270
334
  }
271
335
 
336
+ /**
337
+ * Coalesce a burst of source-change events into at most one component rebuild
338
+ * per `REBUILD_THROTTLE_MS`. The first event after an idle gap rebuilds
339
+ * immediately (so a freshly-picked agent paints without delay); subsequent
340
+ * events inside the gap are merged into a single trailing rebuild.
341
+ */
342
+ private scheduleRebuild(): void {
343
+ if (this.closed) return;
344
+ const now = Date.now();
345
+ const elapsed = now - this.lastRebuildAt;
346
+ if (elapsed >= REBUILD_THROTTLE_MS) {
347
+ this.doRebuild();
348
+ return;
349
+ }
350
+ if (this.rebuildTimer) return; // a trailing rebuild is already pending
351
+ this.rebuildTimer = setTimeout(() => {
352
+ this.rebuildTimer = undefined;
353
+ this.doRebuild();
354
+ }, REBUILD_THROTTLE_MS - elapsed);
355
+ }
356
+
357
+ /** Rebuild the component tree, invalidate the line cache, and request a paint. */
358
+ private doRebuild(): void {
359
+ if (this.closed) return;
360
+ this.lastRebuildAt = Date.now();
361
+ this.content = this.rebuild();
362
+ this.linesDirty = true;
363
+ this.tui.requestRender();
364
+ }
365
+
366
+ /**
367
+ * Return the laid-out content lines at `innerW`, recomputing the cache only
368
+ * when the component tree changed (`linesDirty`) or the width changed.
369
+ * Cheap O(1) on the hot path (every render frame and every keystroke).
370
+ */
371
+ private getRenderedLines(innerW: number): string[] {
372
+ if (innerW <= 0) return [];
373
+ if (!this.linesDirty && this.renderedWidth === innerW) return this.renderedLines;
374
+ this.renderedLines = this.buildContentLines(innerW);
375
+ this.renderedWidth = innerW;
376
+ this.linesDirty = false;
377
+ return this.renderedLines;
378
+ }
379
+
272
380
  private buildContentLines(innerW: number): string[] {
273
381
  if (innerW <= 0) return [];
274
382
  const lines = this.content.render(innerW);
@@ -42,6 +42,8 @@ export interface WidgetAgent {
42
42
  readonly responseText: string;
43
43
  /** Context-window utilisation (0–100), or null when unavailable. */
44
44
  readonly contextPercent: number | null;
45
+ /** Short display model name (always shown; falls back to parent model when unset). */
46
+ readonly modelName?: string;
45
47
  }
46
48
 
47
49
  // ── Per-agent rendering ──────────────────────────────────────────────────────
@@ -74,6 +76,7 @@ export function renderFinishedLine(agent: WidgetAgent, registry: AgentConfigLook
74
76
  }
75
77
 
76
78
  const parts: string[] = [];
79
+ if (agent.modelName) parts.push(agent.modelName);
77
80
  parts.push(formatTurns(agent.turnCount, agent.maxTurns));
78
81
  if (agent.toolUses > 0) parts.push(`${agent.toolUses} tool use${agent.toolUses === 1 ? "" : "s"}`);
79
82
  parts.push(duration);
@@ -98,6 +101,7 @@ export function renderRunningLines(
98
101
  const tokenText = tokens > 0 ? formatSessionTokens(tokens, agent.contextPercent, theme, agent.compactionCount) : "";
99
102
 
100
103
  const parts: string[] = [];
104
+ if (agent.modelName) parts.push(agent.modelName);
101
105
  parts.push(formatTurns(agent.turnCount, agent.maxTurns));
102
106
  if (agent.toolUses > 0) parts.push(`${agent.toolUses} tool use${agent.toolUses === 1 ? "" : "s"}`);
103
107
  if (tokenText) parts.push(tokenText);
@@ -155,14 +159,14 @@ function buildSections(
155
159
  ): WidgetSections {
156
160
  const finishedLines: string[] = [];
157
161
  for (const a of categories.finished) {
158
- finishedLines.push(truncate(theme.fg("dim", "\u251C\u2500") + " " + renderFinishedLine(a, registry, theme)));
162
+ finishedLines.push(truncate(`${theme.fg("dim", "\u251C\u2500")} ${renderFinishedLine(a, registry, theme)}`));
159
163
  }
160
164
 
161
165
  const runningLines: [string, string][] = [];
162
166
  for (const a of categories.running) {
163
167
  const [header, act] = renderRunningLines(a, registry, spinnerFrame, theme);
164
168
  runningLines.push([
165
- truncate(theme.fg("dim", "\u251C\u2500") + ` ${header}`),
169
+ truncate(`${theme.fg("dim", "\u251C\u2500")} ${header}`),
166
170
  truncate(theme.fg("dim", "\u2502 ") + act),
167
171
  ]);
168
172
  }
@@ -248,7 +252,7 @@ function assembleOverflow(
248
252
  const overflowText = overflowParts.join(", ");
249
253
  lines.push(
250
254
  truncate(
251
- theme.fg("dim", "\u2514\u2500") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`,
255
+ `${theme.fg("dim", "\u2514\u2500")} ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`,
252
256
  ),
253
257
  );
254
258
  return lines;
@@ -287,7 +291,7 @@ export function renderWidgetLines(params: {
287
291
  // Assemble with overflow cap (heading takes 1 line).
288
292
  const maxBody = MAX_WIDGET_LINES - 1;
289
293
  const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
290
- const heading = truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"));
294
+ const heading = truncate(`${theme.fg(headingColor, headingIcon)} ${theme.fg(headingColor, "Agents")}`);
291
295
 
292
296
  if (totalBody <= maxBody) {
293
297
  return assembleWithinBudget(heading, { finishedLines, runningLines, queuedLine });