@tintinweb/pi-subagents 0.4.4 → 0.4.5
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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/ui/agent-widget.ts +193 -126
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.5] - 2026-03-16
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- **Widget render-once pattern** — the widget callback is now registered once via `setWidget()` and subsequent updates use `requestRender()` instead of re-registering the entire widget on every `update()` call. Eliminates layout thrashing from repeated widget teardown/setup cycles.
|
|
12
|
+
- **Status bar dedup** — `setStatus()` is now only called when the status text actually changes, avoiding redundant TUI updates.
|
|
13
|
+
- **UICtx change detection** — `setUICtx()` detects context changes and forces widget re-registration, correctly handling session switches.
|
|
14
|
+
|
|
15
|
+
### Refactored
|
|
16
|
+
- Extracted `renderWidget()` private method — moves all widget content rendering out of the `update()` closure into a standalone method that reads live state on each call.
|
|
17
|
+
- `update()` is now a lightweight coordinator: counts agents, manages registration lifecycle, and triggers re-renders.
|
|
18
|
+
|
|
8
19
|
## [0.4.4] - 2026-03-16
|
|
9
20
|
|
|
10
21
|
### Fixed
|
|
@@ -255,6 +266,7 @@ Initial release.
|
|
|
255
266
|
- **Thinking level** — per-agent extended thinking control
|
|
256
267
|
- **`/agent` and `/agents` commands**
|
|
257
268
|
|
|
269
|
+
[0.4.5]: https://github.com/tintinweb/pi-subagents/compare/v0.4.4...v0.4.5
|
|
258
270
|
[0.4.4]: https://github.com/tintinweb/pi-subagents/compare/v0.4.3...v0.4.4
|
|
259
271
|
[0.4.3]: https://github.com/tintinweb/pi-subagents/compare/v0.4.2...v0.4.3
|
|
260
272
|
[0.4.2]: https://github.com/tintinweb/pi-subagents/compare/v0.4.1...v0.4.2
|
package/package.json
CHANGED
package/src/ui/agent-widget.ts
CHANGED
|
@@ -155,6 +155,13 @@ export class AgentWidget {
|
|
|
155
155
|
/** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
|
|
156
156
|
private static readonly ERROR_LINGER_TURNS = 2;
|
|
157
157
|
|
|
158
|
+
/** Whether the widget callback is currently registered with the TUI. */
|
|
159
|
+
private widgetRegistered = false;
|
|
160
|
+
/** Cached TUI reference from widget factory callback, used for requestRender(). */
|
|
161
|
+
private tui: any | undefined;
|
|
162
|
+
/** Last status bar text, used to avoid redundant setStatus calls. */
|
|
163
|
+
private lastStatusText: string | undefined;
|
|
164
|
+
|
|
158
165
|
constructor(
|
|
159
166
|
private manager: AgentManager,
|
|
160
167
|
private agentActivity: Map<string, AgentActivity>,
|
|
@@ -162,7 +169,14 @@ export class AgentWidget {
|
|
|
162
169
|
|
|
163
170
|
/** Set the UI context (grabbed from first tool execution). */
|
|
164
171
|
setUICtx(ctx: UICtx) {
|
|
165
|
-
this.uiCtx
|
|
172
|
+
if (ctx !== this.uiCtx) {
|
|
173
|
+
// UICtx changed — the widget registered on the old context is gone.
|
|
174
|
+
// Force re-registration on next update().
|
|
175
|
+
this.uiCtx = ctx;
|
|
176
|
+
this.widgetRegistered = false;
|
|
177
|
+
this.tui = undefined;
|
|
178
|
+
this.lastStatusText = undefined;
|
|
179
|
+
}
|
|
166
180
|
}
|
|
167
181
|
|
|
168
182
|
/**
|
|
@@ -234,9 +248,11 @@ export class AgentWidget {
|
|
|
234
248
|
return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
|
|
235
249
|
}
|
|
236
250
|
|
|
237
|
-
/**
|
|
238
|
-
|
|
239
|
-
|
|
251
|
+
/**
|
|
252
|
+
* Render the widget content. Called from the registered widget's render() callback,
|
|
253
|
+
* reading live state each time instead of capturing it in a closure.
|
|
254
|
+
*/
|
|
255
|
+
private renderWidget(tui: any, theme: Theme): string[] {
|
|
240
256
|
const allAgents = this.manager.listAgents();
|
|
241
257
|
const running = allAgents.filter(a => a.status === "running");
|
|
242
258
|
const queued = allAgents.filter(a => a.status === "queued");
|
|
@@ -248,148 +264,196 @@ export class AgentWidget {
|
|
|
248
264
|
const hasActive = running.length > 0 || queued.length > 0;
|
|
249
265
|
const hasFinished = finished.length > 0;
|
|
250
266
|
|
|
251
|
-
// Nothing to show —
|
|
252
|
-
if (!hasActive && !hasFinished)
|
|
253
|
-
this.uiCtx.setWidget("agents", undefined);
|
|
254
|
-
this.uiCtx.setStatus("subagents", undefined);
|
|
255
|
-
if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
|
|
256
|
-
// Clean up stale entries
|
|
257
|
-
for (const [id] of this.finishedTurnAge) {
|
|
258
|
-
if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
|
|
259
|
-
}
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
267
|
+
// Nothing to show — return empty (widget will be unregistered by update())
|
|
268
|
+
if (!hasActive && !hasFinished) return [];
|
|
262
269
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if (queued.length > 0) statusParts.push(`${queued.length} queued`);
|
|
268
|
-
const total = running.length + queued.length;
|
|
269
|
-
this.uiCtx.setStatus("subagents", `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`);
|
|
270
|
-
} else {
|
|
271
|
-
this.uiCtx.setStatus("subagents", undefined);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
this.widgetFrame++;
|
|
270
|
+
const w = tui.terminal.columns;
|
|
271
|
+
const truncate = (line: string) => truncateToWidth(line, w);
|
|
272
|
+
const headingColor = hasActive ? "accent" : "dim";
|
|
273
|
+
const headingIcon = hasActive ? "●" : "○";
|
|
275
274
|
const frame = SPINNER[this.widgetFrame % SPINNER.length];
|
|
276
275
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const truncate = (line: string) => truncateToWidth(line, w);
|
|
280
|
-
const headingColor = hasActive ? "accent" : "dim";
|
|
281
|
-
const headingIcon = hasActive ? "●" : "○";
|
|
276
|
+
// Build sections separately for overflow-aware assembly.
|
|
277
|
+
// Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
|
|
282
278
|
|
|
283
|
-
|
|
284
|
-
|
|
279
|
+
const finishedLines: string[] = [];
|
|
280
|
+
for (const a of finished) {
|
|
281
|
+
finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme)));
|
|
282
|
+
}
|
|
285
283
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
284
|
+
const runningLines: string[][] = []; // each entry is [header, activity]
|
|
285
|
+
for (const a of running) {
|
|
286
|
+
const name = getDisplayName(a.type);
|
|
287
|
+
const modeLabel = getPromptModeLabel(a.type);
|
|
288
|
+
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
|
|
289
|
+
const elapsed = formatMs(Date.now() - a.startedAt);
|
|
290
|
+
|
|
291
|
+
const bg = this.agentActivity.get(a.id);
|
|
292
|
+
const toolUses = bg?.toolUses ?? a.toolUses;
|
|
293
|
+
let tokenText = "";
|
|
294
|
+
if (bg?.session) {
|
|
295
|
+
try { tokenText = formatTokens(bg.session.getSessionStats().tokens.total); } catch { /* */ }
|
|
289
296
|
}
|
|
290
297
|
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const elapsed = formatMs(Date.now() - a.startedAt);
|
|
297
|
-
|
|
298
|
-
const bg = this.agentActivity.get(a.id);
|
|
299
|
-
const toolUses = bg?.toolUses ?? a.toolUses;
|
|
300
|
-
let tokenText = "";
|
|
301
|
-
if (bg?.session) {
|
|
302
|
-
try { tokenText = formatTokens(bg.session.getSessionStats().tokens.total); } catch { /* */ }
|
|
303
|
-
}
|
|
298
|
+
const parts: string[] = [];
|
|
299
|
+
if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
|
|
300
|
+
if (tokenText) parts.push(tokenText);
|
|
301
|
+
parts.push(elapsed);
|
|
302
|
+
const statsText = parts.join(" · ");
|
|
304
303
|
|
|
305
|
-
|
|
306
|
-
if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
|
|
307
|
-
if (tokenText) parts.push(tokenText);
|
|
308
|
-
parts.push(elapsed);
|
|
309
|
-
const statsText = parts.join(" · ");
|
|
304
|
+
const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
|
|
310
305
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
]);
|
|
317
|
-
}
|
|
306
|
+
runningLines.push([
|
|
307
|
+
truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`),
|
|
308
|
+
truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
|
|
309
|
+
]);
|
|
310
|
+
}
|
|
318
311
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
312
|
+
const queuedLine = queued.length > 0
|
|
313
|
+
? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
|
|
314
|
+
: undefined;
|
|
315
|
+
|
|
316
|
+
// Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
|
|
317
|
+
const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
|
|
318
|
+
const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
|
|
319
|
+
|
|
320
|
+
const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
|
|
321
|
+
|
|
322
|
+
if (totalBody <= maxBody) {
|
|
323
|
+
// Everything fits — add all lines and fix up connectors for the last item.
|
|
324
|
+
lines.push(...finishedLines);
|
|
325
|
+
for (const pair of runningLines) lines.push(...pair);
|
|
326
|
+
if (queuedLine) lines.push(queuedLine);
|
|
327
|
+
|
|
328
|
+
// Fix last connector: swap ├─ → └─ and │ → space for activity lines.
|
|
329
|
+
if (lines.length > 1) {
|
|
330
|
+
const last = lines.length - 1;
|
|
331
|
+
lines[last] = lines[last].replace("├─", "└─");
|
|
332
|
+
// If last item is a running agent activity line, fix indent of that line
|
|
333
|
+
// and fix the header line above it.
|
|
334
|
+
if (runningLines.length > 0 && !queuedLine) {
|
|
335
|
+
// The last two lines are the last running agent's header + activity.
|
|
336
|
+
if (last >= 2) {
|
|
337
|
+
lines[last - 1] = lines[last - 1].replace("├─", "└─");
|
|
338
|
+
lines[last] = lines[last].replace("│ ", " ");
|
|
347
339
|
}
|
|
348
340
|
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
341
|
+
}
|
|
342
|
+
} else {
|
|
343
|
+
// Overflow — prioritize: running > queued > finished.
|
|
344
|
+
// Reserve 1 line for overflow indicator.
|
|
345
|
+
let budget = maxBody - 1;
|
|
346
|
+
let hiddenRunning = 0;
|
|
347
|
+
let hiddenFinished = 0;
|
|
348
|
+
|
|
349
|
+
// 1. Running agents (2 lines each)
|
|
350
|
+
for (const pair of runningLines) {
|
|
351
|
+
if (budget >= 2) {
|
|
352
|
+
lines.push(...pair);
|
|
353
|
+
budget -= 2;
|
|
354
|
+
} else {
|
|
355
|
+
hiddenRunning++;
|
|
364
356
|
}
|
|
357
|
+
}
|
|
365
358
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
359
|
+
// 2. Queued line
|
|
360
|
+
if (queuedLine && budget >= 1) {
|
|
361
|
+
lines.push(queuedLine);
|
|
362
|
+
budget--;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// 3. Finished agents
|
|
366
|
+
for (const fl of finishedLines) {
|
|
367
|
+
if (budget >= 1) {
|
|
368
|
+
lines.push(fl);
|
|
369
369
|
budget--;
|
|
370
|
+
} else {
|
|
371
|
+
hiddenFinished++;
|
|
370
372
|
}
|
|
373
|
+
}
|
|
371
374
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
375
|
+
// Overflow summary
|
|
376
|
+
const overflowParts: string[] = [];
|
|
377
|
+
if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
|
|
378
|
+
if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
|
|
379
|
+
const overflowText = overflowParts.join(", ");
|
|
380
|
+
lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`)
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return lines;
|
|
385
|
+
}
|
|
381
386
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
387
|
+
/** Force an immediate widget update. */
|
|
388
|
+
update() {
|
|
389
|
+
if (!this.uiCtx) return;
|
|
390
|
+
const allAgents = this.manager.listAgents();
|
|
391
|
+
|
|
392
|
+
// Lightweight existence checks — full categorization happens in renderWidget()
|
|
393
|
+
let runningCount = 0;
|
|
394
|
+
let queuedCount = 0;
|
|
395
|
+
let hasFinished = false;
|
|
396
|
+
for (const a of allAgents) {
|
|
397
|
+
if (a.status === "running") { runningCount++; }
|
|
398
|
+
else if (a.status === "queued") { queuedCount++; }
|
|
399
|
+
else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) { hasFinished = true; }
|
|
400
|
+
}
|
|
401
|
+
const hasActive = runningCount > 0 || queuedCount > 0;
|
|
402
|
+
|
|
403
|
+
// Nothing to show — clear widget
|
|
404
|
+
if (!hasActive && !hasFinished) {
|
|
405
|
+
if (this.widgetRegistered) {
|
|
406
|
+
this.uiCtx.setWidget("agents", undefined);
|
|
407
|
+
this.widgetRegistered = false;
|
|
408
|
+
this.tui = undefined;
|
|
389
409
|
}
|
|
410
|
+
if (this.lastStatusText !== undefined) {
|
|
411
|
+
this.uiCtx.setStatus("subagents", undefined);
|
|
412
|
+
this.lastStatusText = undefined;
|
|
413
|
+
}
|
|
414
|
+
if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
|
|
415
|
+
// Clean up stale entries
|
|
416
|
+
for (const [id] of this.finishedTurnAge) {
|
|
417
|
+
if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
|
|
418
|
+
}
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
390
421
|
|
|
391
|
-
|
|
392
|
-
|
|
422
|
+
// Status bar — only call setStatus when the text actually changes
|
|
423
|
+
let newStatusText: string | undefined;
|
|
424
|
+
if (hasActive) {
|
|
425
|
+
const statusParts: string[] = [];
|
|
426
|
+
if (runningCount > 0) statusParts.push(`${runningCount} running`);
|
|
427
|
+
if (queuedCount > 0) statusParts.push(`${queuedCount} queued`);
|
|
428
|
+
const total = runningCount + queuedCount;
|
|
429
|
+
newStatusText = `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`;
|
|
430
|
+
}
|
|
431
|
+
if (newStatusText !== this.lastStatusText) {
|
|
432
|
+
this.uiCtx.setStatus("subagents", newStatusText);
|
|
433
|
+
this.lastStatusText = newStatusText;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
this.widgetFrame++;
|
|
437
|
+
|
|
438
|
+
// Register widget callback once; subsequent updates use requestRender()
|
|
439
|
+
// which re-invokes render() without replacing the component (avoids layout thrashing).
|
|
440
|
+
if (!this.widgetRegistered) {
|
|
441
|
+
this.uiCtx.setWidget("agents", (tui, theme) => {
|
|
442
|
+
this.tui = tui;
|
|
443
|
+
return {
|
|
444
|
+
render: () => this.renderWidget(tui, theme),
|
|
445
|
+
invalidate: () => {
|
|
446
|
+
// Theme changed — force re-registration so factory captures fresh theme.
|
|
447
|
+
this.widgetRegistered = false;
|
|
448
|
+
this.tui = undefined;
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
}, { placement: "aboveEditor" });
|
|
452
|
+
this.widgetRegistered = true;
|
|
453
|
+
} else {
|
|
454
|
+
// Widget already registered — just request a re-render of existing components.
|
|
455
|
+
this.tui?.requestRender();
|
|
456
|
+
}
|
|
393
457
|
}
|
|
394
458
|
|
|
395
459
|
dispose() {
|
|
@@ -401,5 +465,8 @@ export class AgentWidget {
|
|
|
401
465
|
this.uiCtx.setWidget("agents", undefined);
|
|
402
466
|
this.uiCtx.setStatus("subagents", undefined);
|
|
403
467
|
}
|
|
468
|
+
this.widgetRegistered = false;
|
|
469
|
+
this.tui = undefined;
|
|
470
|
+
this.lastStatusText = undefined;
|
|
404
471
|
}
|
|
405
472
|
}
|