pi-subagents 0.41.0 → 0.42.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.
@@ -5,6 +5,131 @@ import { getAgentDir } from "./utils.ts";
5
5
  const CLEANUP_MARKER_FILE = ".last-cleanup";
6
6
  const PROJECT_ARTIFACT_ROOT = ".pi-subagents";
7
7
 
8
+ const PROJECT_ARTIFACT_PATHS = [
9
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/output.md`,
10
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_input.md`,
11
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_output.md`,
12
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker.jsonl`,
13
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_transcript.jsonl`,
14
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/run_worker_meta.json`,
15
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/progress/run/progress.md`,
16
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/outputs/output.md`,
17
+ `${PROJECT_ARTIFACT_ROOT}/artifacts/outputs/run/output.md`,
18
+ `${PROJECT_ARTIFACT_ROOT}/chain-runs/run.json`,
19
+ ];
20
+
21
+ function globMatchesPath(pattern: string, filePath: string): boolean {
22
+ let expression = "^";
23
+ for (let index = 0; index < pattern.length; index += 1) {
24
+ const character = pattern[index];
25
+ if (character === undefined) break;
26
+ if (character === "*" && pattern[index + 1] === "*") {
27
+ index += 1;
28
+ if (pattern[index + 1] === "/") {
29
+ index += 1;
30
+ expression += "(?:.*/)?";
31
+ } else {
32
+ expression += ".*";
33
+ }
34
+ } else if (character === "*") {
35
+ expression += "[^/]*";
36
+ } else if (character === "?") {
37
+ expression += "[^/]";
38
+ } else if (character === "[") {
39
+ const end = pattern.indexOf("]", index + 1);
40
+ if (end === -1) expression += "\\[";
41
+ else {
42
+ expression += pattern.slice(index, end + 1);
43
+ index = end;
44
+ }
45
+ } else {
46
+ expression += character.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
47
+ }
48
+ }
49
+ try {
50
+ return new RegExp(`${expression}$`).test(filePath);
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ function normalizePattern(pattern: string): string {
57
+ return pattern.replace(/^\.\//, "").replace(/^\//, "").replace(/\/+$/, "");
58
+ }
59
+
60
+ function patternMatchesArtifactPath(pattern: string, artifactPath: string): boolean {
61
+ const normalized = normalizePattern(pattern);
62
+ return normalized === PROJECT_ARTIFACT_ROOT
63
+ || normalized === "*"
64
+ || artifactPath.startsWith(`${normalized}/`)
65
+ || globMatchesPath(normalized, artifactPath);
66
+ }
67
+
68
+ function patternMatchesProjectArtifacts(pattern: string): boolean {
69
+ return PROJECT_ARTIFACT_PATHS.some((artifactPath) => patternMatchesArtifactPath(pattern, artifactPath));
70
+ }
71
+
72
+ function ignoreFileExcludesProjectArtifacts(filePath: string): boolean {
73
+ if (!fs.existsSync(filePath)) return false;
74
+ try {
75
+ const excluded = new Set<string>();
76
+ for (const rawLine of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) {
77
+ const line = rawLine.trim();
78
+ if (!line || line.startsWith("#")) continue;
79
+ const negated = line.startsWith("!");
80
+ const pattern = negated ? line.slice(1) : line;
81
+ for (const artifactPath of PROJECT_ARTIFACT_PATHS) {
82
+ if (!patternMatchesArtifactPath(pattern, artifactPath)) continue;
83
+ if (negated) excluded.delete(artifactPath);
84
+ else excluded.add(artifactPath);
85
+ }
86
+ }
87
+ return excluded.size === PROJECT_ARTIFACT_PATHS.length;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ function filesIncludeProjectArtifacts(files: unknown): boolean | undefined {
94
+ if (!Array.isArray(files)) return undefined;
95
+ const included = new Set<string>();
96
+ for (const entry of files) {
97
+ if (typeof entry !== "string") continue;
98
+ const negated = entry.startsWith("!");
99
+ const pattern = negated ? entry.slice(1) : entry;
100
+ for (const artifactPath of PROJECT_ARTIFACT_PATHS) {
101
+ if (!patternMatchesArtifactPath(pattern, artifactPath)) continue;
102
+ if (negated) included.delete(artifactPath);
103
+ else included.add(artifactPath);
104
+ }
105
+ }
106
+ return included.size > 0;
107
+ }
108
+
109
+ /** Returns a package-publishing warning when project artifacts can enter npm packages. */
110
+ export function getProjectArtifactPackagingWarning(cwd: string): string | undefined {
111
+ const packagePath = path.join(cwd, "package.json");
112
+ if (!fs.existsSync(packagePath)) return undefined;
113
+
114
+ let packageJson: Record<string, unknown>;
115
+ try {
116
+ const parsed: unknown = JSON.parse(fs.readFileSync(packagePath, "utf-8"));
117
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
118
+ packageJson = parsed as Record<string, unknown>;
119
+ } catch {
120
+ return undefined;
121
+ }
122
+
123
+ const filesIncludeArtifacts = filesIncludeProjectArtifacts(packageJson.files);
124
+ if (filesIncludeArtifacts === false) return undefined;
125
+
126
+ const npmIgnorePath = path.join(cwd, ".npmignore");
127
+ const ignorePath = fs.existsSync(npmIgnorePath) ? npmIgnorePath : path.join(cwd, ".gitignore");
128
+ if (filesIncludeArtifacts === undefined && ignoreFileExcludesProjectArtifacts(ignorePath)) return undefined;
129
+
130
+ return "Project-scoped subagent artifacts can be included when this package is published. Add '.pi-subagents/' to .npmignore, restrict package.json files, or set artifactDir to 'session' or 'temp'.";
131
+ }
132
+
8
133
  export function getProjectSubagentsDir(cwd: string): string {
9
134
  return path.join(cwd, PROJECT_ARTIFACT_ROOT);
10
135
  }
package/src/tui/fleet.ts CHANGED
@@ -780,7 +780,7 @@ export class SubagentFleetComponent implements Component {
780
780
  if (width < 36) return [truncateToWidth("Subagent fleet needs at least 36 columns. Esc closes.", width)];
781
781
  const innerWidth = width - 2;
782
782
  const rows = this.tui.terminal?.rows ?? 32;
783
- this.bodyHeight = Math.max(2, Math.min(30, Math.floor(rows * 0.85) - 6));
783
+ this.bodyHeight = Math.max(2, Math.floor(rows * 0.85) - 6);
784
784
  const rosterWidth = Math.max(22, Math.min(46, Math.floor((innerWidth - 1) * 0.38)));
785
785
  const detailWidth = Math.max(1, innerWidth - rosterWidth - 1);
786
786
  const roster = this.rosterLines(rosterWidth);
package/src/tui/render.ts CHANGED
@@ -144,6 +144,24 @@ function runningGlyph(seed?: number): string {
144
144
  return RUNNING_FRAMES[Math.abs(seed) % RUNNING_FRAMES.length]!;
145
145
  }
146
146
 
147
+ const WIDGET_ANIMATION_FRAME_MS = 500;
148
+ let widgetRequestRender: (() => void) | undefined;
149
+
150
+ function animatedSeed(seed: number | undefined, frame: number | undefined): number | undefined {
151
+ if (frame === undefined) return seed;
152
+ return (seed ?? 0) + frame;
153
+ }
154
+
155
+ function widgetAnimationFrame(): number {
156
+ return Math.floor(Date.now() / WIDGET_ANIMATION_FRAME_MS);
157
+ }
158
+
159
+ export function requestWidgetRender(): boolean {
160
+ if (!widgetRequestRender) return false;
161
+ widgetRequestRender();
162
+ return true;
163
+ }
164
+
147
165
  function progressRunningSeed(progress: ProgressSeedSource | undefined): number | undefined {
148
166
  if (!progress) return undefined;
149
167
  return runningSeed(
@@ -461,8 +479,8 @@ function widgetJobsRunningSeed(jobs: AsyncJobState[]): number | undefined {
461
479
  return seed;
462
480
  }
463
481
 
464
- function widgetStatusGlyph(job: AsyncJobState, theme: Theme): string {
465
- if (job.status === "running") return theme.fg("accent", runningGlyph(widgetJobRunningSeed(job)));
482
+ function widgetStatusGlyph(job: AsyncJobState, theme: Theme, frame?: number): string {
483
+ if (job.status === "running") return theme.fg("accent", runningGlyph(animatedSeed(widgetJobRunningSeed(job), frame)));
466
484
  if (job.status === "queued") return theme.fg("muted", "◦");
467
485
  if (job.status === "complete") return theme.fg("success", "✓");
468
486
  if (job.status === "paused") return theme.fg("warning", "■");
@@ -470,8 +488,8 @@ function widgetStatusGlyph(job: AsyncJobState, theme: Theme): string {
470
488
  return theme.fg("error", "✗");
471
489
  }
472
490
 
473
- function widgetStepGlyph(status: AsyncJobStep["status"], theme: Theme, seed?: number): string {
474
- if (status === "running") return theme.fg("accent", runningGlyph(seed));
491
+ function widgetStepGlyph(status: AsyncJobStep["status"], theme: Theme, seed?: number, frame?: number): string {
492
+ if (status === "running") return theme.fg("accent", runningGlyph(animatedSeed(seed, frame)));
475
493
  if (status === "complete" || status === "completed") return theme.fg("success", "✓");
476
494
  if (status === "failed") return theme.fg("error", "✗");
477
495
  if (status === "paused") return theme.fg("warning", "■");
@@ -1008,11 +1026,12 @@ function foregroundStyleWidgetStepLines(
1008
1026
  total: number,
1009
1027
  expanded: boolean,
1010
1028
  width: number,
1029
+ frame?: number,
1011
1030
  ): string[] {
1012
1031
  const status = widgetStepStatus(step.status, theme);
1013
1032
  const stats = widgetStepStats(theme, step);
1014
1033
  const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking);
1015
- const lines = [` ${widgetStepGlyph(step.status, theme, widgetStepRunningSeed(step, index - 1))} ${itemTitle} ${index}/${total}: ${themeBold(theme, step.agent)}${contextModeBadge(theme, step.context)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`];
1034
+ const lines = [` ${widgetStepGlyph(step.status, theme, widgetStepRunningSeed(step, index - 1), frame)} ${itemTitle} ${index}/${total}: ${themeBold(theme, step.agent)}${contextModeBadge(theme, step.context)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`];
1016
1035
  const activity = widgetStepActivityLine(step, width, expanded, job.updatedAt);
1017
1036
  if (activity) lines.push(` ${theme.fg("dim", `⎿ ${activity}`)}`);
1018
1037
  for (const nestedLine of formatNestedWidgetLines(step.children, theme, width, expanded, job.updatedAt, expanded ? 12 : 6)) {
@@ -1038,7 +1057,7 @@ function foregroundStyleWidgetStepLines(
1038
1057
  return lines;
1039
1058
  }
1040
1059
 
1041
- function foregroundStyleWidgetDetails(job: AsyncJobState, theme: Theme, expanded: boolean, width: number): string[] {
1060
+ function foregroundStyleWidgetDetails(job: AsyncJobState, theme: Theme, expanded: boolean, width: number, frame?: number): string[] {
1042
1061
  if (!job.steps?.length) return [
1043
1062
  ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
1044
1063
  ...formatNestedWidgetLines(job.nestedChildren, theme, width, expanded, job.updatedAt, expanded ? 12 : 6).map((line) => ` ${line}`),
@@ -1048,7 +1067,7 @@ function foregroundStyleWidgetDetails(job: AsyncJobState, theme: Theme, expanded
1048
1067
  const itemTitle = job.mode === "parallel" || job.activeParallelGroup ? "Agent" : "Step";
1049
1068
  const lines: string[] = [];
1050
1069
  for (const [index, step] of job.steps.entries()) {
1051
- lines.push(...foregroundStyleWidgetStepLines(job, theme, step, itemTitle, index + 1, total, expanded, width));
1070
+ lines.push(...foregroundStyleWidgetStepLines(job, theme, step, itemTitle, index + 1, total, expanded, width, frame));
1052
1071
  }
1053
1072
  const attached = new Set(job.steps.flatMap((step) => step.children?.map((child) => child.id) ?? []));
1054
1073
  const unattached = job.nestedChildren?.filter((child) => !attached.has(child.id)) ?? [];
@@ -1058,20 +1077,20 @@ function foregroundStyleWidgetDetails(job: AsyncJobState, theme: Theme, expanded
1058
1077
  return lines;
1059
1078
  }
1060
1079
 
1061
- function buildSingleWidgetLines(job: AsyncJobState, theme: Theme, width: number, expanded: boolean): string[] {
1080
+ function buildSingleWidgetLines(job: AsyncJobState, theme: Theme, width: number, expanded: boolean, frame?: number): string[] {
1062
1081
  const stats = widgetStats(job, theme);
1063
1082
  const count = job.mode === "chain" ? job.chainStepCount : job.stepsTotal ?? job.agents?.length ?? job.steps?.length;
1064
1083
  const mode = widgetJobName(job);
1065
1084
  const title = `async subagent ${mode}${count && count > 1 ? ` (${count})` : ""}`;
1066
1085
  return [
1067
1086
  `${theme.fg("toolTitle", themeBold(theme, title))} ${theme.fg("dim", "· background")}`,
1068
- `${widgetStatusGlyph(job, theme)} ${themeBold(theme, mode)}${contextModeBadge(theme, job.context)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
1069
- ...foregroundStyleWidgetDetails(job, theme, expanded, width),
1087
+ `${widgetStatusGlyph(job, theme, frame)} ${themeBold(theme, mode)}${contextModeBadge(theme, job.context)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
1088
+ ...foregroundStyleWidgetDetails(job, theme, expanded, width, frame),
1070
1089
  ].map((line) => truncLine(line, width));
1071
1090
  }
1072
1091
 
1073
- function compactSingleWidgetLines(job: AsyncJobState, theme: Theme, width: number): string[] {
1074
- const fullLines = buildSingleWidgetLines(job, theme, width, false);
1092
+ function compactSingleWidgetLines(job: AsyncJobState, theme: Theme, width: number, frame?: number): string[] {
1093
+ const fullLines = buildSingleWidgetLines(job, theme, width, false, frame);
1075
1094
  if (fullLines.length <= 10 || !job.steps?.length || (job.mode !== "parallel" && !job.activeParallelGroup)) return fullLines;
1076
1095
 
1077
1096
  const total = job.stepsTotal ?? job.steps.length;
@@ -1083,7 +1102,7 @@ function compactSingleWidgetLines(job: AsyncJobState, theme: Theme, width: numbe
1083
1102
  const stepStats = widgetStepStats(theme, step);
1084
1103
  const activitySuffix = activity ? ` ${theme.fg("dim", "·")} ${theme.fg("dim", activity)}` : "";
1085
1104
  const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking);
1086
- lines.push(` ${widgetStepGlyph(step.status, theme, widgetStepRunningSeed(step, index))} ${itemTitle} ${index + 1}/${total}: ${themeBold(theme, step.agent)}${contextModeBadge(theme, step.context)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${activitySuffix}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}`);
1105
+ lines.push(` ${widgetStepGlyph(step.status, theme, widgetStepRunningSeed(step, index), frame)} ${itemTitle} ${index + 1}/${total}: ${themeBold(theme, step.agent)}${contextModeBadge(theme, step.context)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${activitySuffix}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}`);
1087
1106
  for (const nestedLine of formatNestedWidgetLines(step.children, theme, width, false, job.updatedAt, 6)) lines.push(` ${nestedLine}`);
1088
1107
  }
1089
1108
  if (job.steps.some((step) => step.status === "running")) lines.push(theme.fg("accent", ` ${liveDetailHintText()}`));
@@ -1139,10 +1158,10 @@ function widgetHeaderCounts(jobs: AsyncJobState[]): { running: AsyncJobState[];
1139
1158
  };
1140
1159
  }
1141
1160
 
1142
- function buildSingleLineWidgetLines(jobs: AsyncJobState[], theme: Theme, width: number): string[] {
1161
+ function buildSingleLineWidgetLines(jobs: AsyncJobState[], theme: Theme, width: number, frame?: number): string[] {
1143
1162
  const counts = widgetHeaderCounts(jobs);
1144
1163
  const hasActive = counts.running.length > 0 || counts.queued.length > 0;
1145
- const glyph = counts.running.length > 0 ? runningGlyph(widgetJobsRunningSeed(counts.running)) : hasActive ? "●" : "○";
1164
+ const glyph = counts.running.length > 0 ? runningGlyph(animatedSeed(widgetJobsRunningSeed(counts.running), frame)) : hasActive ? "●" : "○";
1146
1165
  const parts: string[] = [];
1147
1166
  if (counts.running.length > 0) parts.push(`${counts.running.length}/${jobs.length} running`);
1148
1167
  if (counts.queued.length > 0) parts.push(`${counts.queued.length} queued`);
@@ -1202,10 +1221,10 @@ function selectProgressiveJobKeys(jobs: AsyncJobState[], previousKeys: string[],
1202
1221
  return selected;
1203
1222
  }
1204
1223
 
1205
- function progressiveHeaderLine(jobs: AsyncJobState[], theme: Theme, width: number): string {
1224
+ function progressiveHeaderLine(jobs: AsyncJobState[], theme: Theme, width: number, frame?: number): string {
1206
1225
  const counts = widgetHeaderCounts(jobs);
1207
1226
  const hasActive = counts.running.length > 0 || counts.queued.length > 0;
1208
- const glyph = counts.running.length > 0 ? runningGlyph(widgetJobsRunningSeed(counts.running)) : hasActive ? "●" : "○";
1227
+ const glyph = counts.running.length > 0 ? runningGlyph(animatedSeed(widgetJobsRunningSeed(counts.running), frame)) : hasActive ? "●" : "○";
1209
1228
  const parts: string[] = [];
1210
1229
  if (counts.running.length > 0) parts.push(formatAgentRunningLabel(counts.running.length));
1211
1230
  if (counts.queued.length > 0) parts.push(`${counts.queued.length} queued`);
@@ -1218,7 +1237,7 @@ function progressiveHeaderLine(jobs: AsyncJobState[], theme: Theme, width: numbe
1218
1237
  return truncLine(`${theme.fg(hasActive ? "accent" : "dim", glyph)} ${theme.fg(hasActive ? "accent" : "dim", "Async agents")} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(", ") || `${jobs.length} total`)}`, width);
1219
1238
  }
1220
1239
 
1221
- function progressiveJobLine(job: AsyncJobState, theme: Theme, width: number): string {
1240
+ function progressiveJobLine(job: AsyncJobState, theme: Theme, width: number, frame?: number): string {
1222
1241
  const stats = widgetStats(job, theme);
1223
1242
  const activity = widgetActivity(job);
1224
1243
  const status = job.status === "complete" ? "done" : job.status;
@@ -1228,7 +1247,7 @@ function progressiveJobLine(job: AsyncJobState, theme: Theme, width: number): st
1228
1247
  stats,
1229
1248
  activity && activity.toLowerCase() !== status ? theme.fg("dim", activity) : "",
1230
1249
  ].filter(Boolean);
1231
- return truncLine(` ${widgetStatusGlyph(job, theme)} ${parts.join(` ${theme.fg("dim", "·")} `)}`, width);
1250
+ return truncLine(` ${widgetStatusGlyph(job, theme, frame)} ${parts.join(` ${theme.fg("dim", "·")} `)}`, width);
1232
1251
  }
1233
1252
 
1234
1253
  function progressiveHiddenLine(hiddenJobs: AsyncJobState[], theme: Theme, width: number): string {
@@ -1241,9 +1260,9 @@ function progressiveHiddenLine(hiddenJobs: AsyncJobState[], theme: Theme, width:
1241
1260
  return truncLine(theme.fg("dim", ` +${hiddenJobs.length} more${parts.length ? ` (${parts.join(", ")})` : ""}`), width);
1242
1261
  }
1243
1262
 
1244
- function buildProgressiveWidgetLines(jobs: AsyncJobState[], theme: Theme, width: number, lockedRows: number, previousKeys: string[]): { lines: string[]; visibleJobKeys: string[] } {
1263
+ function buildProgressiveWidgetLines(jobs: AsyncJobState[], theme: Theme, width: number, lockedRows: number, previousKeys: string[], frame?: number): { lines: string[]; visibleJobKeys: string[] } {
1245
1264
  const rowCount = Math.max(1, lockedRows);
1246
- if (rowCount === 1) return { lines: buildSingleLineWidgetLines(jobs, theme, width), visibleJobKeys: [] };
1265
+ if (rowCount === 1) return { lines: buildSingleLineWidgetLines(jobs, theme, width, frame), visibleJobKeys: [] };
1247
1266
 
1248
1267
  const bodyRows = rowCount - 1;
1249
1268
  let visibleJobKeys = selectProgressiveJobKeys(jobs, previousKeys, bodyRows);
@@ -1259,8 +1278,8 @@ function buildProgressiveWidgetLines(jobs: AsyncJobState[], theme: Theme, width:
1259
1278
  }
1260
1279
 
1261
1280
  const lines = [
1262
- progressiveHeaderLine(jobs, theme, width),
1263
- ...visibleJobs.map((job) => progressiveJobLine(job, theme, width)),
1281
+ progressiveHeaderLine(jobs, theme, width, frame),
1282
+ ...visibleJobs.map((job) => progressiveJobLine(job, theme, width, frame)),
1264
1283
  ];
1265
1284
  if (hiddenJobs.length > 0 && lines.length < rowCount) lines.push(progressiveHiddenLine(hiddenJobs, theme, width));
1266
1285
  while (lines.length < rowCount) lines.push(" ");
@@ -1271,6 +1290,11 @@ function collapsedWidgetLineBudget(rows: number): number {
1271
1290
  return Math.max(10, Math.min(14, Math.floor(rows * 0.35)));
1272
1291
  }
1273
1292
 
1293
+ function paddedWidgetLine(line: string, width: number): string {
1294
+ const text = ` ${line} `;
1295
+ return `${text}${" ".repeat(Math.max(0, width - visibleWidth(text)))}`;
1296
+ }
1297
+
1274
1298
  function fitWidgetLineBudget(lines: string[], theme: Theme, width: number, expanded: boolean): string[] {
1275
1299
  const rows = process.stdout.rows || 30;
1276
1300
  const budget = expanded
@@ -1285,7 +1309,7 @@ function fitWidgetLineBudget(lines: string[], theme: Theme, width: number, expan
1285
1309
  return [...lines.slice(0, visibleLines), truncLine(theme.fg("dim", hint), width)];
1286
1310
  }
1287
1311
 
1288
- function fitAdaptiveWidgetLines(jobs: AsyncJobState[], lines: string[], theme: Theme, width: number, expanded: boolean): string[] {
1312
+ function fitAdaptiveWidgetLines(jobs: AsyncJobState[], lines: string[], theme: Theme, width: number, expanded: boolean, frame?: number): string[] {
1289
1313
  if (expanded) {
1290
1314
  resetWidgetLayoutSession();
1291
1315
  return fitWidgetLineBudget(lines, theme, width, true);
@@ -1297,11 +1321,11 @@ function fitAdaptiveWidgetLines(jobs: AsyncJobState[], lines: string[], theme: T
1297
1321
  const availableRows = estimateAvailableWidgetRows();
1298
1322
 
1299
1323
  if (hasMatchingSession && widgetLayoutSession?.tier === "single-line") {
1300
- return buildSingleLineWidgetLines(jobs, theme, width);
1324
+ return buildSingleLineWidgetLines(jobs, theme, width, frame);
1301
1325
  }
1302
1326
 
1303
1327
  if (hasMatchingSession && widgetLayoutSession?.tier === "progressive" && widgetLayoutSession.lockedRows !== undefined) {
1304
- const rendered = buildProgressiveWidgetLines(jobs, theme, width, widgetLayoutSession.lockedRows, widgetLayoutSession.visibleJobKeys);
1328
+ const rendered = buildProgressiveWidgetLines(jobs, theme, width, widgetLayoutSession.lockedRows, widgetLayoutSession.visibleJobKeys, frame);
1305
1329
  widgetLayoutSession.visibleJobKeys = rendered.visibleJobKeys;
1306
1330
  return rendered.lines;
1307
1331
  }
@@ -1313,39 +1337,48 @@ function fitAdaptiveWidgetLines(jobs: AsyncJobState[], lines: string[], theme: T
1313
1337
 
1314
1338
  if (availableRows <= 2) {
1315
1339
  widgetLayoutSession = { expanded, rows, columns, tier: "single-line", visibleJobKeys: [] };
1316
- return buildSingleLineWidgetLines(jobs, theme, width);
1340
+ return buildSingleLineWidgetLines(jobs, theme, width, frame);
1317
1341
  }
1318
1342
 
1319
1343
  const lockedRows = Math.min(availableRows, collapsedWidgetLineBudget(rows));
1320
- const rendered = buildProgressiveWidgetLines(jobs, theme, width, lockedRows, []);
1344
+ const rendered = buildProgressiveWidgetLines(jobs, theme, width, lockedRows, [], frame);
1321
1345
  widgetLayoutSession = { expanded, rows, columns, tier: "progressive", lockedRows, visibleJobKeys: rendered.visibleJobKeys };
1322
1346
  return rendered.lines;
1323
1347
  }
1324
1348
 
1325
- function buildWidgetComponent(jobs: AsyncJobState[], expanded: boolean): (_tui: unknown, theme: Theme) => Component {
1326
- return (_tui, theme) => {
1327
- const width = getTermWidth();
1328
- const lines = expanded
1329
- ? buildWidgetLines(jobs, theme, width, true)
1330
- : jobs.length === 1
1331
- ? compactSingleWidgetLines(jobs[0]!, theme, width)
1332
- : buildWidgetLines(jobs, theme, width, false);
1349
+ function buildWidgetComponent(jobs: AsyncJobState[], expanded: boolean): (tui: { requestRender(): void }, theme: Theme) => Component & { dispose?(): void } {
1350
+ return (tui, theme) => {
1351
+ const requestRender = () => tui.requestRender();
1352
+ widgetRequestRender = requestRender;
1333
1353
  const container = new Container();
1334
- for (const line of fitAdaptiveWidgetLines(jobs, lines, theme, width, expanded)) container.addChild(new Text(line, 1, 0));
1335
- return container;
1354
+ container.render = (renderWidth: number): string[] => {
1355
+ const width = getTermWidth();
1356
+ const frame = widgetAnimationFrame();
1357
+ const lines = expanded
1358
+ ? buildWidgetLines(jobs, theme, width, true, frame)
1359
+ : jobs.length === 1
1360
+ ? compactSingleWidgetLines(jobs[0]!, theme, width, frame)
1361
+ : buildWidgetLines(jobs, theme, width, false, frame);
1362
+ return fitAdaptiveWidgetLines(jobs, lines, theme, width, expanded, frame).map((line) => paddedWidgetLine(line, renderWidth));
1363
+ };
1364
+ const component = container as Component & { dispose?(): void };
1365
+ component.dispose = () => {
1366
+ if (widgetRequestRender === requestRender) widgetRequestRender = undefined;
1367
+ };
1368
+ return component;
1336
1369
  };
1337
1370
  }
1338
1371
 
1339
- export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = getTermWidth(), expanded = false): string[] {
1372
+ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = getTermWidth(), expanded = false, frame?: number): string[] {
1340
1373
  if (jobs.length === 0) return [];
1341
- if (jobs.length === 1) return buildSingleWidgetLines(jobs[0]!, theme, width, expanded);
1374
+ if (jobs.length === 1) return buildSingleWidgetLines(jobs[0]!, theme, width, expanded, frame);
1342
1375
  const running = jobs.filter((job) => job.status === "running");
1343
1376
  const queued = jobs.filter((job) => job.status === "queued");
1344
1377
  const finished = jobs.filter((job) => job.status !== "running" && job.status !== "queued");
1345
1378
 
1346
1379
  const lines: string[] = [];
1347
1380
  const hasActive = running.length > 0 || queued.length > 0;
1348
- const headerGlyph = running.length > 0 ? runningGlyph(widgetJobsRunningSeed(running)) : hasActive ? "●" : "○";
1381
+ const headerGlyph = running.length > 0 ? runningGlyph(animatedSeed(widgetJobsRunningSeed(running), frame)) : hasActive ? "●" : "○";
1349
1382
  lines.push(truncLine(`${theme.fg(hasActive ? "accent" : "dim", headerGlyph)} ${theme.fg(hasActive ? "accent" : "dim", "Async agents")} ${theme.fg("dim", "· background")}`, width));
1350
1383
 
1351
1384
  const items: string[][] = [];
@@ -1358,7 +1391,7 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
1358
1391
  if (slots <= 0) { hiddenRunning++; continue; }
1359
1392
  const stats = widgetStats(job, theme);
1360
1393
  items.push([
1361
- `${widgetStatusGlyph(job, theme)} ${themeBold(theme, widgetJobName(job))}${contextModeBadge(theme, job.context)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
1394
+ `${widgetStatusGlyph(job, theme, frame)} ${themeBold(theme, widgetJobName(job))}${contextModeBadge(theme, job.context)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
1362
1395
  ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
1363
1396
  ...widgetParallelAgentDetails(job, theme, expanded, width),
1364
1397
  ]);
@@ -1375,7 +1408,7 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
1375
1408
  if (slots <= 0) { hiddenFinished++; continue; }
1376
1409
  const stats = widgetStats(job, theme);
1377
1410
  items.push([
1378
- `${widgetStatusGlyph(job, theme)} ${themeBold(theme, widgetJobName(job))}${contextModeBadge(theme, job.context)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
1411
+ `${widgetStatusGlyph(job, theme, frame)} ${themeBold(theme, widgetJobName(job))}${contextModeBadge(theme, job.context)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`,
1379
1412
  ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
1380
1413
  ...widgetParallelAgentDetails(job, theme, expanded, width),
1381
1414
  ]);
@@ -1412,6 +1445,7 @@ export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = ge
1412
1445
  export function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void {
1413
1446
  if (jobs.length === 0) {
1414
1447
  resetWidgetLayoutSession();
1448
+ widgetRequestRender = undefined;
1415
1449
  if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
1416
1450
  return;
1417
1451
  }
@@ -5,10 +5,4 @@ declare module "@earendil-works/pi-agent-core" {
5
5
  }
6
6
  }
7
7
 
8
- declare module "@earendil-works/pi-coding-agent" {
9
- interface ExtensionUIContext {
10
- requestRender?: () => void;
11
- }
12
- }
13
-
14
8
  export {};