@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 +14 -0
- package/docs/architecture/architecture.md +13 -14
- package/docs/plans/0148-split-agent-widget-rendering.md +255 -0
- package/docs/retro/0144-consolidate-observation-model.md +39 -0
- package/package.json +1 -1
- package/src/ui/agent-widget.ts +12 -188
- package/src/ui/display.ts +2 -1
- package/src/ui/widget-renderer.ts +236 -0
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
|
|
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
|
|
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
|
|
622
|
-
| Direct SDK import in `conversation-viewer.ts` | `conversation-viewer.test.ts` | Hoisted `vi.mock("@earendil-works/pi-tui")` to intercept `wrapTextWithAnsi`
|
|
623
|
-
| Widget mixes rendering, lifecycle, and state
|
|
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
|
|
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
|
-
|
|
694
|
-
The widget
|
|
695
|
-
Rendering functions receive data (agent list, activity map, registry) and return formatted strings
|
|
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
package/src/ui/agent-widget.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
+
}
|