@gotgenes/pi-subagents 6.15.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,34 @@ 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
+
22
+ ## [6.16.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.15.0...pi-subagents-v6.16.0) (2026-05-23)
23
+
24
+
25
+ ### Features
26
+
27
+ * add session and outputFile convenience getters to AgentRecord ([#144](https://github.com/gotgenes/pi-packages/issues/144)) ([b451894](https://github.com/gotgenes/pi-packages/commit/b451894d101b43be9115dcf6d45725a09da81df8))
28
+
29
+
30
+ ### Documentation
31
+
32
+ * mark Step L complete, remove resolved smells from architecture doc ([#144](https://github.com/gotgenes/pi-packages/issues/144)) ([36ab7a5](https://github.com/gotgenes/pi-packages/commit/36ab7a51d0320666b71d6c9e20c3bbf63b7c43c5))
33
+ * plan consolidate observation model ([#144](https://github.com/gotgenes/pi-packages/issues/144)) ([9aa2c85](https://github.com/gotgenes/pi-packages/commit/9aa2c8508079c6ae847662631afd223e8966e12e))
34
+ * **retro:** add retro notes for issue [#145](https://github.com/gotgenes/pi-packages/issues/145) ([2d23081](https://github.com/gotgenes/pi-packages/commit/2d230817d53357a62eb752b56f8b1c8ce4af718c))
35
+
8
36
  ## [6.15.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.14.1...pi-subagents-v6.15.0) (2026-05-23)
9
37
 
10
38
 
@@ -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,15 +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
- | Dual observation | `record-observer.ts`, `ui-observer.ts` | Both independently count tool uses and accumulate lifetime usage from the same session events; consumers use `activity?.toolUses ?? record.toolUses` fallbacks | High |
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
- | `record.execution?.session` traversal | 15+ callsites across tools, notification, widget, menu | Callers reach through `ExecutionState` to access session and outputFile - Law of Demeter violation | Medium |
624
- | Direct SDK import in `conversation-viewer.ts` | `conversation-viewer.test.ts` | Hoisted `vi.mock("@earendil-works/pi-tui")` to intercept `wrapTextWithAnsi` | Low |
625
- | 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 |
626
- | `deps.` prefix noise in function bodies | 12 modules across tools, UI, notification, 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 |
627
626
 
628
627
  ### Dependency bag convention
629
628
 
@@ -634,9 +633,9 @@ Applied incrementally as each step touches a module:
634
633
 
635
634
  This eliminates the `deps.` prefix noise across ~124 callsites in 12 modules.
636
635
 
637
- ### Step L: Consolidate observation model (#144)
636
+ ### Step L: Consolidate observation model (#144)
638
637
 
639
- Remove `_toolUses` and `_lifetimeUsage` from `AgentActivityTracker`.
638
+ Removed `_toolUses` and `_lifetimeUsage` from `AgentActivityTracker`.
640
639
  UI consumers read stats from `AgentRecord` instead of the tracker.
641
640
  The UI observer retains event subscriptions for re-render triggers but no longer accumulates stats independently.
642
641
 
@@ -690,13 +689,11 @@ Apply the dependency bag convention: `ConversationViewerOptions` is destructured
690
689
 
691
690
  Impact: eliminates the hoisted `vi.mock("@earendil-works/pi-tui")` in `conversation-viewer.test.ts`.
692
691
 
693
- ### Step P: Split AgentWidget rendering (#148)
692
+ ### Step P: Split AgentWidget rendering (#148)
694
693
 
695
- Extract pure rendering functions from `AgentWidget` into `ui/widget-renderer.ts`.
696
- The widget becomes a thin lifecycle/polling wrapper that calls pure render functions.
697
- Rendering functions receive data (agent list, activity map, registry) and return formatted strings - testable without widget lifecycle.
698
-
699
- 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.
700
697
 
701
698
  ### Step dependencies
702
699
 
@@ -0,0 +1,263 @@
1
+ ---
2
+ issue: 144
3
+ issue_title: "Consolidate observation model (Phase 9, Step L)"
4
+ ---
5
+
6
+ # Consolidate observation model
7
+
8
+ ## Problem Statement
9
+
10
+ `record-observer.ts` and `ui-observer.ts` independently count tool uses and accumulate lifetime usage from the same session events.
11
+ `AgentRecord` owns `_toolUses` and `_lifetimeUsage` (accumulated by the record observer), while `AgentActivityTracker` maintains its own `_toolUses` and `_lifetimeUsage` (accumulated by the UI observer).
12
+ Consumers use `activity?.toolUses ?? record.toolUses` fallbacks to paper over the ambiguity.
13
+
14
+ Separately, 15+ callsites navigate `record.execution?.session` and `record.execution?.outputFile`, leaking the `ExecutionState` structure to every consumer.
15
+
16
+ Finally, `NotificationDeps` (4 fields) is a dependency bag on a class where every method uses every field — plain constructor parameters are simpler.
17
+
18
+ ## Goals
19
+
20
+ - Remove `_toolUses` and `_lifetimeUsage` from `AgentActivityTracker` so stats have a single source of truth on `AgentRecord`.
21
+ - Update UI consumers (widget, conversation viewer, notification, foreground runner) to read stats from `AgentRecord` instead of the tracker.
22
+ - Remove the `onToolEnd` counter and `onUsageUpdate` accumulator from `AgentActivityTracker`, keeping only live UI state (active tools, response text, turn count).
23
+ - Stop the UI observer from duplicating `tool_execution_end` counting and `message_end` usage accumulation.
24
+ - Add `session` and `outputFile` convenience getters on `AgentRecord` to hide the `execution?.` traversal.
25
+ - Replace `NotificationDeps` interface with plain parameters on `NotificationManager` constructor.
26
+
27
+ ## Non-Goals
28
+
29
+ - Splitting `AgentWidget` rendering into pure functions (Step P / #148 — depends on this work).
30
+ - Narrowing `ExtensionContext` for menu handlers (Step N / #146 — independent track).
31
+ - Injecting text wrapping into `ConversationViewer` (Step O / #147 — independent track).
32
+
33
+ ## Background
34
+
35
+ ### Dual counting
36
+
37
+ Both observers subscribe to the same session events:
38
+
39
+ | Event | `record-observer.ts` | `ui-observer.ts` |
40
+ | --------------------- | ---------------------------- | ------------------------------------- |
41
+ | `tool_execution_end` | `record.incrementToolUses()` | `tracker.onToolEnd()` → `_toolUses++` |
42
+ | `message_end` (usage) | `record.addUsage(delta)` | `tracker.onUsageUpdate(delta)` |
43
+
44
+ The widget reads `bg?.toolUses ?? a.toolUses` and the conversation viewer reads `this.activity?.toolUses ?? this.record.toolUses`, preferring the tracker when alive and falling back to the record.
45
+ After this change, there is one source: `AgentRecord`.
46
+
47
+ ### `execution?.` traversal
48
+
49
+ `ExecutionState` holds `session` and `outputFile`.
50
+ These are set once when the agent session is created (`agent-manager.ts` line 276).
51
+ Consumers reach through `record.execution?.session` and `record.execution?.outputFile` in 12 distinct locations across 7 files.
52
+ Convenience getters on `AgentRecord` eliminate the traversal and hide the `ExecutionState` structure.
53
+
54
+ ### `NotificationDeps` bag
55
+
56
+ `NotificationManager` receives 4 fields via `NotificationDeps`: `sendMessage`, `agentActivity`, `markFinished`, `updateWidget`.
57
+ Every method on the class uses every field.
58
+ Per code-design convention (dependency width), a 4-field interface where every consumer uses every field is fine as a bag, but the architecture doc explicitly calls for plain parameters here.
59
+ This is a mechanical change.
60
+
61
+ ## Design Overview
62
+
63
+ ### AgentActivityTracker changes
64
+
65
+ Remove `_toolUses`, `_lifetimeUsage`, `onToolEnd`, `onUsageUpdate`, `toolUses` getter, and `lifetimeUsage` getter.
66
+ Retain: `_activeTools`, `_toolKeySeq`, `_responseText`, `_session`, `_turnCount`, `_maxTurns`, and their associated transition methods and getters.
67
+
68
+ `onToolStart` stays (tracks active tools for the widget spinner).
69
+ A new `onToolDone(toolName)` method replaces `onToolEnd` — it removes the tool from `_activeTools` without incrementing any counter.
70
+ This rename clarifies that the method only manages the active-tool display set.
71
+
72
+ ### UI observer changes
73
+
74
+ The UI observer stops calling counter/accumulator methods:
75
+
76
+ - `tool_execution_end` handler: calls `tracker.onToolDone(name)` (active-tool tracking only).
77
+ - `message_end` handler: removed entirely (no more usage accumulation).
78
+ The `onUpdate?.()` call for usage re-render moves to the record observer path via the existing widget update flow.
79
+
80
+ ### AgentRecord convenience getters
81
+
82
+ ```typescript
83
+ get session(): AgentSession | undefined {
84
+ return this.execution?.session;
85
+ }
86
+
87
+ get outputFile(): string | undefined {
88
+ return this.execution?.outputFile;
89
+ }
90
+ ```
91
+
92
+ Callers change from `record.execution?.session` to `record.session`.
93
+ The `execution` field remains writable (set by `AgentManager`), but consumers no longer need to know about `ExecutionState`.
94
+
95
+ ### Consumer migration
96
+
97
+ | File | Before | After |
98
+ | -------------------------- | -------------------------------------------------- | ------------------------------------------------ |
99
+ | `agent-widget.ts` | `bg?.toolUses ?? a.toolUses` | `a.toolUses` |
100
+ | `agent-widget.ts` | `getLifetimeTotal(bg?.lifetimeUsage)` | `getLifetimeTotal(a.lifetimeUsage)` |
101
+ | `agent-widget.ts` | `getSessionContextPercent(bg?.session)` | `getSessionContextPercent(a.session)` |
102
+ | `conversation-viewer.ts` | `this.activity?.toolUses ?? this.record.toolUses` | `this.record.toolUses` |
103
+ | `conversation-viewer.ts` | `getLifetimeTotal(this.activity?.lifetimeUsage)` | `getLifetimeTotal(this.record.lifetimeUsage)` |
104
+ | `conversation-viewer.ts` | `getSessionContextPercent(this.activity?.session)` | `getSessionContextPercent(this.record.session)` |
105
+ | `notification.ts` | `record.execution?.session` | `record.session` |
106
+ | `notification.ts` | `record.execution?.outputFile` | `record.outputFile` |
107
+ | `tools/get-result-tool.ts` | `record.execution?.session` | `record.session` |
108
+ | `tools/steer-tool.ts` | `record.execution?.session` | `record.session` |
109
+ | `agent-menu.ts` | `record.execution?.session` | `record.session` |
110
+ | `service-adapter.ts` | `record.execution?.session` | `record.session` |
111
+ | `agent-manager.ts` | `record.execution?.session` | `record.session` (in dispose paths) |
112
+ | `foreground-runner.ts` | `fgState.toolUses` | `record.toolUses` (in final result) |
113
+ | `foreground-runner.ts` | `formatLifetimeTokens(fgState)` | `formatLifetimeTokens(record)` (in final result) |
114
+
115
+ ### NotificationManager refactoring
116
+
117
+ Replace:
118
+
119
+ ```typescript
120
+ export interface NotificationDeps { … }
121
+ constructor(private deps: NotificationDeps) {}
122
+ ```
123
+
124
+ With:
125
+
126
+ ```typescript
127
+ constructor(
128
+ private sendMessage: NotificationDeps["sendMessage"],
129
+ private agentActivity: Map<string, AgentActivityTracker>,
130
+ private markFinished: (id: string) => void,
131
+ private updateWidget: () => void,
132
+ ) {}
133
+ ```
134
+
135
+ Internal references change from `this.deps.sendMessage(…)` to `this.sendMessage(…)`.
136
+ The `NotificationDeps` interface is removed.
137
+
138
+ ### Foreground runner streaming
139
+
140
+ During foreground execution, `streamUpdate` reads `fgState.toolUses` and `formatLifetimeTokens(fgState)` for live spinner updates.
141
+ After removing these from the tracker, `streamUpdate` must read from the record instead.
142
+ The record is available only after `spawnAndWait` returns the record reference via `onSessionCreated`.
143
+ Before that callback fires, tool uses and usage are both zero — the tracker never had meaningful data at that point either.
144
+ `streamUpdate` will capture a `let recordRef: AgentRecord | undefined` and read `recordRef?.toolUses ?? 0` during the spinner phase.
145
+ After `onSessionCreated`, `recordRef` is set and subsequent spinner ticks read live values from the record.
146
+
147
+ ## Module-Level Changes
148
+
149
+ ### Modified files
150
+
151
+ 1. `src/ui/agent-activity-tracker.ts` — Remove `_toolUses`, `_lifetimeUsage`, `onToolEnd`, `onUsageUpdate`, `toolUses` getter, `lifetimeUsage` getter.
152
+ Rename remaining tool-end logic to `onToolDone`.
153
+ Remove `addUsage` and `UsageDelta` imports.
154
+ 2. `src/ui/ui-observer.ts` — Call `tracker.onToolDone` instead of `tracker.onToolEnd`.
155
+ Remove `message_end` usage accumulation block.
156
+ 3. `src/agent-record.ts` — Add `get session()` and `get outputFile()` convenience getters.
157
+ Import `AgentSession` type for the return type.
158
+ 4. `src/notification.ts` — Remove `NotificationDeps` interface.
159
+ Change `NotificationManager` constructor to plain parameters.
160
+ Replace `this.deps.*` with `this.*`.
161
+ Change `record.execution?.session` → `record.session`, `record.execution?.outputFile` → `record.outputFile`.
162
+ 5. `src/ui/agent-widget.ts` — Read `a.toolUses`, `a.lifetimeUsage`, `a.session` instead of `bg?.toolUses ?? a.toolUses` etc.
163
+ 6. `src/ui/conversation-viewer.ts` — Read `this.record.toolUses`, `this.record.lifetimeUsage`, `this.record.session` instead of fallback pattern.
164
+ 7. `src/tools/get-result-tool.ts` — `record.execution?.session` → `record.session`.
165
+ 8. `src/tools/steer-tool.ts` — `record.execution?.session` → `record.session`.
166
+ 9. `src/ui/agent-menu.ts` — `record.execution?.session` → `record.session`.
167
+ 10. `src/service-adapter.ts` — `record.execution?.session` → `record.session`.
168
+ 11. `src/agent-manager.ts` — `record.execution?.session` → `record.session` in dispose paths.
169
+ Keep the `record.execution = { session, outputFile }` assignment unchanged (that's the write path).
170
+ 12. `src/tools/foreground-runner.ts` — Read `record.toolUses` and `formatLifetimeTokens(record)` for final result.
171
+ Capture `recordRef` for streaming phase.
172
+ 13. `src/tools/helpers.ts` — `buildDetails`: read `record.toolUses` and `record.lifetimeUsage` (already does); remove optional `activity` parameter's usage of `toolUses`/`lifetimeUsage` if present (verify).
173
+ 14. `src/index.ts` — Update `NotificationManager` construction from bag to plain parameters.
174
+ 15. `src/tools/background-spawner.ts` — `record?.execution?.outputFile` → `record?.outputFile`.
175
+
176
+ ### Test files modified
177
+
178
+ 1. `test/ui/agent-activity-tracker.test.ts` — Remove tests for `toolUses`, `lifetimeUsage`, `onToolEnd`, `onUsageUpdate`.
179
+ Add tests for `onToolDone` (active-tool removal without counting).
180
+ 2. `test/ui/ui-observer.test.ts` — Update `tool_execution_end` expectations (calls `onToolDone` not `onToolEnd`).
181
+ Remove `message_end` usage accumulation tests.
182
+ 3. `test/notification.test.ts` — Update `makeDeps()` factory to use plain parameters.
183
+ Or update `NotificationManager` construction call to match new signature.
184
+ 4. `test/agent-record.test.ts` — Add tests for `session` and `outputFile` getters.
185
+ 5. `test/conversation-viewer.test.ts` — Remove mock activity tracker usage for stats; tests use record stats directly.
186
+
187
+ ## Test Impact Analysis
188
+
189
+ ### New unit tests enabled
190
+
191
+ 1. `AgentRecord.session` and `AgentRecord.outputFile` getters — straightforward getter tests on the record class.
192
+ 2. `AgentActivityTracker.onToolDone` — verifies active-tool removal without side effects on a counter.
193
+
194
+ ### Existing tests that become simpler
195
+
196
+ 1. `agent-activity-tracker.test.ts` — 4 test blocks for `toolUses`, `lifetimeUsage`, `onToolEnd`, `onUsageUpdate` can be removed.
197
+ The `onToolDone` replacement needs fewer assertions (no counter check).
198
+ 2. `ui-observer.test.ts` — The `message_end` usage accumulation tests can be removed.
199
+ The `tool_execution_end` test simplifies (verifies active-tool removal only).
200
+ 3. `notification.test.ts` — The `makeDeps()` helper changes shape but stays the same size.
201
+ 4. `conversation-viewer.test.ts` — Tests that mock `activity.toolUses` and `activity.lifetimeUsage` can simplify to reading from the record.
202
+
203
+ ### Existing tests that must stay
204
+
205
+ 1. `record-observer.test.ts` — All tests remain as-is.
206
+ The record observer is the sole source of `incrementToolUses` and `addUsage` calls now.
207
+ 2. `agent-record.test.ts` — All existing tests for `incrementToolUses`, `addUsage`, `incrementCompactions` remain.
208
+ 3. `notification.test.ts` — Pure helper tests (`escapeXml`, `getStatusLabel`, `formatTaskNotification`, `buildNotificationDetails`, `buildEventData`) remain unchanged.
209
+
210
+ ## TDD Order
211
+
212
+ 1. **Red→Green: `AgentRecord` convenience getters.**
213
+ Add tests for `record.session` and `record.outputFile` (returns `undefined` when no execution, delegates when set).
214
+ Implement the getters.
215
+ Commit: `feat: add session and outputFile convenience getters to AgentRecord (#144)`
216
+
217
+ 2. **Green→Green: Migrate callsites from `execution?.` to convenience getters.**
218
+ Update all 12 callsites across 7 files (`notification.ts`, `agent-widget.ts`, `agent-menu.ts`, `get-result-tool.ts`, `steer-tool.ts`, `service-adapter.ts`, `agent-manager.ts`, `background-spawner.ts`).
219
+ No test changes needed — existing tests pass.
220
+ Run `pnpm run check` to verify.
221
+ Commit: `refactor: use AgentRecord.session and .outputFile convenience getters (#144)`
222
+
223
+ 3. **Red→Green: Rename `onToolEnd` to `onToolDone` and remove counter.**
224
+ Update `agent-activity-tracker.test.ts`: remove `onToolEnd` counter tests, add `onToolDone` tests (active-tool removal only, no `toolUses` assertion).
225
+ Implement: rename method, remove `_toolUses++`.
226
+ Update `ui-observer.ts` to call `onToolDone`.
227
+ Update `ui-observer.test.ts` expectations.
228
+ Commit: `refactor: rename AgentActivityTracker.onToolEnd to onToolDone (#144)`
229
+
230
+ 4. **Red→Green: Remove `_toolUses` and `_lifetimeUsage` from tracker.**
231
+ Update `agent-activity-tracker.test.ts`: remove `toolUses` getter, `lifetimeUsage` getter, and `onUsageUpdate` test blocks.
232
+ Implement: remove `_toolUses`, `_lifetimeUsage`, `toolUses` getter, `lifetimeUsage` getter, `onUsageUpdate` method, `UsageDelta` type, `addUsage` import.
233
+ Update `ui-observer.ts`: remove `message_end` usage accumulation block.
234
+ Update `ui-observer.test.ts`: remove `message_end` usage tests.
235
+ Run `pnpm run check` to catch any remaining references.
236
+ Commit: `refactor: remove duplicate stats from AgentActivityTracker (#144)`
237
+
238
+ 5. **Green→Green: Migrate UI consumers to read stats from `AgentRecord`.**
239
+ Update `agent-widget.ts`: replace `bg?.toolUses ?? a.toolUses` → `a.toolUses`, `bg?.lifetimeUsage` → `a.lifetimeUsage`, `bg?.session` → `a.session`.
240
+ Update `conversation-viewer.ts`: replace `activity?.toolUses ?? record.toolUses` → `record.toolUses`, `activity?.lifetimeUsage` → `record.lifetimeUsage`, `activity?.session` → `record.session`.
241
+ Update `foreground-runner.ts`: read stats from record instead of tracker for final result.
242
+ Capture `recordRef` for streaming phase.
243
+ Update `conversation-viewer.test.ts` if any tests mock activity stats.
244
+ Commit: `refactor: read stats from AgentRecord in UI consumers (#144)`
245
+
246
+ 6. **Red→Green: Replace `NotificationDeps` with plain parameters.**
247
+ Update `notification.test.ts`: change `makeDeps()` → individual parameters in `NotificationManager` constructor calls.
248
+ Implement: remove `NotificationDeps` interface, change constructor to plain parameters, replace `this.deps.*` with `this.*`.
249
+ Update `index.ts`: change `NotificationManager` construction to pass individual arguments.
250
+ Commit: `refactor: dissolve NotificationDeps into plain constructor parameters (#144)`
251
+
252
+ ## Risks and Mitigations
253
+
254
+ | Risk | Mitigation |
255
+ | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
256
+ | Foreground streaming reads stale `recordRef` before `onSessionCreated` fires | Before `onSessionCreated`, both tracker and record have zero stats — behavior is unchanged. Capture `recordRef` as `undefined` initially and guard with `??`. |
257
+ | Widget reads `a.toolUses` but `a` is the snapshot from `listAgents()` — might be stale | `listAgents()` returns live `AgentRecord` references (not copies), so `a.toolUses` reflects the latest record-observer increment. |
258
+ | Removing `lifetimeUsage` from tracker breaks `formatLifetimeTokens(fgState)` in foreground runner | Step 5 explicitly migrates this callsite to `formatLifetimeTokens(record)`. The TDD order places the removal (step 4) before the migration (step 5), but step 4's `pnpm run check` will catch the type error and both steps can be merged if needed. |
259
+ | `buildDetails` in `helpers.ts` accepts an `activity` parameter for `turnCount`/`maxTurns` | `turnCount` and `maxTurns` remain on the tracker — only `toolUses` and `lifetimeUsage` are removed. `buildDetails` reads `record.toolUses` and `record.lifetimeUsage` already; it reads `activity?.turnCount` and `activity?.maxTurns` which remain valid. |
260
+
261
+ ## Open Questions
262
+
263
+ - None — the architecture doc prescribes the exact changes and the design is straightforward.
@@ -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.