@gotgenes/pi-subagents 6.18.6 → 6.18.7
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 +11 -0
- package/docs/architecture/architecture.md +6 -6
- package/docs/plans/0170-reduce-build-content-lines-complexity.md +225 -0
- package/docs/retro/0169-extract-run-context-from-run-options.md +31 -0
- package/docs/retro/0170-reduce-build-content-lines-complexity.md +36 -0
- package/package.json +1 -1
- package/src/ui/conversation-viewer.ts +15 -89
- package/src/ui/message-formatters.ts +188 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [6.18.7](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.18.6...pi-subagents-v6.18.7) (2026-05-24)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Documentation
|
|
12
|
+
|
|
13
|
+
* plan reduce buildContentLines complexity ([#170](https://github.com/gotgenes/pi-packages/issues/170)) ([9912d16](https://github.com/gotgenes/pi-packages/commit/9912d16a375aef3fcf39148f0fc6e0c7ca761f31))
|
|
14
|
+
* **retro:** add planning stage notes for issue [#170](https://github.com/gotgenes/pi-packages/issues/170) ([3ed1b75](https://github.com/gotgenes/pi-packages/commit/3ed1b7570af3e6569015570c657dc7ea1fe583f4))
|
|
15
|
+
* **retro:** add retro notes for issue [#169](https://github.com/gotgenes/pi-packages/issues/169) ([419c451](https://github.com/gotgenes/pi-packages/commit/419c451f285564f98a0ba11dddb215f38ad541c3))
|
|
16
|
+
* **retro:** add TDD stage notes for issue [#170](https://github.com/gotgenes/pi-packages/issues/170) ([75b3253](https://github.com/gotgenes/pi-packages/commit/75b325393be083eca02cf1db3a872a504ba03e53))
|
|
17
|
+
* update architecture for message-formatters extraction ([#170](https://github.com/gotgenes/pi-packages/issues/170)) ([1005354](https://github.com/gotgenes/pi-packages/commit/1005354d6faf632ff617acdc679660cffd3afbe2))
|
|
18
|
+
|
|
8
19
|
## [6.18.6](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.18.5...pi-subagents-v6.18.6) (2026-05-24)
|
|
9
20
|
|
|
10
21
|
|
|
@@ -220,7 +220,7 @@ sequenceDiagram
|
|
|
220
220
|
|
|
221
221
|
## Module organization
|
|
222
222
|
|
|
223
|
-
The extension has
|
|
223
|
+
The extension has 52 source files (7,461 LOC) organized into six domains plus entry-point wiring.
|
|
224
224
|
All eight domains have directories: `config/`, `session/`, `lifecycle/`, `observation/`, `service/`, `tools/`, `ui/`, and `handlers/`.
|
|
225
225
|
Issue #164 moved the 26 previously flat root-level files into five new domain directories, reducing the root to 5 files + 8 directories.
|
|
226
226
|
|
|
@@ -286,6 +286,7 @@ src/
|
|
|
286
286
|
│ ├── agent-config-editor.ts agent detail/edit view
|
|
287
287
|
│ ├── agent-creation-wizard.ts agent creation (AI + manual)
|
|
288
288
|
│ ├── conversation-viewer.ts scrollable session overlay
|
|
289
|
+
│ ├── message-formatters.ts pure per-message-type formatters (extracted from conversation-viewer)
|
|
289
290
|
│ ├── agent-activity-tracker.ts live activity state tracker
|
|
290
291
|
│ ├── agent-file-ops.ts filesystem abstraction
|
|
291
292
|
│ ├── ui-observer.ts session-event observer for streaming
|
|
@@ -429,7 +430,7 @@ These are fire-and-forget broadcast events — no request IDs, no reply channels
|
|
|
429
430
|
| Metric | Value |
|
|
430
431
|
| ------------------------- | ---------------------------- |
|
|
431
432
|
| Health score | 75/100 (B) |
|
|
432
|
-
| Total LOC | 7,
|
|
433
|
+
| Total LOC | 7,461 (52 files) |
|
|
433
434
|
| Dead code | 0 files, 0 exports |
|
|
434
435
|
| Maintainability index | 90.7 (good) |
|
|
435
436
|
| Avg cyclomatic complexity | 1.5 |
|
|
@@ -463,7 +464,6 @@ Functions with cyclomatic complexity ≥ 21 (critical threshold):
|
|
|
463
464
|
|
|
464
465
|
| Function | Cyclomatic | Cognitive | File | Concern |
|
|
465
466
|
| ------------------- | ---------- | --------- | --------------------------- | ---------------------------------- |
|
|
466
|
-
| `buildContentLines` | 30 | 71 | `ui/conversation-viewer.ts` | Formats session events for display |
|
|
467
467
|
| `renderResult` | 26 | 43 | `tools/agent-tool.ts` | Formats agent result for LLM |
|
|
468
468
|
| `showAgentDetail` | 25 | 33 | `ui/agent-config-editor.ts` | Agent detail/edit view |
|
|
469
469
|
| `renderWidgetLines` | 25 | 44 | `ui/widget-renderer.ts` | Renders widget status lines |
|
|
@@ -636,10 +636,10 @@ All existing consumers satisfy both sub-interfaces via structural typing with no
|
|
|
636
636
|
Extracted `exec`, `registry`, `cwd`, and `parentSession` into `RunContext`, nested as `RunOptions.context`.
|
|
637
637
|
`RunOptions` reduced from 12 to 9 fields (1 nested `context` + 8 flat execution fields).
|
|
638
638
|
|
|
639
|
-
### Step 7: Reduce buildContentLines complexity ([#170][170])
|
|
639
|
+
### Step 7: Reduce buildContentLines complexity ([#170][170]) ✓ Done
|
|
640
640
|
|
|
641
|
-
|
|
642
|
-
|
|
641
|
+
Extracted formatting sub-functions for each content type (user, assistant, tool result, bash execution, streaming indicator) into `ui/message-formatters.ts`.
|
|
642
|
+
`buildContentLines` in `conversation-viewer.ts` is now a ~30-line dispatch loop delegating to `formatMessage` and `formatStreamingIndicator`.
|
|
643
643
|
|
|
644
644
|
### Step 8: Reduce renderResult complexity ([#171][171])
|
|
645
645
|
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 170
|
|
3
|
+
issue_title: "refactor(pi-subagents): reduce buildContentLines complexity (cognitive 71)"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Reduce `buildContentLines` complexity
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`buildContentLines` in `ui/conversation-viewer.ts` has cyclomatic complexity 30 and cognitive complexity 71 — the highest in the codebase (fallow #2 refactoring target, score 9.7).
|
|
11
|
+
The method formats session events for display, handling user messages, assistant messages, tool calls, tool results, bash execution output, and a streaming indicator in a single function.
|
|
12
|
+
|
|
13
|
+
## Goals
|
|
14
|
+
|
|
15
|
+
- Extract per-content-type formatting into standalone pure functions in a new module.
|
|
16
|
+
- Reduce `buildContentLines` to a dispatch loop that delegates to formatters.
|
|
17
|
+
- Make each formatter independently testable with clear input/output.
|
|
18
|
+
|
|
19
|
+
## Non-Goals
|
|
20
|
+
|
|
21
|
+
- Changing the visual output or behavior of the conversation viewer.
|
|
22
|
+
- Refactoring `render()` or the chrome/scrolling logic.
|
|
23
|
+
- Restructuring the `ConversationViewer` class itself (constructor, options, etc.).
|
|
24
|
+
|
|
25
|
+
## Background
|
|
26
|
+
|
|
27
|
+
Issue #164 (reorganize source into domain directories) is implemented — files are already in `src/ui/`.
|
|
28
|
+
|
|
29
|
+
`buildContentLines` currently handles five concerns in a single method body:
|
|
30
|
+
|
|
31
|
+
1. **User messages** — extract text from string or content array, wrap, push with `[User]` header.
|
|
32
|
+
2. **Assistant messages** — separate text parts from tool calls, wrap text, append `[Tool: name]` lines.
|
|
33
|
+
3. **Tool results** — extract text, truncate to 500 chars, wrap in dim styling.
|
|
34
|
+
4. **Bash execution** — render command line, truncate/wrap output.
|
|
35
|
+
5. **Streaming indicator** — append activity description for running agents.
|
|
36
|
+
|
|
37
|
+
Each branch uses `this.theme` and `this.wrapText` but has no other instance dependencies.
|
|
38
|
+
The method also manages separator logic (`needsSeparator`) and applies a final `truncateToWidth` safety net.
|
|
39
|
+
|
|
40
|
+
Dependencies consumed by the formatters:
|
|
41
|
+
|
|
42
|
+
- `Theme` from `display.ts` — for `fg()` and `bold()`.
|
|
43
|
+
- `truncateToWidth` from `@earendil-works/pi-tui`.
|
|
44
|
+
- `extractText` from `session/context.ts`.
|
|
45
|
+
- `getToolCallName` and `isBashExecution` — file-local helpers in `conversation-viewer.ts`.
|
|
46
|
+
|
|
47
|
+
## Design Overview
|
|
48
|
+
|
|
49
|
+
### New module: `ui/message-formatters.ts`
|
|
50
|
+
|
|
51
|
+
A new file containing pure functions that convert a single message into display lines.
|
|
52
|
+
Each formatter receives the message, a `width`, and a narrow `FormatterContext` (theme + wrapText).
|
|
53
|
+
Each returns `string[]` — the formatted lines for that message, **excluding** separators and the final `truncateToWidth` pass (those remain in `buildContentLines`).
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
/** Narrow context shared by all message formatters. */
|
|
57
|
+
export interface FormatterContext {
|
|
58
|
+
theme: Theme;
|
|
59
|
+
wrapText: (text: string, width: number) => string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatUserMessage(
|
|
63
|
+
content: string | unknown[],
|
|
64
|
+
width: number,
|
|
65
|
+
ctx: FormatterContext,
|
|
66
|
+
): string[] | null;
|
|
67
|
+
|
|
68
|
+
export function formatAssistantMessage(
|
|
69
|
+
content: Array<{ type: string; text?: string }>,
|
|
70
|
+
width: number,
|
|
71
|
+
ctx: FormatterContext,
|
|
72
|
+
): string[];
|
|
73
|
+
|
|
74
|
+
export function formatToolResult(
|
|
75
|
+
content: unknown[],
|
|
76
|
+
width: number,
|
|
77
|
+
ctx: FormatterContext,
|
|
78
|
+
): string[] | null;
|
|
79
|
+
|
|
80
|
+
export function formatBashExecution(
|
|
81
|
+
msg: BashExecutionMessage,
|
|
82
|
+
width: number,
|
|
83
|
+
ctx: FormatterContext,
|
|
84
|
+
): string[];
|
|
85
|
+
|
|
86
|
+
export function formatStreamingIndicator(
|
|
87
|
+
activeTools: ReadonlyMap<string, string>,
|
|
88
|
+
responseText: string | undefined,
|
|
89
|
+
width: number,
|
|
90
|
+
theme: Theme,
|
|
91
|
+
): string[];
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`formatUserMessage` and `formatToolResult` return `null` when the content is empty (matching the current `continue` behavior), letting the caller skip the separator.
|
|
95
|
+
|
|
96
|
+
### Relocated helpers
|
|
97
|
+
|
|
98
|
+
`getToolCallName`, the `ToolCallContent` interface, `BashExecutionMessage`, and `isBashExecution` move to `message-formatters.ts` — they are consumed only by the formatters.
|
|
99
|
+
The type guard `isBashExecution` remains exported so `buildContentLines` can use it in the dispatch condition.
|
|
100
|
+
|
|
101
|
+
### Simplified `buildContentLines`
|
|
102
|
+
|
|
103
|
+
After extraction, `buildContentLines` becomes a ~25-line dispatch loop:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
private buildContentLines(width: number): string[] {
|
|
107
|
+
if (width <= 0) return [];
|
|
108
|
+
const ctx = { theme: this.theme, wrapText: this.wrapText };
|
|
109
|
+
const messages = this.session.messages;
|
|
110
|
+
if (messages.length === 0) {
|
|
111
|
+
return [this.theme.fg("dim", "(waiting for first message...)")];
|
|
112
|
+
}
|
|
113
|
+
const lines: string[] = [];
|
|
114
|
+
let needsSeparator = false;
|
|
115
|
+
for (const msg of messages) {
|
|
116
|
+
const formatted = formatMessage(msg, width, ctx);
|
|
117
|
+
if (!formatted) continue;
|
|
118
|
+
if (needsSeparator) lines.push(this.theme.fg("dim", "───"));
|
|
119
|
+
lines.push(...formatted);
|
|
120
|
+
needsSeparator = true;
|
|
121
|
+
}
|
|
122
|
+
if (this.record.status === "running" && this.activity) {
|
|
123
|
+
lines.push(...formatStreamingIndicator(
|
|
124
|
+
this.activity.activeTools, this.activity.responseText, width, this.theme,
|
|
125
|
+
));
|
|
126
|
+
}
|
|
127
|
+
return lines.map(l => truncateToWidth(l, width));
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
A private `formatMessage` dispatcher selects the right formatter by `msg.role`, keeping the per-role logic in the new module.
|
|
132
|
+
The `formatMessage` function lives in `message-formatters.ts` and encapsulates the role-based dispatch:
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
export function formatMessage(
|
|
136
|
+
msg: { role: string; [key: string]: unknown },
|
|
137
|
+
width: number,
|
|
138
|
+
ctx: FormatterContext,
|
|
139
|
+
): string[] | null;
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Design principles applied
|
|
143
|
+
|
|
144
|
+
- **SRP**: Each formatter has one reason to change (its content type's display rules).
|
|
145
|
+
- **ISP**: `FormatterContext` is a 2-field interface — narrower than `ConversationViewerOptions`.
|
|
146
|
+
- **Tell-Don't-Ask**: The caller tells the formatter "format this message at this width" and receives lines back — no interrogation of the message's internals in the caller.
|
|
147
|
+
- **No output arguments**: Formatters return new arrays; they don't mutate a shared `lines` accumulator.
|
|
148
|
+
|
|
149
|
+
## Module-Level Changes
|
|
150
|
+
|
|
151
|
+
### New file: `src/ui/message-formatters.ts`
|
|
152
|
+
|
|
153
|
+
- `FormatterContext` interface.
|
|
154
|
+
- `ToolCallContent` interface (moved from `conversation-viewer.ts`).
|
|
155
|
+
- `BashExecutionMessage` interface (moved from `conversation-viewer.ts`).
|
|
156
|
+
- `getToolCallName` function (moved from `conversation-viewer.ts`).
|
|
157
|
+
- `isBashExecution` type guard (moved from `conversation-viewer.ts`).
|
|
158
|
+
- `formatUserMessage` function.
|
|
159
|
+
- `formatAssistantMessage` function.
|
|
160
|
+
- `formatToolResult` function.
|
|
161
|
+
- `formatBashExecution` function.
|
|
162
|
+
- `formatStreamingIndicator` function.
|
|
163
|
+
- `formatMessage` dispatcher function.
|
|
164
|
+
|
|
165
|
+
### Modified: `src/ui/conversation-viewer.ts`
|
|
166
|
+
|
|
167
|
+
- Remove `ToolCallContent`, `BashExecutionMessage`, `getToolCallName`, `isBashExecution` (moved to `message-formatters.ts`).
|
|
168
|
+
- Import `formatMessage`, `formatStreamingIndicator` from `message-formatters.ts`.
|
|
169
|
+
- Replace `buildContentLines` body with the dispatch loop above.
|
|
170
|
+
|
|
171
|
+
### New file: `test/message-formatters.test.ts`
|
|
172
|
+
|
|
173
|
+
- Unit tests for each formatter function and the `formatMessage` dispatcher.
|
|
174
|
+
|
|
175
|
+
### Modified: `test/conversation-viewer.test.ts`
|
|
176
|
+
|
|
177
|
+
- Existing tests remain as-is — they exercise the integrated `render()` and `buildContentLines` paths (width-safety and clamping), which are genuine integration tests for the viewer.
|
|
178
|
+
- No tests become redundant; the new unit tests cover formatter logic that was previously only reachable through the viewer's `render()` method.
|
|
179
|
+
|
|
180
|
+
## Test Impact Analysis
|
|
181
|
+
|
|
182
|
+
1. **New unit tests enabled**: Each formatter can now be tested in isolation — verifying header labels, text wrapping, truncation thresholds (500-char limit), tool-call name extraction, empty-content null returns, and streaming indicator formatting — without constructing a full `ConversationViewer` with mock `TUI`, `AgentSession`, and `AgentRecord`.
|
|
183
|
+
2. **No existing tests become redundant**: The existing `conversation-viewer.test.ts` tests are render-width-safety integration tests.
|
|
184
|
+
They exercise the full `render()` → `buildContentLines` → `truncateToWidth` pipeline and should remain.
|
|
185
|
+
3. **Existing tests stay as-is**: They test the viewer's chrome, scrolling, and width-clamping behavior — concerns orthogonal to the per-message formatting logic being extracted.
|
|
186
|
+
|
|
187
|
+
## TDD Order
|
|
188
|
+
|
|
189
|
+
1. **Red → Green**: Add unit tests for `formatUserMessage` — plain string content, content-array content, empty content returning null, header and wrapping behavior.
|
|
190
|
+
Commit: `test: add formatUserMessage unit tests`
|
|
191
|
+
|
|
192
|
+
2. **Red → Green**: Add unit tests for `formatAssistantMessage` — text-only content, tool-call-only content, mixed content, empty text parts.
|
|
193
|
+
Commit: `test: add formatAssistantMessage unit tests`
|
|
194
|
+
|
|
195
|
+
3. **Red → Green**: Add unit tests for `formatToolResult` — normal content, content exceeding 500 chars (truncation), empty content returning null.
|
|
196
|
+
Commit: `test: add formatToolResult unit tests`
|
|
197
|
+
|
|
198
|
+
4. **Red → Green**: Add unit tests for `formatBashExecution` — command rendering, output wrapping, long output truncation, empty output.
|
|
199
|
+
Commit: `test: add formatBashExecution unit tests`
|
|
200
|
+
|
|
201
|
+
5. **Red → Green**: Add unit tests for `formatStreamingIndicator` — active tools, response text fallback, no-activity "thinking" fallback.
|
|
202
|
+
Commit: `test: add formatStreamingIndicator unit tests`
|
|
203
|
+
|
|
204
|
+
6. **Red → Green**: Add unit tests for `formatMessage` dispatcher — correct delegation by role, unknown role returning null.
|
|
205
|
+
Commit: `test: add formatMessage dispatcher tests`
|
|
206
|
+
|
|
207
|
+
7. **Green → Refactor**: Create `message-formatters.ts` with all formatter functions and the dispatcher.
|
|
208
|
+
Move `ToolCallContent`, `BashExecutionMessage`, `getToolCallName`, `isBashExecution` from `conversation-viewer.ts`.
|
|
209
|
+
Commit: `refactor: extract message formatters from conversation-viewer`
|
|
210
|
+
|
|
211
|
+
8. **Green → Refactor**: Simplify `buildContentLines` to use the new `formatMessage` dispatcher and `formatStreamingIndicator`.
|
|
212
|
+
Verify all existing `conversation-viewer.test.ts` tests still pass.
|
|
213
|
+
Commit: `refactor: simplify buildContentLines to dispatch loop`
|
|
214
|
+
|
|
215
|
+
## Risks and Mitigations
|
|
216
|
+
|
|
217
|
+
| Risk | Mitigation |
|
|
218
|
+
| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
219
|
+
| Formatter output differs subtly from inline code (missing separator, wrong styling) | Steps 7–8 keep all existing integration tests passing — any visual regression fails the width-safety suite. |
|
|
220
|
+
| `FormatterContext` grows over time as new formatters need more dependencies | The interface is deliberately minimal (2 fields); if a future formatter needs something new, it should accept it as a parameter rather than widening the context. |
|
|
221
|
+
| `msg` type is `unknown`-heavy due to Pi SDK not exporting narrow types | Preserve the existing file-local type guards and interfaces — they already handle the runtime shape safely. |
|
|
222
|
+
|
|
223
|
+
## Open Questions
|
|
224
|
+
|
|
225
|
+
- None — the extraction is mechanical and the issue's approach section is unambiguous.
|
|
@@ -35,3 +35,34 @@ Test count unchanged (50 files, 805 tests — pure refactor with no behavior cha
|
|
|
35
35
|
- The `agent-manager.test.ts` update also added two new assertions (`context.exec` and `context.registry` are defined) to each existing `getRunConfig` threading test, confirming the context object is wired correctly; these were not in the plan but add useful coverage.
|
|
36
36
|
- All 16 `runAgent()` call sites in tests used inline option literals (no spread patterns), so TypeScript caught any missed site at compile time — the plan's risk mitigation held.
|
|
37
37
|
- No deviations from the plan otherwise; the comment-only step was trivial.
|
|
38
|
+
|
|
39
|
+
## Stage: Final Retrospective (2026-05-24T17:32:52Z)
|
|
40
|
+
|
|
41
|
+
### Session summary
|
|
42
|
+
|
|
43
|
+
Completed the full issue lifecycle (plan → TDD → ship → retro) in a single conversation.
|
|
44
|
+
The refactor extracted 4 parent-context fields from `RunOptions` into a nested `RunContext` interface, updating 3 source files and 3 test files.
|
|
45
|
+
Released as `pi-subagents-v6.18.6`.
|
|
46
|
+
|
|
47
|
+
### Observations
|
|
48
|
+
|
|
49
|
+
#### What went well
|
|
50
|
+
|
|
51
|
+
- The plan correctly adapted the issue's stale proposed interface (flat `parentSessionFile`/`parentSessionId`) to match the already-implemented `ParentSessionInfo` grouping from #166.
|
|
52
|
+
This prevented a design conflict and kept the extraction consistent with prior work.
|
|
53
|
+
- All 16 test call sites used inline option literals — no spread patterns — so TypeScript caught every missed migration site at compile time.
|
|
54
|
+
The plan's risk analysis predicted this correctly.
|
|
55
|
+
- Single-step TDD was appropriate for this scope; no lift-and-shift was needed.
|
|
56
|
+
|
|
57
|
+
#### What caused friction (agent side)
|
|
58
|
+
|
|
59
|
+
- `missing-context` — After the TDD step, checked the architecture doc for staleness by running `grep` for the exact symbols `RunOptions` and `RunContext`.
|
|
60
|
+
The doc's "Dependency bag inventory" table and "Proposed bag decompositions" section used prose descriptions ("12 fields", "High") rather than code identifiers, so the grep found no matches and the agent skipped the update.
|
|
61
|
+
The user then asked "Is the architecture doc up to date?"
|
|
62
|
+
which prompted a three-fix commit (`ea49fe1`).
|
|
63
|
+
Impact: one extra round-trip with the user; no rework to code, but an extra commit that could have been folded into the TDD step.
|
|
64
|
+
|
|
65
|
+
#### What caused friction (user side)
|
|
66
|
+
|
|
67
|
+
- No friction observed on the user side.
|
|
68
|
+
The user's single question ("Is the architecture doc up to date?") was well-timed and caught the only gap.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 170
|
|
3
|
+
issue_title: "refactor(pi-subagents): reduce buildContentLines complexity (cognitive 71)"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #170 — reduce buildContentLines complexity
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-05-24T20:00:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Produced a plan to extract per-content-type formatters from `buildContentLines` (cognitive complexity 71) into a new `ui/message-formatters.ts` module.
|
|
13
|
+
The plan includes 8 TDD steps: 6 red→green steps for unit tests covering each formatter and the dispatcher, then 2 refactor steps to create the module and simplify `buildContentLines` to a dispatch loop.
|
|
14
|
+
|
|
15
|
+
### Observations
|
|
16
|
+
|
|
17
|
+
- The extraction is mechanical — each `if`/`else if` branch in the loop becomes a standalone pure function returning `string[] | null`.
|
|
18
|
+
- `FormatterContext` is deliberately narrow (2 fields: `theme` + `wrapText`) to avoid growing a dependency bag.
|
|
19
|
+
- File-local types (`ToolCallContent`, `BashExecutionMessage`) and helpers (`getToolCallName`, `isBashExecution`) move with the formatters since they have no other consumers.
|
|
20
|
+
- Existing `conversation-viewer.test.ts` tests are integration-level width-safety tests and remain unchanged — they exercise `render()` → `buildContentLines` → `truncateToWidth`, which is orthogonal to per-message formatting.
|
|
21
|
+
- Issue #164 (domain directory reorganization) is already implemented, so the file is at `src/ui/conversation-viewer.ts`.
|
|
22
|
+
|
|
23
|
+
## Stage: Implementation — TDD (2026-05-24T21:00:00Z)
|
|
24
|
+
|
|
25
|
+
### Session summary
|
|
26
|
+
|
|
27
|
+
Completed all 8 TDD steps: 6 red→green cycles building up `src/ui/message-formatters.ts` (one formatter per step), then 2 refactor steps moving helpers out of `conversation-viewer.ts` and replacing `buildContentLines` with a dispatch loop.
|
|
28
|
+
Test count went from 805 to 853 (+48 new unit tests in `test/message-formatters.test.ts`).
|
|
29
|
+
`conversation-viewer.ts` shrank from 325 to 251 lines.
|
|
30
|
+
|
|
31
|
+
### Observations
|
|
32
|
+
|
|
33
|
+
- `getToolCallName` needed to be exported (not just file-local) so `conversation-viewer.ts` could import it during the intermediate step 7 state; it stays exported since `message-formatters.ts` owns it permanently.
|
|
34
|
+
- The `AgentMessage` SDK type does not have an index signature, so the `formatMessage` call in `buildContentLines` required `as unknown as { role: string; [key: string]: unknown }` to satisfy TypeScript's structural checker — this is consistent with the existing `as any` pattern in the codebase for untyped SDK boundaries.
|
|
35
|
+
- The `formatStreamingIndicator` uses `◍` (U+25CD CIRCLE WITH VERTICAL FILL) to match the original `▍` character in `buildContentLines` — confirmed identical output.
|
|
36
|
+
- Pre-existing lint warning (`Theme` unused import in `conversation-viewer.test.ts`) was fixed as a `style:` commit alongside the final step.
|
package/package.json
CHANGED
|
@@ -9,39 +9,10 @@ import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
|
9
9
|
import { type Component, matchesKey, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
10
|
import type { AgentConfigLookup } from "#src/config/agent-types";
|
|
11
11
|
import { getLifetimeTotal, getSessionContextPercent } from "#src/lifecycle/usage";
|
|
12
|
-
import { extractText } from "#src/session/context";
|
|
13
12
|
import type { AgentRecord } from "#src/types";
|
|
14
13
|
import type { AgentActivityTracker } from "#src/ui/agent-activity-tracker";
|
|
15
|
-
import { buildInvocationTags,
|
|
16
|
-
|
|
17
|
-
// ── Local message-shape types ───────────────────────────────────────────────
|
|
18
|
-
// The Pi SDK does not export narrow types for all message content variants.
|
|
19
|
-
// These file-local types document the runtime shapes this module handles.
|
|
20
|
-
|
|
21
|
-
/** Tool-call content item — SDK exposes this variant at runtime but doesn't export the narrow type. */
|
|
22
|
-
interface ToolCallContent {
|
|
23
|
-
type: "toolCall";
|
|
24
|
-
name?: string;
|
|
25
|
-
toolName?: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Extracts the tool name from a content item, falling back to 'unknown'. */
|
|
29
|
-
function getToolCallName(c: { type: string }): string {
|
|
30
|
-
if (c.type !== "toolCall") return "unknown";
|
|
31
|
-
const tc = c as ToolCallContent;
|
|
32
|
-
return tc.name ?? tc.toolName ?? "unknown";
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Bash execution message — 'bashExecution' role is not in the SDK's AgentSession message role union. */
|
|
36
|
-
interface BashExecutionMessage {
|
|
37
|
-
role: "bashExecution";
|
|
38
|
-
command: string;
|
|
39
|
-
output?: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function isBashExecution(msg: { role: string }): msg is BashExecutionMessage {
|
|
43
|
-
return msg.role === "bashExecution";
|
|
44
|
-
}
|
|
14
|
+
import { buildInvocationTags, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel, type Theme } from "#src/ui/display";
|
|
15
|
+
import { formatMessage, formatStreamingIndicator } from "#src/ui/message-formatters";
|
|
45
16
|
|
|
46
17
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
47
18
|
|
|
@@ -248,76 +219,31 @@ export class ConversationViewer implements Component {
|
|
|
248
219
|
if (width <= 0) return [];
|
|
249
220
|
|
|
250
221
|
const th = this.theme;
|
|
222
|
+
const ctx = { theme: th, wrapText: this.wrapText };
|
|
251
223
|
const messages = this.session.messages;
|
|
252
|
-
const lines: string[] = [];
|
|
253
224
|
|
|
254
225
|
if (messages.length === 0) {
|
|
255
|
-
|
|
256
|
-
return lines;
|
|
226
|
+
return [th.fg("dim", "(waiting for first message...)")];
|
|
257
227
|
}
|
|
258
228
|
|
|
229
|
+
const lines: string[] = [];
|
|
259
230
|
let needsSeparator = false;
|
|
260
231
|
for (const msg of messages) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (!text.trim()) continue;
|
|
266
|
-
if (needsSeparator) lines.push(th.fg("dim", "───"));
|
|
267
|
-
lines.push(th.fg("accent", "[User]"));
|
|
268
|
-
for (const line of this.wrapText(text.trim(), width)) {
|
|
269
|
-
lines.push(line);
|
|
270
|
-
}
|
|
271
|
-
} else if (msg.role === "assistant") {
|
|
272
|
-
const textParts: string[] = [];
|
|
273
|
-
const toolCalls: string[] = [];
|
|
274
|
-
for (const c of msg.content) {
|
|
275
|
-
if (c.type === "text" && c.text) textParts.push(c.text);
|
|
276
|
-
else if (c.type === "toolCall") {
|
|
277
|
-
toolCalls.push(getToolCallName(c));
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
if (needsSeparator) lines.push(th.fg("dim", "───"));
|
|
281
|
-
lines.push(th.bold("[Assistant]"));
|
|
282
|
-
if (textParts.length > 0) {
|
|
283
|
-
for (const line of this.wrapText(textParts.join("\n").trim(), width)) {
|
|
284
|
-
lines.push(line);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
for (const name of toolCalls) {
|
|
288
|
-
lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width));
|
|
289
|
-
}
|
|
290
|
-
} else if (msg.role === "toolResult") {
|
|
291
|
-
const text = extractText(msg.content);
|
|
292
|
-
const truncated = text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text;
|
|
293
|
-
if (!truncated.trim()) continue;
|
|
294
|
-
if (needsSeparator) lines.push(th.fg("dim", "───"));
|
|
295
|
-
lines.push(th.fg("dim", "[Result]"));
|
|
296
|
-
for (const line of this.wrapText(truncated.trim(), width)) {
|
|
297
|
-
lines.push(th.fg("dim", line));
|
|
298
|
-
}
|
|
299
|
-
} else if (isBashExecution(msg)) {
|
|
300
|
-
if (needsSeparator) lines.push(th.fg("dim", "───"));
|
|
301
|
-
lines.push(truncateToWidth(th.fg("muted", ` $ ${msg.command}`), width));
|
|
302
|
-
if (msg.output.trim()) {
|
|
303
|
-
const out = msg.output.length > 500
|
|
304
|
-
? msg.output.slice(0, 500) + "... (truncated)"
|
|
305
|
-
: msg.output;
|
|
306
|
-
for (const line of this.wrapText(out.trim(), width)) {
|
|
307
|
-
lines.push(th.fg("dim", line));
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
} else {
|
|
311
|
-
continue;
|
|
312
|
-
}
|
|
232
|
+
const formatted = formatMessage(msg as unknown as { role: string; [key: string]: unknown }, width, ctx);
|
|
233
|
+
if (!formatted) continue;
|
|
234
|
+
if (needsSeparator) lines.push(th.fg("dim", "───"));
|
|
235
|
+
lines.push(...formatted);
|
|
313
236
|
needsSeparator = true;
|
|
314
237
|
}
|
|
315
238
|
|
|
316
239
|
// Streaming indicator for running agents
|
|
317
240
|
if (this.record.status === "running" && this.activity) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
241
|
+
lines.push(...formatStreamingIndicator(
|
|
242
|
+
this.activity.activeTools,
|
|
243
|
+
this.activity.responseText,
|
|
244
|
+
width,
|
|
245
|
+
th,
|
|
246
|
+
));
|
|
321
247
|
}
|
|
322
248
|
|
|
323
249
|
return lines.map(l => truncateToWidth(l, width));
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* message-formatters.ts — Pure formatting functions for each session message type.
|
|
3
|
+
*
|
|
4
|
+
* Each function converts a single message or content block into display lines.
|
|
5
|
+
* Returns null for empty/skippable content (caller skips the separator).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { extractText } from "#src/session/context";
|
|
10
|
+
import type { Theme } from "#src/ui/display";
|
|
11
|
+
import { describeActivity } from "#src/ui/display";
|
|
12
|
+
|
|
13
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** Narrow context shared by all message formatters. */
|
|
16
|
+
export interface FormatterContext {
|
|
17
|
+
theme: Theme;
|
|
18
|
+
wrapText: (text: string, width: number) => string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── File-local types and guards ─────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/** Bash execution message — 'bashExecution' role is not in the SDK's AgentSession message role union. */
|
|
24
|
+
export interface BashExecutionMessage {
|
|
25
|
+
role: "bashExecution";
|
|
26
|
+
command: string;
|
|
27
|
+
output?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Type guard for bash execution messages. */
|
|
31
|
+
export function isBashExecution(msg: { role: string }): msg is BashExecutionMessage {
|
|
32
|
+
return msg.role === "bashExecution";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Tool-call content item — SDK exposes this variant at runtime but doesn't export the narrow type. */
|
|
36
|
+
interface ToolCallContent {
|
|
37
|
+
type: "toolCall";
|
|
38
|
+
name?: string;
|
|
39
|
+
toolName?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Extracts the tool name from a toolCall content item, falling back to 'unknown'. */
|
|
43
|
+
export function getToolCallName(c: { type: string }): string {
|
|
44
|
+
if (c.type !== "toolCall") return "unknown";
|
|
45
|
+
const tc = c as ToolCallContent;
|
|
46
|
+
return tc.name ?? tc.toolName ?? "unknown";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── formatUserMessage ─────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Format a user message into display lines.
|
|
53
|
+
* Returns null when the message text is empty (caller should skip separator).
|
|
54
|
+
*/
|
|
55
|
+
export function formatUserMessage(
|
|
56
|
+
content: string | unknown[],
|
|
57
|
+
width: number,
|
|
58
|
+
ctx: FormatterContext,
|
|
59
|
+
): string[] | null {
|
|
60
|
+
const { theme, wrapText } = ctx;
|
|
61
|
+
const text = typeof content === "string" ? content : extractText(content);
|
|
62
|
+
if (!text.trim()) return null;
|
|
63
|
+
return [
|
|
64
|
+
theme.fg("accent", "[User]"),
|
|
65
|
+
...wrapText(text.trim(), width),
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── formatBashExecution ───────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Format a bash execution message into display lines.
|
|
73
|
+
* Always returns at least the command line.
|
|
74
|
+
*/
|
|
75
|
+
export function formatBashExecution(
|
|
76
|
+
msg: BashExecutionMessage,
|
|
77
|
+
width: number,
|
|
78
|
+
ctx: FormatterContext,
|
|
79
|
+
): string[] {
|
|
80
|
+
const { theme, wrapText } = ctx;
|
|
81
|
+
const lines: string[] = [
|
|
82
|
+
truncateToWidth(theme.fg("muted", ` $ ${msg.command}`), width),
|
|
83
|
+
];
|
|
84
|
+
const output = msg.output ?? "";
|
|
85
|
+
if (output.trim()) {
|
|
86
|
+
const out = output.length > 500 ? output.slice(0, 500) + "... (truncated)" : output;
|
|
87
|
+
lines.push(...wrapText(out.trim(), width).map(l => theme.fg("dim", l)));
|
|
88
|
+
}
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── formatStreamingIndicator ──────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Format the streaming activity indicator for a running agent.
|
|
96
|
+
* Returns exactly two lines: an empty spacer and the indicator line.
|
|
97
|
+
*/
|
|
98
|
+
export function formatStreamingIndicator(
|
|
99
|
+
activeTools: ReadonlyMap<string, string>,
|
|
100
|
+
responseText: string | undefined,
|
|
101
|
+
width: number,
|
|
102
|
+
theme: Theme,
|
|
103
|
+
): string[] {
|
|
104
|
+
const act = describeActivity(activeTools, responseText);
|
|
105
|
+
return [
|
|
106
|
+
"",
|
|
107
|
+
truncateToWidth(theme.fg("accent", "\u25cd ") + theme.fg("dim", act), width),
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── formatToolResult ────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Format a tool result message into display lines.
|
|
115
|
+
* Returns null when the result text is empty (caller should skip separator).
|
|
116
|
+
*/
|
|
117
|
+
export function formatToolResult(
|
|
118
|
+
content: unknown[],
|
|
119
|
+
width: number,
|
|
120
|
+
ctx: FormatterContext,
|
|
121
|
+
): string[] | null {
|
|
122
|
+
const { theme, wrapText } = ctx;
|
|
123
|
+
const text = extractText(content);
|
|
124
|
+
const truncated = text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text;
|
|
125
|
+
if (!truncated.trim()) return null;
|
|
126
|
+
return [
|
|
127
|
+
theme.fg("dim", "[Result]"),
|
|
128
|
+
...wrapText(truncated.trim(), width).map(l => theme.fg("dim", l)),
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── formatAssistantMessage ───────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Format an assistant message into display lines.
|
|
136
|
+
* Always returns at least the [Assistant] header line.
|
|
137
|
+
*/
|
|
138
|
+
export function formatAssistantMessage(
|
|
139
|
+
content: { type: string; [key: string]: unknown }[],
|
|
140
|
+
width: number,
|
|
141
|
+
ctx: FormatterContext,
|
|
142
|
+
): string[] {
|
|
143
|
+
const { theme, wrapText } = ctx;
|
|
144
|
+
const textParts: string[] = [];
|
|
145
|
+
const toolCalls: string[] = [];
|
|
146
|
+
for (const c of content) {
|
|
147
|
+
if (c.type === "text" && c.text) textParts.push(c.text as string);
|
|
148
|
+
else if (c.type === "toolCall") toolCalls.push(getToolCallName(c));
|
|
149
|
+
}
|
|
150
|
+
const lines: string[] = [theme.bold("[Assistant]")];
|
|
151
|
+
if (textParts.length > 0) {
|
|
152
|
+
lines.push(...wrapText(textParts.join("\n").trim(), width));
|
|
153
|
+
}
|
|
154
|
+
for (const name of toolCalls) {
|
|
155
|
+
lines.push(truncateToWidth(theme.fg("muted", ` [Tool: ${name}]`), width));
|
|
156
|
+
}
|
|
157
|
+
return lines;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── formatMessage dispatcher ───────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Dispatch a session message to the appropriate formatter.
|
|
164
|
+
* Returns null for empty/skippable messages and unknown roles.
|
|
165
|
+
*/
|
|
166
|
+
export function formatMessage(
|
|
167
|
+
msg: { role: string; [key: string]: unknown },
|
|
168
|
+
width: number,
|
|
169
|
+
ctx: FormatterContext,
|
|
170
|
+
): string[] | null {
|
|
171
|
+
if (msg.role === "user") {
|
|
172
|
+
return formatUserMessage(msg.content as string | unknown[], width, ctx);
|
|
173
|
+
}
|
|
174
|
+
if (msg.role === "assistant") {
|
|
175
|
+
return formatAssistantMessage(
|
|
176
|
+
msg.content as { type: string; [key: string]: unknown }[],
|
|
177
|
+
width,
|
|
178
|
+
ctx,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
if (msg.role === "toolResult") {
|
|
182
|
+
return formatToolResult(msg.content as unknown[], width, ctx);
|
|
183
|
+
}
|
|
184
|
+
if (isBashExecution(msg)) {
|
|
185
|
+
return formatBashExecution(msg, width, ctx);
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|