@gotgenes/pi-subagents 6.16.0 → 6.16.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/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ 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
+ ## [6.16.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.16.0...pi-subagents-v6.16.1) (2026-05-23)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-subagents:** theme parentheses and separator in formatSessionTokens ([33b67f6](https://github.com/gotgenes/pi-packages/commit/33b67f63915563c41605addb885af52b47844e96))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * plan split AgentWidget rendering from lifecycle ([#148](https://github.com/gotgenes/pi-packages/issues/148)) ([24c11d5](https://github.com/gotgenes/pi-packages/commit/24c11d51f2b6c9d2712815fbe46ede72084ffcbb))
19
+ * **retro:** add retro notes for issue [#144](https://github.com/gotgenes/pi-packages/issues/144) ([f3cdfd4](https://github.com/gotgenes/pi-packages/commit/f3cdfd46fe223d6cecb5dad0d739f053e7468433))
20
+ * update architecture for widget rendering extraction ([#148](https://github.com/gotgenes/pi-packages/issues/148)) ([450707e](https://github.com/gotgenes/pi-packages/commit/450707e1d0f41ae78f1a655ab350fd8f4fd64125))
21
+
8
22
  ## [6.16.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.15.0...pi-subagents-v6.16.0) (2026-05-23)
9
23
 
10
24
 
@@ -69,7 +69,8 @@ renderer.ts - notification TUI component
69
69
  record-observer.ts - session-event observer for record statistics
70
70
 
71
71
  ui/display.ts - pure formatters, display helpers, and shared types (Theme, AgentDetails)
72
- ui/agent-widget.ts - above-editor live status widget
72
+ ui/agent-widget.ts - above-editor live status widget (thin lifecycle wrapper)
73
+ ui/widget-renderer.ts - pure rendering functions for agent widget
73
74
  ui/agent-menu.ts - /agents slash command menu
74
75
  ui/conversation-viewer.ts - scrollable session overlay
75
76
  ui/ui-observer.ts - session-event observer for UI streaming
@@ -615,13 +616,13 @@ Phase 9 targets the next layer: observation model consolidation, `ExtensionConte
615
616
 
616
617
  ### Current smells
617
618
 
618
- | Smell | Location | Evidence | Severity |
619
- | ------------------------------------------------ | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
620
- | `execute` does config resolution for its callees | `agent-tool.ts` (145-line `execute`) | ~60 lines unpack config, resolve model, compute metadata, repack into 16-field bags for spawners; `ctx` threaded 4 layers deep | Medium |
621
- | Wide `ctx` in menu handlers | `agent-menu.ts`, `agent-config-editor.ts`, `agent-creation-wizard.ts` | Functions declare `ctx: ExtensionContext` but only call `ctx.ui.select/confirm/input/notify/editor`; 43 `ctx as any` casts across 3 test files | Medium |
622
- | Direct SDK import in `conversation-viewer.ts` | `conversation-viewer.test.ts` | Hoisted `vi.mock("@earendil-works/pi-tui")` to intercept `wrapTextWithAnsi` | Low |
623
- | Widget mixes rendering, lifecycle, and state | `agent-widget.ts` (370 lines) | `renderWidget` is ~109 lines mixing data collection, formatting, and overflow layout; constructor takes 3 concrete collaborators | Low |
624
- | `deps.` prefix noise in function bodies | remaining modules across tools, UI, service-adapter | Functions accept a `deps` bag and access every field as `deps.foo`; hides real dependencies and lengthens every call line | Low |
619
+ | Smell | Location | Evidence | Severity |
620
+ | ------------------------------------------------ | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
621
+ | `execute` does config resolution for its callees | `agent-tool.ts` (145-line `execute`) | ~60 lines unpack config, resolve model, compute metadata, repack into 16-field bags for spawners; `ctx` threaded 4 layers deep | Medium |
622
+ | Wide `ctx` in menu handlers | `agent-menu.ts`, `agent-config-editor.ts`, `agent-creation-wizard.ts` | Functions declare `ctx: ExtensionContext` but only call `ctx.ui.select/confirm/input/notify/editor`; 43 `ctx as any` casts across 3 test files | Medium |
623
+ | Direct SDK import in `conversation-viewer.ts` | `conversation-viewer.test.ts` | Hoisted `vi.mock("@earendil-works/pi-tui")` to intercept `wrapTextWithAnsi` | Low |
624
+ | ~~Widget mixes rendering, lifecycle, and state~~ | ~~`agent-widget.ts` (370 lines)~~ | Resolved by #148: rendering extracted to `widget-renderer.ts`; widget is now 198 lines | Done |
625
+ | `deps.` prefix noise in function bodies | remaining modules across tools, UI, service-adapter | Functions accept a `deps` bag and access every field as `deps.foo`; hides real dependencies and lengthens every call line | Low |
625
626
 
626
627
  ### Dependency bag convention
627
628
 
@@ -688,13 +689,11 @@ Apply the dependency bag convention: `ConversationViewerOptions` is destructured
688
689
 
689
690
  Impact: eliminates the hoisted `vi.mock("@earendil-works/pi-tui")` in `conversation-viewer.test.ts`.
690
691
 
691
- ### Step P: Split AgentWidget rendering (#148)
692
+ ### Step P: Split AgentWidget rendering (#148)
692
693
 
693
- Extract pure rendering functions from `AgentWidget` into `ui/widget-renderer.ts`.
694
- The widget becomes a thin lifecycle/polling wrapper that calls pure render functions.
695
- Rendering functions receive data (agent list, activity map, registry) and return formatted strings - testable without widget lifecycle.
696
-
697
- Depends on Step L: once the tracker drops stats fields, the renderer reads from `AgentRecord` for tool uses and usage, and from `AgentActivityTracker` only for live UI state (active tools, response text, turn count).
694
+ Extracted pure rendering functions (`renderWidgetLines`, `renderFinishedLine`, `renderRunningLines`) from `AgentWidget` into `ui/widget-renderer.ts`.
695
+ The widget is now a thin lifecycle/polling wrapper (198 lines, down from 374) that delegates to pure render functions.
696
+ Rendering functions receive data (agent list, activity map, registry) and return formatted strings testable without widget lifecycle. 23 new unit tests cover all status variants, overflow, tree connectors, and empty states.
698
697
 
699
698
  ### Step dependencies
700
699
 
@@ -0,0 +1,255 @@
1
+ ---
2
+ issue: 148
3
+ issue_title: "Split AgentWidget rendering from lifecycle (Phase 9, Step P)"
4
+ ---
5
+
6
+ # Split AgentWidget rendering from lifecycle
7
+
8
+ ## Problem Statement
9
+
10
+ `AgentWidget` (374 lines) mixes rendering, lifecycle management, spinner animation, state filtering, and status bar management in a single class.
11
+ `renderWidget` alone is ~109 lines, and `renderFinishedLine` adds another ~40.
12
+ The constructor takes 3 concrete collaborators (`AgentManager`, `Map<string, AgentActivityTracker>`, `AgentTypeRegistry`) with no interface extraction.
13
+ Rendering logic cannot be unit-tested without instantiating the full widget with its lifecycle machinery.
14
+
15
+ ## Goals
16
+
17
+ - Extract pure rendering functions from `AgentWidget` into `ui/widget-renderer.ts`.
18
+ - Make `AgentWidget` a thin lifecycle/polling wrapper that delegates to pure render functions.
19
+ - Enable direct unit testing of rendering logic with plain data — no widget lifecycle, no mocks for `setInterval`/`setWidget`/`requestRender`.
20
+
21
+ ## Non-Goals
22
+
23
+ - Changing the visual output of the widget (this is a pure refactor).
24
+ - Extracting the status bar logic into a separate module (could follow up).
25
+ - Narrowing the `AgentManager` dependency to an interface (tracked separately in the architecture doc).
26
+ - Injecting `truncateToWidth` (tracked as #147, Step O — an independent track).
27
+
28
+ ## Background
29
+
30
+ ### Dependency: #144 (Step L) — Consolidate observation model
31
+
32
+ Issue #144 is **closed/implemented**.
33
+ The renderer now reads stats (`toolUses`, `lifetimeUsage`, `compactionCount`) from `AgentRecord` and live UI state (`activeTools`, `responseText`, `turnCount`, `maxTurns`) from `AgentActivityTracker`.
34
+ No dual-counting fallback exists.
35
+
36
+ ### Existing pure helpers
37
+
38
+ `ui/display.ts` already contains stateless formatting functions (`formatMs`, `formatTurns`, `formatSessionTokens`, `describeActivity`, `getDisplayName`, `getPromptModeLabel`, `SPINNER`, `ERROR_STATUSES`, `Theme`).
39
+ The new `widget-renderer.ts` will consume these — it does not duplicate them.
40
+
41
+ ### Rendering data flow
42
+
43
+ The widget's `renderWidget` currently:
44
+
45
+ 1. Calls `this.manager.listAgents()` to get `AgentRecord[]`.
46
+ 2. Categorizes into running/queued/finished.
47
+ 3. Filters finished agents via `this.shouldShowFinished()`.
48
+ 4. Looks up `this.agentActivity.get(a.id)` for live stats.
49
+ 5. Calls `this.registry` for display names.
50
+ 6. Reads `this.widgetFrame` for spinner animation.
51
+ 7. Assembles tree-style lines with overflow logic.
52
+
53
+ Steps 3–7 are pure given the right inputs.
54
+ Steps 1–2 are also pure categorization.
55
+
56
+ ## Design Overview
57
+
58
+ ### Separation of concerns
59
+
60
+ The rendering extraction splits the widget into two layers:
61
+
62
+ 1. **`widget-renderer.ts`** — Pure functions that accept data and return `string[]`.
63
+ No `this`, no timers, no SDK types, no side effects.
64
+ 2. **`agent-widget.ts`** — Thin lifecycle wrapper that owns timers, UICtx, finished-turn aging, and calls the renderer with live data.
65
+
66
+ ### Renderer input shape
67
+
68
+ Rather than passing the full `AgentRecord` class (which carries mutation methods and phase collaborators), the renderer receives a plain data slice:
69
+
70
+ ```typescript
71
+ /** Minimal agent snapshot for rendering — no class methods, no mutation surface. */
72
+ export interface WidgetAgent {
73
+ readonly id: string;
74
+ readonly type: SubagentType;
75
+ readonly status: string;
76
+ readonly description: string;
77
+ readonly toolUses: number;
78
+ readonly startedAt: number;
79
+ readonly completedAt?: number;
80
+ readonly error?: string;
81
+ readonly lifetimeUsage?: Readonly<LifetimeUsage>;
82
+ readonly compactionCount: number;
83
+ }
84
+ ```
85
+
86
+ This is structurally compatible with `AgentRecord` (the class satisfies it), so no mapping code is needed at the call site — `listAgents()` returns `AgentRecord[]` which satisfies `WidgetAgent[]`.
87
+
88
+ ### Renderer input for activity
89
+
90
+ Activity state is read from `AgentActivityTracker`.
91
+ The renderer needs a read-only view per agent:
92
+
93
+ ```typescript
94
+ /** Read-only activity snapshot for widget rendering. */
95
+ export interface WidgetActivity {
96
+ readonly activeTools: ReadonlyMap<string, string>;
97
+ readonly responseText: string;
98
+ readonly turnCount: number;
99
+ readonly maxTurns?: number;
100
+ readonly session?: SessionLike;
101
+ }
102
+ ```
103
+
104
+ `AgentActivityTracker` already satisfies this structurally (it exposes these as getters).
105
+
106
+ ### Agent config lookup
107
+
108
+ The renderer needs `getDisplayName` and `getPromptModeLabel`, which take a `SubagentType` and an `AgentConfigLookup`.
109
+ The renderer accepts `AgentConfigLookup` (the existing interface from `agent-types.ts`) — not the concrete `AgentTypeRegistry` class.
110
+
111
+ ### Renderer API
112
+
113
+ ```typescript
114
+ /** Pure rendering of the widget body. Returns lines to display. */
115
+ export function renderWidgetLines(params: {
116
+ agents: readonly WidgetAgent[];
117
+ activityMap: ReadonlyMap<string, WidgetActivity>;
118
+ registry: AgentConfigLookup;
119
+ spinnerFrame: number;
120
+ terminalWidth: number;
121
+ shouldShowFinished: (agentId: string, status: string) => boolean;
122
+ }): string[];
123
+
124
+ /** Pure rendering of a single finished agent line (no tree connector prefix). */
125
+ export function renderFinishedLine(
126
+ agent: WidgetAgent,
127
+ activity: WidgetActivity | undefined,
128
+ registry: AgentConfigLookup,
129
+ theme: Theme,
130
+ ): string;
131
+
132
+ /** Pure rendering of a single running agent (header + activity lines, no tree connector prefix). */
133
+ export function renderRunningLines(
134
+ agent: WidgetAgent,
135
+ activity: WidgetActivity | undefined,
136
+ registry: AgentConfigLookup,
137
+ spinnerFrame: number,
138
+ theme: Theme,
139
+ ): [header: string, activity: string];
140
+ ```
141
+
142
+ The top-level `renderWidgetLines` encapsulates the full categorization, overflow logic, and tree-connector fixup.
143
+ The per-agent functions are exported for fine-grained testing.
144
+
145
+ The `shouldShowFinished` callback is injected rather than re-implementing the aging logic inside the renderer, keeping the renderer pure and the aging state in the widget.
146
+
147
+ ### Call site in AgentWidget
148
+
149
+ ```typescript
150
+ // Inside renderWidget(tui, theme):
151
+ const w = tui.terminal.columns;
152
+ return renderWidgetLines({
153
+ agents: this.manager.listAgents(),
154
+ activityMap: this.agentActivity,
155
+ registry: this.registry,
156
+ spinnerFrame: this.widgetFrame,
157
+ terminalWidth: w,
158
+ shouldShowFinished: (id, status) => this.shouldShowFinished(id, status),
159
+ });
160
+ ```
161
+
162
+ The widget's `renderWidget` method shrinks to ~5 lines.
163
+
164
+ ### Tell-Don't-Ask verification
165
+
166
+ The renderer receives pre-collected data and returns formatted strings.
167
+ It does not reach through collaborators — it reads flat fields from `WidgetAgent` and `WidgetActivity`.
168
+ The widget tells the renderer "render this data"; the renderer returns lines.
169
+ No Law of Demeter violations.
170
+
171
+ ## Module-Level Changes
172
+
173
+ ### New file: `src/ui/widget-renderer.ts`
174
+
175
+ - `WidgetAgent` interface (structural subset of `AgentRecord`).
176
+ - `WidgetActivity` interface (structural subset of `AgentActivityTracker`).
177
+ - `renderWidgetLines()` — top-level rendering with categorization, overflow, tree connectors.
178
+ - `renderFinishedLine()` — single finished-agent line.
179
+ - `renderRunningLines()` — single running-agent header + activity pair.
180
+ - Imports from `display.ts` (`SPINNER`, `ERROR_STATUSES`, `formatMs`, `formatTurns`, `formatSessionTokens`, `describeActivity`, `getDisplayName`, `getPromptModeLabel`, `Theme`), from `usage.ts` (`getLifetimeTotal`, `getSessionContextPercent`, `LifetimeUsage`, `SessionLike`), and from `@earendil-works/pi-tui` (`truncateToWidth`).
181
+
182
+ ### Modified: `src/ui/agent-widget.ts`
183
+
184
+ - Remove `renderWidget()` method body — replace with call to `renderWidgetLines()`.
185
+ - Remove `renderFinishedLine()` method entirely.
186
+ - Remove direct imports of display helpers and usage helpers that are now only consumed by `widget-renderer.ts`.
187
+ - Keep: constructor, `setUICtx`, `onTurnStart`, `ensureTimer`, `shouldShowFinished`, `markFinished`, `update`, `dispose`, `UICtx` type, `MAX_WIDGET_LINES`.
188
+ - The inline type on `renderFinishedLine`'s parameter `a` is replaced by the `WidgetAgent` import.
189
+
190
+ ### New file: `test/widget-renderer.test.ts`
191
+
192
+ - Unit tests for `renderWidgetLines`, `renderFinishedLine`, `renderRunningLines`.
193
+ - Uses plain data objects (no mocks for `AgentManager`, `setInterval`, or SDK).
194
+ - Stub `Theme` matching the pattern in `test/renderer.test.ts`.
195
+
196
+ ### No changes to
197
+
198
+ - `src/index.ts` — the widget is constructed the same way; renderer is internal to the widget module.
199
+ - `src/ui/display.ts` — unchanged; consumed by the new renderer.
200
+ - `src/usage.ts` — unchanged.
201
+
202
+ ## Test Impact Analysis
203
+
204
+ 1. The extraction enables direct unit testing of widget rendering that was previously impossible — testing `renderWidget` required constructing a full `AgentWidget` with mocked `AgentManager`, fake timers, and a stubbed UICtx.
205
+ The new tests cover: finished-agent line formatting (all status variants), running-agent header/activity rendering, overflow logic, tree-connector fixup, empty-state handling, and `shouldShowFinished` filtering.
206
+ 2. No existing tests become redundant — there are currently **no** unit tests for `AgentWidget` rendering.
207
+ The existing `display.test.ts` tests lower-level formatters and remains as-is.
208
+ 3. `renderer.test.ts` tests the notification renderer — unrelated, stays as-is.
209
+
210
+ ## TDD Order
211
+
212
+ 1. **Red → Green:** Test `renderFinishedLine` for a completed agent (success icon, stats, duration).
213
+ Commit: `test: add renderFinishedLine tests for completed status`
214
+
215
+ 2. **Red → Green:** Test `renderFinishedLine` for error/aborted/steered/stopped statuses (icon and status text variations).
216
+ Commit: `test: renderFinishedLine error and terminal status variants`
217
+
218
+ 3. **Red → Green:** Test `renderRunningLines` (spinner frame, stats, activity description, token display).
219
+ Commit: `test: add renderRunningLines tests`
220
+
221
+ 4. **Red → Green:** Test `renderWidgetLines` — basic case with one running agent (heading, tree connectors).
222
+ Commit: `test: renderWidgetLines single running agent`
223
+
224
+ 5. **Red → Green:** Test `renderWidgetLines` — mixed running + finished + queued, verifying categorization and ordering.
225
+ Commit: `test: renderWidgetLines mixed agent states`
226
+
227
+ 6. **Red → Green:** Test `renderWidgetLines` — overflow cap with many agents, verifying the priority (running > queued > finished) and overflow summary line.
228
+ Commit: `test: renderWidgetLines overflow behavior`
229
+
230
+ 7. **Red → Green:** Test `renderWidgetLines` — empty state returns `[]`; finished-only state uses dim heading.
231
+ Commit: `test: renderWidgetLines empty and finished-only states`
232
+
233
+ 8. **Green → Refactor:** Extract `renderFinishedLine`, `renderRunningLines`, and `renderWidgetLines` into `src/ui/widget-renderer.ts`.
234
+ All tests pass against the extracted module.
235
+ Commit: `refactor: extract widget rendering into widget-renderer`
236
+
237
+ 9. **Green → Refactor:** Wire `AgentWidget.renderWidget()` to delegate to `renderWidgetLines()`.
238
+ Remove the inlined rendering logic from `agent-widget.ts`.
239
+ Remove unused imports.
240
+ Commit: `refactor: AgentWidget delegates rendering to widget-renderer`
241
+
242
+ 10. **Verify:** Run full test suite (`pnpm vitest run`) and type check (`pnpm run check`).
243
+ Commit: none (verification only).
244
+
245
+ ## Risks and Mitigations
246
+
247
+ | Risk | Mitigation |
248
+ | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
249
+ | Structural compatibility between `AgentRecord` and `WidgetAgent` could drift if `AgentRecord` renames a field. | TypeScript's structural checking catches this at the call site in `agent-widget.ts` — `listAgents()` returns `AgentRecord[]` which must satisfy `readonly WidgetAgent[]`. |
250
+ | `truncateToWidth` is an external dependency (`@earendil-works/pi-tui`) in the renderer. | Step O (#147) will inject it; for now, the renderer imports it directly, matching the current widget behavior. |
251
+ | Overflow logic is complex and hand-tested — extraction could introduce subtle line-count bugs. | TDD steps 4–7 exercise overflow edge cases before the extraction step. The extraction is a mechanical move with tests already passing. |
252
+
253
+ ## Open Questions
254
+
255
+ - None — the design follows the architecture doc's Step P specification and the dependency (#144) is already implemented.
@@ -0,0 +1,39 @@
1
+ ---
2
+ issue: 144
3
+ issue_title: "Consolidate observation model (Phase 9, Step L)"
4
+ ---
5
+
6
+ # Retro: #144 — Consolidate observation model
7
+
8
+ ## Final Retrospective (2026-05-23)
9
+
10
+ ### Session summary
11
+
12
+ Planned and implemented the Phase 9 Step L observation model consolidation.
13
+ Removed dual `_toolUses`/`_lifetimeUsage` counting from `AgentActivityTracker`, added `session`/`outputFile` convenience getters to `AgentRecord`, migrated 14 callsites, and dissolved `NotificationDeps` into plain constructor parameters.
14
+ Released as `pi-subagents-v6.16.0`.
15
+
16
+ ### Observations
17
+
18
+ #### What went well
19
+
20
+ - The plan correctly anticipated that TDD steps 4 (remove tracker stats) and 5 (migrate UI consumers) would be type-coupled and need merging.
21
+ This played out exactly as predicted — no surprise rework.
22
+ - Step 2's grep sweep during `execution?.` migration found two callsites (`agent-tool.ts:315`, `agent-manager.ts:353`) that the plan's file list missed.
23
+ Systematic grep at migration time caught them before commit.
24
+
25
+ #### What caused friction (agent side)
26
+
27
+ - `instruction-violation` — Did not load the `colgrep` skill during the planning phase despite two explicit instructions: AGENTS.md ("Use `colgrep` for intent-based codebase exploration") and the `/plan-issue` prompt ("load the `code-design` skill and the `colgrep` skill for convention discovery").
28
+ Loaded 4 other skills but skipped colgrep.
29
+ User-caught ("I noticed you didn't load or use `colgrep`").
30
+ Impact: one extra round-trip with the user; no rework since the plan hadn't been committed yet.
31
+ The colgrep searches proved useful once run — highest-scoring hit for "dependency bag converted to plain constructor parameters" was `notification.ts`, directly confirming the target.
32
+ - `wrong-abstraction` — When editing `src/ui/ui-observer.ts` to remove the `message_end` accumulation block, the replacement text closed the `session.subscribe(...)` callback but also added the function's closing brace, producing a duplicate `}`.
33
+ Autoformat caught the parse error immediately.
34
+ Impact: one follow-up edit, no downstream rework.
35
+
36
+ #### What caused friction (user side)
37
+
38
+ - None observed.
39
+ The user's intervention on colgrep was timely — caught before plan commit, not after.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "6.16.0",
3
+ "version": "6.16.1",
4
4
  "exports": {
5
5
  ".": "./src/service.ts"
6
6
  },
@@ -5,28 +5,11 @@
5
5
  * Uses the callback form of setWidget for themed rendering.
6
6
  */
7
7
 
8
- import { truncateToWidth } from "@earendil-works/pi-tui";
9
8
  import type { AgentManager } from "../agent-manager.js";
10
9
  import { AgentTypeRegistry } from "../agent-types.js";
11
- import type { SubagentType } from "../types.js";
12
- import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
10
  import type { AgentActivityTracker } from "./agent-activity-tracker.js";
14
- import {
15
- describeActivity,
16
- ERROR_STATUSES,
17
- formatMs,
18
- formatSessionTokens,
19
- formatTurns,
20
- getDisplayName,
21
- getPromptModeLabel,
22
- SPINNER,
23
- type Theme,
24
- } from "./display.js";
25
-
26
- // ---- Constants ----
27
-
28
- /** Maximum number of rendered lines before overflow collapse kicks in. */
29
- const MAX_WIDGET_LINES = 12;
11
+ import { ERROR_STATUSES, type Theme } from "./display.js";
12
+ import { renderWidgetLines } from "./widget-renderer.js";
30
13
 
31
14
  // ---- Types ----
32
15
 
@@ -113,176 +96,17 @@ export class AgentWidget {
113
96
  }
114
97
  }
115
98
 
116
- /** Render a finished agent line. */
117
- private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string }, theme: Theme): string {
118
- const name = getDisplayName(a.type, this.registry);
119
- const modeLabel = getPromptModeLabel(a.type, this.registry);
120
- const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
121
-
122
- let icon: string;
123
- let statusText: string;
124
- if (a.status === "completed") {
125
- icon = theme.fg("success", "✓");
126
- statusText = "";
127
- } else if (a.status === "steered") {
128
- icon = theme.fg("warning", "✓");
129
- statusText = theme.fg("warning", " (turn limit)");
130
- } else if (a.status === "stopped") {
131
- icon = theme.fg("dim", "■");
132
- statusText = theme.fg("dim", " stopped");
133
- } else if (a.status === "error") {
134
- icon = theme.fg("error", "✗");
135
- const errMsg = a.error ? `: ${a.error.slice(0, 60)}` : "";
136
- statusText = theme.fg("error", ` error${errMsg}`);
137
- } else {
138
- // aborted
139
- icon = theme.fg("error", "✗");
140
- statusText = theme.fg("warning", " aborted");
141
- }
142
-
143
- const parts: string[] = [];
144
- const activity = this.agentActivity.get(a.id);
145
- if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
146
- if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
147
- parts.push(duration);
148
-
149
- const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
150
- return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
151
- }
152
-
153
- /**
154
- * Render the widget content. Called from the registered widget's render() callback,
155
- * reading live state each time instead of capturing it in a closure.
156
- */
99
+ /** Delegate rendering to the pure widget-renderer module. */
157
100
  private renderWidget(tui: any, theme: Theme): string[] {
158
- const allAgents = this.manager.listAgents();
159
- const running = allAgents.filter(a => a.status === "running");
160
- const queued = allAgents.filter(a => a.status === "queued");
161
- const finished = allAgents.filter(a =>
162
- a.status !== "running" && a.status !== "queued" && a.completedAt
163
- && this.shouldShowFinished(a.id, a.status),
164
- );
165
-
166
- const hasActive = running.length > 0 || queued.length > 0;
167
- const hasFinished = finished.length > 0;
168
-
169
- // Nothing to show — return empty (widget will be unregistered by update())
170
- if (!hasActive && !hasFinished) return [];
171
-
172
- const w = tui.terminal.columns;
173
- const truncate = (line: string) => truncateToWidth(line, w);
174
- const headingColor = hasActive ? "accent" : "dim";
175
- const headingIcon = hasActive ? "●" : "○";
176
- const frame = SPINNER[this.widgetFrame % SPINNER.length];
177
-
178
- // Build sections separately for overflow-aware assembly.
179
- // Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
180
-
181
- const finishedLines: string[] = [];
182
- for (const a of finished) {
183
- finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme)));
184
- }
185
-
186
- const runningLines: string[][] = []; // each entry is [header, activity]
187
- for (const a of running) {
188
- const name = getDisplayName(a.type, this.registry);
189
- const modeLabel = getPromptModeLabel(a.type, this.registry);
190
- const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
191
- const elapsed = formatMs(Date.now() - a.startedAt);
192
-
193
- const bg = this.agentActivity.get(a.id);
194
- const tokens = getLifetimeTotal(a.lifetimeUsage);
195
- const contextPercent = getSessionContextPercent(a.session);
196
- const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : "";
197
-
198
- const parts: string[] = [];
199
- if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
200
- if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
201
- if (tokenText) parts.push(tokenText);
202
- parts.push(elapsed);
203
- const statsText = parts.join(" · ");
204
-
205
- const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
206
-
207
- runningLines.push([
208
- truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`),
209
- truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
210
- ]);
211
- }
212
-
213
- const queuedLine = queued.length > 0
214
- ? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
215
- : undefined;
216
-
217
- // Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
218
- const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
219
- const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
220
-
221
- const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
222
-
223
- if (totalBody <= maxBody) {
224
- // Everything fits — add all lines and fix up connectors for the last item.
225
- lines.push(...finishedLines);
226
- for (const pair of runningLines) lines.push(...pair);
227
- if (queuedLine) lines.push(queuedLine);
228
-
229
- // Fix last connector: swap ├─ → └─ and │ → space for activity lines.
230
- if (lines.length > 1) {
231
- const last = lines.length - 1;
232
- lines[last] = lines[last].replace("├─", "└─");
233
- // If last item is a running agent activity line, fix indent of that line
234
- // and fix the header line above it.
235
- if (runningLines.length > 0 && !queuedLine) {
236
- // The last two lines are the last running agent's header + activity.
237
- if (last >= 2) {
238
- lines[last - 1] = lines[last - 1].replace("├─", "└─");
239
- lines[last] = lines[last].replace("│ ", " ");
240
- }
241
- }
242
- }
243
- } else {
244
- // Overflow — prioritize: running > queued > finished.
245
- // Reserve 1 line for overflow indicator.
246
- let budget = maxBody - 1;
247
- let hiddenRunning = 0;
248
- let hiddenFinished = 0;
249
-
250
- // 1. Running agents (2 lines each)
251
- for (const pair of runningLines) {
252
- if (budget >= 2) {
253
- lines.push(...pair);
254
- budget -= 2;
255
- } else {
256
- hiddenRunning++;
257
- }
258
- }
259
-
260
- // 2. Queued line
261
- if (queuedLine && budget >= 1) {
262
- lines.push(queuedLine);
263
- budget--;
264
- }
265
-
266
- // 3. Finished agents
267
- for (const fl of finishedLines) {
268
- if (budget >= 1) {
269
- lines.push(fl);
270
- budget--;
271
- } else {
272
- hiddenFinished++;
273
- }
274
- }
275
-
276
- // Overflow summary
277
- const overflowParts: string[] = [];
278
- if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
279
- if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
280
- const overflowText = overflowParts.join(", ");
281
- lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`)
282
- );
283
- }
284
-
285
- return lines;
101
+ return renderWidgetLines({
102
+ agents: this.manager.listAgents(),
103
+ activityMap: this.agentActivity,
104
+ registry: this.registry,
105
+ spinnerFrame: this.widgetFrame,
106
+ terminalWidth: tui.terminal.columns,
107
+ theme,
108
+ shouldShowFinished: (id, status) => this.shouldShowFinished(id, status),
109
+ });
286
110
  }
287
111
 
288
112
  /** Force an immediate widget update. */
package/src/ui/display.ts CHANGED
@@ -94,7 +94,8 @@ export function formatSessionTokens(
94
94
  annot.push(theme.fg("dim", `↻${compactions}`));
95
95
  }
96
96
  if (annot.length === 0) return tokenStr;
97
- return `${tokenStr} (${annot.join(" · ")})`;
97
+ const sep = theme.fg("dim", " · ");
98
+ return `${tokenStr} ${theme.fg("dim", "(")}${annot.join(sep)}${theme.fg("dim", ")")}`;
98
99
  }
99
100
 
100
101
  /** Format turn count with optional max limit: "⟳5≤30" or "⟳5". */
@@ -0,0 +1,236 @@
1
+ /**
2
+ * widget-renderer.ts — Pure rendering functions for the agent widget.
3
+ *
4
+ * All functions are stateless: they receive data and return formatted strings.
5
+ * No timers, no SDK types, no side effects. Consumed by AgentWidget.
6
+ */
7
+
8
+ import { truncateToWidth } from "@earendil-works/pi-tui";
9
+ import type { AgentConfigLookup } from "../agent-types.js";
10
+ import type { SubagentType } from "../types.js";
11
+ import type { LifetimeUsage, SessionLike } from "../usage.js";
12
+ import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
+ import {
14
+ describeActivity,
15
+ formatMs,
16
+ formatSessionTokens,
17
+ formatTurns,
18
+ getDisplayName,
19
+ getPromptModeLabel,
20
+ SPINNER,
21
+ type Theme,
22
+ } from "./display.js";
23
+
24
+ // ── Data interfaces ──────────────────────────────────────────────────────────
25
+
26
+ /** Minimal agent snapshot for rendering — no class methods, no mutation surface. */
27
+ export interface WidgetAgent {
28
+ readonly id: string;
29
+ readonly type: SubagentType;
30
+ readonly status: string;
31
+ readonly description: string;
32
+ readonly toolUses: number;
33
+ readonly startedAt: number;
34
+ readonly completedAt?: number;
35
+ readonly error?: string;
36
+ readonly lifetimeUsage?: Readonly<LifetimeUsage>;
37
+ readonly compactionCount: number;
38
+ }
39
+
40
+ /** Read-only activity snapshot for widget rendering. */
41
+ export interface WidgetActivity {
42
+ readonly activeTools: ReadonlyMap<string, string>;
43
+ readonly responseText: string;
44
+ readonly turnCount: number;
45
+ readonly maxTurns?: number;
46
+ readonly session?: SessionLike;
47
+ }
48
+
49
+ // ── Per-agent rendering ──────────────────────────────────────────────────────
50
+
51
+ /** Render a single finished agent line (no tree connector prefix). */
52
+ export function renderFinishedLine(
53
+ agent: WidgetAgent,
54
+ activity: WidgetActivity | undefined,
55
+ registry: AgentConfigLookup,
56
+ theme: Theme,
57
+ ): string {
58
+ const name = getDisplayName(agent.type, registry);
59
+ const modeLabel = getPromptModeLabel(agent.type, registry);
60
+ const duration = formatMs((agent.completedAt ?? Date.now()) - agent.startedAt);
61
+
62
+ let icon: string;
63
+ let statusText: string;
64
+ if (agent.status === "completed") {
65
+ icon = theme.fg("success", "✓");
66
+ statusText = "";
67
+ } else if (agent.status === "steered") {
68
+ icon = theme.fg("warning", "✓");
69
+ statusText = theme.fg("warning", " (turn limit)");
70
+ } else if (agent.status === "stopped") {
71
+ icon = theme.fg("dim", "■");
72
+ statusText = theme.fg("dim", " stopped");
73
+ } else if (agent.status === "error") {
74
+ icon = theme.fg("error", "✗");
75
+ const errMsg = agent.error ? `: ${agent.error.slice(0, 60)}` : "";
76
+ statusText = theme.fg("error", ` error${errMsg}`);
77
+ } else {
78
+ // aborted
79
+ icon = theme.fg("error", "✗");
80
+ statusText = theme.fg("warning", " aborted");
81
+ }
82
+
83
+ const parts: string[] = [];
84
+ if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
85
+ if (agent.toolUses > 0) parts.push(`${agent.toolUses} tool use${agent.toolUses === 1 ? "" : "s"}`);
86
+ parts.push(duration);
87
+
88
+ const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
89
+ return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", agent.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
90
+ }
91
+
92
+ /** Render a single running agent as header + activity line pair (no tree connector prefix). */
93
+ export function renderRunningLines(
94
+ agent: WidgetAgent,
95
+ activity: WidgetActivity | undefined,
96
+ registry: AgentConfigLookup,
97
+ spinnerFrame: number,
98
+ theme: Theme,
99
+ ): [header: string, activity: string] {
100
+ const name = getDisplayName(agent.type, registry);
101
+ const modeLabel = getPromptModeLabel(agent.type, registry);
102
+ const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
103
+ const elapsed = formatMs(Date.now() - agent.startedAt);
104
+
105
+ const tokens = getLifetimeTotal(agent.lifetimeUsage);
106
+ const contextPercent = activity?.session ? getSessionContextPercent(activity.session) : null;
107
+ const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, agent.compactionCount) : "";
108
+
109
+ const parts: string[] = [];
110
+ if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
111
+ if (agent.toolUses > 0) parts.push(`${agent.toolUses} tool use${agent.toolUses === 1 ? "" : "s"}`);
112
+ if (tokenText) parts.push(tokenText);
113
+ parts.push(elapsed);
114
+ const statsText = parts.join(" · ");
115
+
116
+ const frame = SPINNER[spinnerFrame % SPINNER.length];
117
+ const activityText = activity ? describeActivity(activity.activeTools, activity.responseText) : "thinking\u2026";
118
+
119
+ const header = `${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", agent.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`;
120
+ const activityLine = theme.fg("dim", ` \u23BF ${activityText}`);
121
+
122
+ return [header, activityLine];
123
+ }
124
+
125
+ // ── Full widget rendering ────────────────────────────────────────────────────
126
+
127
+ /** Maximum number of rendered lines before overflow collapse kicks in. */
128
+ const MAX_WIDGET_LINES = 12;
129
+
130
+ /** Pure rendering of the widget body. Returns lines to display. */
131
+ export function renderWidgetLines(params: {
132
+ agents: readonly WidgetAgent[];
133
+ activityMap: ReadonlyMap<string, WidgetActivity>;
134
+ registry: AgentConfigLookup;
135
+ spinnerFrame: number;
136
+ terminalWidth: number;
137
+ theme: Theme;
138
+ shouldShowFinished: (agentId: string, status: string) => boolean;
139
+ }): string[] {
140
+ const { agents, activityMap, registry, spinnerFrame, terminalWidth, theme, shouldShowFinished } = params;
141
+
142
+ const running = agents.filter(a => a.status === "running");
143
+ const queued = agents.filter(a => a.status === "queued");
144
+ const finished = agents.filter(a =>
145
+ a.status !== "running" && a.status !== "queued" && a.completedAt
146
+ && shouldShowFinished(a.id, a.status),
147
+ );
148
+
149
+ const hasActive = running.length > 0 || queued.length > 0;
150
+ const hasFinished = finished.length > 0;
151
+
152
+ if (!hasActive && !hasFinished) return [];
153
+
154
+ const truncate = (line: string) => truncateToWidth(line, terminalWidth);
155
+ const headingColor = hasActive ? "accent" : "dim";
156
+ const headingIcon = hasActive ? "\u25CF" : "\u25CB";
157
+
158
+ // Build sections separately for overflow-aware assembly.
159
+ const finishedLines: string[] = [];
160
+ for (const a of finished) {
161
+ finishedLines.push(truncate(theme.fg("dim", "\u251C\u2500") + " " + renderFinishedLine(a, activityMap.get(a.id), registry, theme)));
162
+ }
163
+
164
+ const runningLines: [string, string][] = [];
165
+ for (const a of running) {
166
+ const [header, act] = renderRunningLines(a, activityMap.get(a.id), registry, spinnerFrame, theme);
167
+ runningLines.push([
168
+ truncate(theme.fg("dim", "\u251C\u2500") + ` ${header}`),
169
+ truncate(theme.fg("dim", "\u2502 ") + act),
170
+ ]);
171
+ }
172
+
173
+ const queuedLine = queued.length > 0
174
+ ? truncate(theme.fg("dim", "\u251C\u2500") + ` ${theme.fg("muted", "\u25E6")} ${theme.fg("dim", `${queued.length} queued`)}`)
175
+ : undefined;
176
+
177
+ // Assemble with overflow cap (heading takes 1 line).
178
+ const maxBody = MAX_WIDGET_LINES - 1;
179
+ const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
180
+
181
+ const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
182
+
183
+ if (totalBody <= maxBody) {
184
+ lines.push(...finishedLines);
185
+ for (const pair of runningLines) lines.push(...pair);
186
+ if (queuedLine) lines.push(queuedLine);
187
+
188
+ // Fix last connector: swap \u251C\u2500 \u2192 \u2514\u2500 and \u2502 \u2192 space for activity lines.
189
+ if (lines.length > 1) {
190
+ const last = lines.length - 1;
191
+ lines[last] = lines[last].replace("\u251C\u2500", "\u2514\u2500");
192
+ if (runningLines.length > 0 && !queuedLine) {
193
+ if (last >= 2) {
194
+ lines[last - 1] = lines[last - 1].replace("\u251C\u2500", "\u2514\u2500");
195
+ lines[last] = lines[last].replace("\u2502 ", " ");
196
+ }
197
+ }
198
+ }
199
+ } else {
200
+ // Overflow — prioritize: running > queued > finished.
201
+ let budget = maxBody - 1;
202
+ let hiddenRunning = 0;
203
+ let hiddenFinished = 0;
204
+
205
+ for (const pair of runningLines) {
206
+ if (budget >= 2) {
207
+ lines.push(...pair);
208
+ budget -= 2;
209
+ } else {
210
+ hiddenRunning++;
211
+ }
212
+ }
213
+
214
+ if (queuedLine && budget >= 1) {
215
+ lines.push(queuedLine);
216
+ budget--;
217
+ }
218
+
219
+ for (const fl of finishedLines) {
220
+ if (budget >= 1) {
221
+ lines.push(fl);
222
+ budget--;
223
+ } else {
224
+ hiddenFinished++;
225
+ }
226
+ }
227
+
228
+ const overflowParts: string[] = [];
229
+ if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
230
+ if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
231
+ const overflowText = overflowParts.join(", ");
232
+ lines.push(truncate(theme.fg("dim", "\u2514\u2500") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`));
233
+ }
234
+
235
+ return lines;
236
+ }