@yandy0725/pi-subagents 0.1.0 → 0.2.1

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.1",
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,9 +33,9 @@
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"
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
39
  },
40
40
  "dependencies": {
41
41
  "@sinclair/typebox": "^0.34.49"
@@ -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
 
@@ -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
  }
@@ -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) ----
@@ -87,6 +89,7 @@ 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
95
  ? record.result.slice(0, resultMaxLen) + "…"
@@ -36,6 +36,7 @@ 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));
@@ -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. */
@@ -45,6 +46,7 @@ export class SubagentsServiceAdapter implements SubagentsService {
45
46
  }
46
47
 
47
48
  let model: unknown;
49
+ let modelName: string | undefined;
48
50
  if (options?.model) {
49
51
  const registry = this.runtime.currentCtx.modelRegistry;
50
52
  if (!registry) {
@@ -56,6 +58,11 @@ export class SubagentsServiceAdapter implements SubagentsService {
56
58
  }
57
59
  model = resolved;
58
60
  }
61
+ // Always compute display model name, even when same as parent
62
+ modelName = resolveModelName(
63
+ (model as { id?: string; name?: string } | undefined) ??
64
+ (this.runtime.currentCtx.model as { id?: string; name?: string } | undefined),
65
+ );
59
66
 
60
67
  const description = options?.description ?? prompt.slice(0, 80);
61
68
  const isBackground = !(options?.foreground ?? false);
@@ -69,6 +76,9 @@ export class SubagentsServiceAdapter implements SubagentsService {
69
76
  inheritContext: options?.inheritContext,
70
77
  bypassQueue: options?.bypassQueue,
71
78
  isBackground,
79
+ // Service API builds a display-minimal invocation (modelName only);
80
+ // the tool path additionally carries thinking/maxTurns/inherit tags.
81
+ invocation: modelName != null ? { modelName } : undefined,
72
82
  });
73
83
  }
74
84
 
@@ -126,6 +136,7 @@ export function toSubagentRecord(record: Subagent): SubagentRecord {
126
136
  if (record.result !== undefined) out.result = record.result;
127
137
  if (record.error !== undefined) out.error = record.error;
128
138
  if (record.completedAt !== undefined) out.completedAt = record.completedAt;
139
+ if (record.modelName !== undefined) out.modelName = record.modelName;
129
140
 
130
141
  return out;
131
142
  }
@@ -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. */
@@ -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
+ }
@@ -15,6 +15,14 @@ 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(model: { id?: string; name?: string } | undefined): string | undefined {
20
+ if (!model) return undefined;
21
+ const raw = model.name ?? model.id;
22
+ if (!raw) return undefined;
23
+ return raw.replace(/^Claude\s+/i, "").toLowerCase();
24
+ }
25
+
18
26
  /** Model info extracted from the parent session context. */
19
27
  export interface ModelInfo {
20
28
  parentModel: { id: string; name?: string } | undefined;
@@ -103,13 +111,8 @@ export function resolveSpawnConfig(
103
111
  const inheritContext = resolvedConfig.inheritContext;
104
112
  const runInBackground = resolvedConfig.runInBackground;
105
113
 
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;
114
+ // Compute display model name always shown, even when same as parent.
115
+ const modelName = resolveModelName(model ?? modelInfo.parentModel);
113
116
 
114
117
  const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? settings.defaultMaxTurns);
115
118
 
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
 
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[];
@@ -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[] {
@@ -219,10 +276,11 @@ export class TranscriptOverlay implements Component {
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);