@gotgenes/pi-subagents 6.18.5 → 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 CHANGED
@@ -5,6 +5,29 @@ 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
+
19
+ ## [6.18.6](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.18.5...pi-subagents-v6.18.6) (2026-05-24)
20
+
21
+
22
+ ### Documentation
23
+
24
+ * plan extract RunContext from RunOptions ([#169](https://github.com/gotgenes/pi-packages/issues/169)) ([ca12b2e](https://github.com/gotgenes/pi-packages/commit/ca12b2ebd116cb50c6e12d2d3fe3a87ff997d6d6))
25
+ * **retro:** add planning stage notes for issue [#169](https://github.com/gotgenes/pi-packages/issues/169) ([05b0176](https://github.com/gotgenes/pi-packages/commit/05b01764a4aaa885efca9d061abf6bacb384057c))
26
+ * **retro:** add retro notes for issue [#168](https://github.com/gotgenes/pi-packages/issues/168) ([dfe46ed](https://github.com/gotgenes/pi-packages/commit/dfe46ed5b5ee62371912dbbc6227443adee8e67b))
27
+ * **retro:** add TDD stage notes for issue [#169](https://github.com/gotgenes/pi-packages/issues/169) ([84c0d8d](https://github.com/gotgenes/pi-packages/commit/84c0d8d5ef0a94750c470e3f10b3ab589caac794))
28
+ * update architecture doc for RunContext extraction ([#169](https://github.com/gotgenes/pi-packages/issues/169)) ([ea49fe1](https://github.com/gotgenes/pi-packages/commit/ea49fe1b9c316e6541814de821738d6d121c4d13))
29
+ * update RunOptions field references in comments ([#169](https://github.com/gotgenes/pi-packages/issues/169)) ([fd9c3ed](https://github.com/gotgenes/pi-packages/commit/fd9c3ed0e0b01c45136c8f34d8f31a89564e8061))
30
+
8
31
  ## [6.18.5](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.18.4...pi-subagents-v6.18.5) (2026-05-24)
9
32
 
10
33
 
@@ -220,7 +220,7 @@ sequenceDiagram
220
220
 
221
221
  ## Module organization
222
222
 
223
- The extension has 51 source files (7,338 LOC) organized into six domains plus entry-point wiring.
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,288 (53 files) |
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 |
@@ -446,7 +447,7 @@ Bags with 10+ fields are the highest priority for decomposition.
446
447
  | --------------------------- | ------------------------------------------------------ | ------------------------------------------------- | -------- |
447
448
  | `ResolvedSpawnConfig` | 3 nested | foreground-runner, background-spawner, agent-tool | ✓ done |
448
449
  | `AgentSpawnConfig` | 13 → 13 (ParentSessionInfo nested) | agent-manager (internal) | ✓ done |
449
- | `RunOptions` | 12 | agent-runner | High |
450
+ | `RunOptions` | 9 (`RunContext` nested) | agent-runner | ✓ done |
450
451
  | `SessionConfig` | 8 (ToolFilterConfig nested) | agent-runner (output of assembler) | ✓ done |
451
452
  | `NotificationDetails` | 10 | notification | Medium |
452
453
  | `ResourceLoaderOptions` | 10 | agent-runner (SDK bridge) | Medium |
@@ -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 |
@@ -543,22 +543,23 @@ export interface ParentSessionInfo {
543
543
 
544
544
  `AgentSpawnConfig` now carries `parentSession?: ParentSessionInfo` instead of three flat optional fields.
545
545
 
546
- #### RunOptions (12 fields → extract RunContext)
546
+ #### RunOptions (12 fields → extract RunContext) — done ([#169][169])
547
547
 
548
- The `RunOptions` bag mixes execution parameters with context information:
548
+ The `RunOptions` bag mixes execution parameters with context information.
549
+ `RunContext` was extracted and nested as `RunOptions.context`:
549
550
 
550
551
  ```typescript
551
- /** Parent context needed to configure the child session. */
552
- interface RunContext {
553
- cwd?: string;
554
- parentSessionFile?: string;
555
- parentSessionId?: string;
552
+ /** Parent execution context where/who is running. */
553
+ export interface RunContext {
556
554
  exec: ShellExec;
557
555
  registry: AgentConfigLookup;
556
+ cwd?: string;
557
+ parentSession?: ParentSessionInfo;
558
558
  }
559
559
  ```
560
560
 
561
561
  The remaining `RunOptions` fields (`model`, `maxTurns`, `signal`, `isolated`, `thinkingLevel`, `defaultMaxTurns`, `graceTurns`, `onSessionCreated`) are genuine execution parameters.
562
+ `RunOptions` now has 9 fields: 1 nested `context: RunContext` plus 8 flat execution fields.
562
563
 
563
564
  #### SessionConfig (11 fields → extract ToolFilterConfig) — done ([#168][168])
564
565
 
@@ -630,15 +631,15 @@ All existing consumers satisfy both sub-interfaces via structural typing with no
630
631
  `filterActiveTools` now accepts a single `ToolFilterConfig` argument.
631
632
  `SessionConfig` reduced from 10 to 8 top-level fields.
632
633
 
633
- ### Step 6: Extract RunContext from RunOptions ([#169][169])
634
+ ### Step 6: Extract RunContext from RunOptions ([#169][169]) ✓ Done
634
635
 
635
- Extract context fields into `RunContext`.
636
- Reduces RunOptions from 12 to 7 fields.
636
+ Extracted `exec`, `registry`, `cwd`, and `parentSession` into `RunContext`, nested as `RunOptions.context`.
637
+ `RunOptions` reduced from 12 to 9 fields (1 nested `context` + 8 flat execution fields).
637
638
 
638
- ### Step 7: Reduce buildContentLines complexity ([#170][170])
639
+ ### Step 7: Reduce buildContentLines complexity ([#170][170]) ✓ Done
639
640
 
640
- `buildContentLines` in `conversation-viewer.ts` has cognitive complexity 71.
641
- Extract formatting sub-functions for each content type (tool calls, text, bash output).
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`.
642
643
 
643
644
  ### Step 8: Reduce renderResult complexity ([#171][171])
644
645
 
@@ -0,0 +1,194 @@
1
+ ---
2
+ issue: 169
3
+ issue_title: "refactor(pi-subagents): extract RunContext from RunOptions (12 fields)"
4
+ ---
5
+
6
+ # Extract RunContext from RunOptions
7
+
8
+ ## Problem Statement
9
+
10
+ `RunOptions` in `agent-runner.ts` has 12 fields mixing two distinct concerns: parent execution context ("where/who is running") and per-call execution parameters ("how to run").
11
+ Extracting the context cluster into a named `RunContext` interface makes the separation explicit and reduces the flat field count from 12 to 9 (8 execution fields + 1 nested context).
12
+
13
+ ## Goals
14
+
15
+ - Define a `RunContext` interface grouping the 4 parent-context fields: `exec`, `registry`, `cwd`, and `parentSession`.
16
+ - Nest `RunContext` inside `RunOptions` as `context: RunContext`, replacing the 4 flat fields.
17
+ - Update `runAgent()` to read context fields from `options.context.*`.
18
+ - Update `AgentManager.startAgent()` to construct the nested `context` object when building `RunOptions`.
19
+ - Update all test files that construct or assert on `RunOptions` fields.
20
+ - Non-breaking refactor — `RunOptions` is not part of the public API (`service.ts` export boundary).
21
+
22
+ ## Non-Goals
23
+
24
+ - Changing the `AgentRunner` interface signature — `run()` keeps its 4 positional parameters; `RunContext` is nested inside `RunOptions`, not a separate parameter.
25
+ - Extracting `RunContext` into its own file — the interface is small (4 fields) and co-located with its consumer (`runAgent`).
26
+ - Further splitting the remaining 8 execution fields — they form a coherent "how to run" cluster.
27
+ - Hoisting `RunContext` construction to `AgentManager` instance level — two of the four fields (`cwd`, `parentSession`) vary per spawn, so a per-spawn construction is appropriate.
28
+
29
+ ## Background
30
+
31
+ Issue #164 (closed) reorganized source into domain directories; the runner now lives at `src/lifecycle/agent-runner.ts`.
32
+ Issue #166 (closed) extracted `ParentSessionInfo` and nested it inside `RunOptions.parentSession`.
33
+ Issue #167 (closed) split `RunnerIO` into `EnvironmentIO` and `SessionFactoryIO`.
34
+ Issue #168 (closed) extracted `ToolFilterConfig` from `SessionConfig`.
35
+
36
+ This issue continues the structural improvement by separating the two concerns mixed in `RunOptions`.
37
+
38
+ ### Field analysis
39
+
40
+ | Field | Concern | Usage in `runAgent()` |
41
+ | ------------------ | --------- | ------------------------------------------ |
42
+ | `exec` | Context | `io.detectEnv(options.exec, effectiveCwd)` |
43
+ | `registry` | Context | Passed to `assembleSessionConfig` |
44
+ | `cwd` | Context | Override working directory (worktree) |
45
+ | `parentSession` | Context | Session dir derivation + session linking |
46
+ | `model` | Execution | Per-call model override |
47
+ | `maxTurns` | Execution | Turn limit |
48
+ | `signal` | Execution | Abort forwarding |
49
+ | `isolated` | Execution | Extension isolation flag |
50
+ | `thinkingLevel` | Execution | Thinking level override |
51
+ | `onSessionCreated` | Execution | Session delivery callback |
52
+ | `defaultMaxTurns` | Execution | Fallback turn limit from runtime config |
53
+ | `graceTurns` | Execution | Grace window after soft limit |
54
+
55
+ ### Consumer analysis
56
+
57
+ `AgentManager.startAgent()` is the sole constructor of `RunOptions`.
58
+ The context fields come from two sources:
59
+
60
+ - Manager instance fields: `this.exec`, `this.registry`
61
+ - Per-spawn values: `worktreeCwd` (computed locally), `options.parentSession` (from `AgentSpawnConfig`)
62
+
63
+ ## Design Overview
64
+
65
+ ### `RunContext` interface
66
+
67
+ ```typescript
68
+ export interface RunContext {
69
+ /** Shell-exec callback for detectEnv — injected from pi.exec(). */
70
+ exec: ShellExec;
71
+ /** Agent config lookup — provides resolveAgentConfig and getToolNamesForType. */
72
+ registry: AgentConfigLookup;
73
+ /** Override working directory (e.g. for worktree isolation). */
74
+ cwd?: string;
75
+ /** Parent session identity (file path + session ID). */
76
+ parentSession?: ParentSessionInfo;
77
+ }
78
+ ```
79
+
80
+ ### Updated `RunOptions`
81
+
82
+ ```typescript
83
+ export interface RunOptions {
84
+ /** Parent execution context — where/who is running. */
85
+ context: RunContext;
86
+ model?: Model<any>;
87
+ maxTurns?: number;
88
+ signal?: AbortSignal;
89
+ isolated?: boolean;
90
+ thinkingLevel?: ThinkingLevel;
91
+ onSessionCreated?: (session: AgentSession) => void;
92
+ defaultMaxTurns?: number;
93
+ graceTurns?: number;
94
+ }
95
+ ```
96
+
97
+ ### Call-site sketch — `AgentManager.startAgent`
98
+
99
+ ```typescript
100
+ const promise = this.runner.run(snapshot, type, prompt, {
101
+ context: {
102
+ exec: this.exec,
103
+ registry: this.registry,
104
+ cwd: worktreeCwd,
105
+ parentSession: options.parentSession,
106
+ },
107
+ model: options.model,
108
+ maxTurns: options.maxTurns,
109
+ // ... remaining execution fields
110
+ });
111
+ ```
112
+
113
+ ### Access pattern in `runAgent`
114
+
115
+ ```typescript
116
+ const effectiveCwd = options.context.cwd ?? snapshot.cwd;
117
+ const env = await io.detectEnv(options.context.exec, effectiveCwd);
118
+ // ...
119
+ const sessionDir = io.deriveSessionDir(
120
+ options.context.parentSession?.parentSessionFile,
121
+ cfg.effectiveCwd,
122
+ );
123
+ ```
124
+
125
+ ## Module-Level Changes
126
+
127
+ ### `src/lifecycle/agent-runner.ts`
128
+
129
+ 1. Add `RunContext` interface (4 fields, exported) before `RunOptions`.
130
+ 2. Replace the 4 flat context fields on `RunOptions` with `context: RunContext`.
131
+ 3. Update all `options.*` reads in `runAgent()`:
132
+ - `options.exec` → `options.context.exec`
133
+ - `options.cwd` → `options.context.cwd`
134
+ - `options.parentSession` → `options.context.parentSession`
135
+ - `options.registry` → `options.context.registry`
136
+ 4. Move JSDoc from the removed flat fields to `RunContext` interface members.
137
+ 5. Export `RunContext`.
138
+
139
+ ### `src/lifecycle/agent-manager.ts`
140
+
141
+ 1. Update the `RunOptions` object literal in `startAgent()` to nest the four context fields under `context: { ... }`.
142
+ 2. No import changes needed — `RunOptions` is consumed via the `AgentRunner` interface, not imported directly.
143
+
144
+ ### No changes needed
145
+
146
+ - `src/lifecycle/agent-runner.ts` — `AgentRunner` interface signature unchanged (`options: RunOptions`).
147
+ - `src/lifecycle/agent-runner.ts` — `createAgentRunner()` unchanged.
148
+ - `src/index.ts` — no changes (doesn't import `RunOptions`).
149
+ - `src/runtime.ts` — comment-only reference to `RunOptions`; update comment if desired.
150
+ - `src/session/session-config.ts` — comment-only reference; update comment if desired.
151
+
152
+ ## Test Impact Analysis
153
+
154
+ ### New unit tests enabled
155
+
156
+ The extraction does not enable new test surfaces — `RunContext` is a plain data carrier with no behavior.
157
+ A type-check verification (`pnpm run check`) confirms the structural compatibility.
158
+
159
+ ### Existing tests that need updates
160
+
161
+ 1. `test/lifecycle/agent-runner.test.ts` — 9 `runAgent()` call sites: wrap `exec`, `registry`, `cwd`, and `parentSession` fields in `context: { ... }`.
162
+ 2. `test/lifecycle/agent-runner-extension-tools.test.ts` — 7 `runAgent()` call sites: same wrapping.
163
+ 3. `test/lifecycle/agent-manager.test.ts` — 3 assertion sites: update `runOpts.parentSession` → `runOpts.context.parentSession`, `runOpts.defaultMaxTurns` stays flat (execution field).
164
+
165
+ ### Tests that stay as-is
166
+
167
+ - `test/lifecycle/agent-runner-settings.test.ts` — tests `normalizeMaxTurns` (pure function, no `RunOptions` involvement).
168
+ - All other test files — no `RunOptions` construction or assertion.
169
+
170
+ ## TDD Order
171
+
172
+ 1. **Define `RunContext` and update `RunOptions`** — add `RunContext` interface, replace 4 flat fields with `context: RunContext` on `RunOptions`.
173
+ Update all `options.*` reads in `runAgent()` to `options.context.*`.
174
+ Update `agent-manager.ts` `startAgent()` to construct nested context.
175
+ Update `agent-runner.test.ts` (9 call sites) and `agent-runner-extension-tools.test.ts` (7 call sites) to nest context fields.
176
+ Update `agent-manager.test.ts` assertions (3 sites) to read from `runOpts.context.*`.
177
+ Run `pnpm run check` and `pnpm vitest run` to verify.
178
+ Commit: `refactor: extract RunContext from RunOptions (#169)`
179
+
180
+ 2. **Update comments** — update comment references in `runtime.ts` and `session-config.ts` that mention `RunOptions` field names.
181
+ Commit: `docs: update RunOptions field references in comments (#169)`
182
+
183
+ ## Risks and Mitigations
184
+
185
+ | Risk | Mitigation |
186
+ | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
187
+ | Test factories using spread patterns lose context fields silently | No test factory returns `Partial<RunOptions>` — all call sites construct the options inline, so TypeScript will reject missing `context` |
188
+ | `agent-manager.test.ts` assertions on execution fields break | Only context-field assertions change; execution-field assertions (`defaultMaxTurns`, `graceTurns`) remain on `runOpts.*` |
189
+ | Nested access adds verbosity to `runAgent()` | Only 6 access sites gain the `.context` prefix; readability trade-off is minimal for the structural clarity gained |
190
+
191
+ ## Open Questions
192
+
193
+ None — the extraction follows the natural "where/who vs. how" seam identified in the issue body.
194
+ The issue's proposed flat `parentSessionFile`/`parentSessionId` fields have been superseded by the already-implemented `parentSession?: ParentSessionInfo` grouping from #166.
@@ -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.
@@ -37,3 +37,40 @@ All 805 tests continue to pass; no new tests were added (pure structural refacto
37
37
  - All 9 flat-field assertions in `session-config.test.ts` (`result.toolNames`, `result.extensions`, `result.disallowedSet`) were correctly migrated to `result.toolFilter.*` — grep confirmed no stragglers.
38
38
  - `agent-runner-extension-tools.test.ts` required zero changes, confirming its role as a regression canary.
39
39
  - Architecture doc updated: `SessionConfig` row in the wide-interface table marked `✓ done`; Step 5 narrative updated to reflect actual field count (10 → 8, not 11 → 8 as the issue stated).
40
+
41
+ ## Stage: Final Retrospective (2026-05-24T20:00:00Z)
42
+
43
+ ### Session summary
44
+
45
+ Issue #168 completed across three sessions (Planning → TDD → Ship) with zero friction, rework, or plan deviations.
46
+ Total diff: 3 files changed, 37 insertions, 41 deletions (net reduction).
47
+ Released as `pi-subagents-v6.18.5`.
48
+
49
+ ### Observations
50
+
51
+ #### What went well
52
+
53
+ - **Grep-before-commit safety net.**
54
+ The planning session identified 9 flat-field assertions in `session-config.test.ts` that would silently pass as `undefined` if missed during migration.
55
+ The TDD session grepped for all three field names before committing step 1, catching all 9 in one pass.
56
+ This is the testing skill’s "grep for all test files" rule applied to assertion migration.
57
+ - **Regression canary identification during planning.**
58
+ The planning session called out `agent-runner-extension-tools.test.ts` as a zero-change regression canary.
59
+ The TDD session confirmed this prediction — no changes needed, all existing tests green.
60
+ Identifying canary tests during planning gave confidence that the two refactoring steps were correctly scoped.
61
+ - **2-step granularity was right.**
62
+ Step 1 (interface + assembler + tests) left intentional type errors in the consumer.
63
+ Step 2 (consumer update) resolved them.
64
+ This kept each commit reviewable and type-check-green at the session-config boundary.
65
+
66
+ #### What caused friction (agent side)
67
+
68
+ None.
69
+
70
+ #### What caused friction (user side)
71
+
72
+ None.
73
+
74
+ ### Changes made
75
+
76
+ No process changes — clean execution with no proposals warranted.
@@ -0,0 +1,68 @@
1
+ ---
2
+ issue: 169
3
+ issue_title: "refactor(pi-subagents): extract RunContext from RunOptions (12 fields)"
4
+ ---
5
+
6
+ # Retro: #169 — extract RunContext from RunOptions
7
+
8
+ ## Stage: Planning (2026-05-24T17:07:10Z)
9
+
10
+ ### Session summary
11
+
12
+ Produced a plan to extract 4 parent-context fields (`exec`, `registry`, `cwd`, `parentSession`) from `RunOptions` into a nested `RunContext` interface.
13
+ The plan is a single-step refactor (all changes in one commit) plus a comment-update commit, affecting 3 source files and 3 test files.
14
+
15
+ ### Observations
16
+
17
+ - The issue body proposed flat `parentSessionFile`/`parentSessionId` fields on `RunContext`, but #166 already grouped these into `ParentSessionInfo`.
18
+ The plan uses `parentSession?: ParentSessionInfo` instead, preserving the existing grouping.
19
+ - `RunOptions` is purely internal — not exported via `service.ts` — so the refactor is non-breaking.
20
+ - All test call sites construct `RunOptions` inline (no `Partial<RunOptions>` spread patterns), so TypeScript will catch any missing `context` field at compile time.
21
+ - The change is small enough to land in a single TDD step — no lift-and-shift needed.
22
+ - Prerequisite #164 (directory reorganization) is already implemented.
23
+
24
+ ## Stage: Implementation — TDD (2026-05-24T17:14:32Z)
25
+
26
+ ### Session summary
27
+
28
+ Completed both TDD steps in one session.
29
+ Step 1 defined `RunContext`, updated `RunOptions`, migrated `runAgent()` reads to `options.context.*`, restructured `AgentManager.startAgent()`, and updated all 16 test call sites across 3 test files.
30
+ Step 2 updated comment references in `runtime.ts` and `session-config.ts`.
31
+ Test count unchanged (50 files, 805 tests — pure refactor with no behavior change).
32
+
33
+ ### Observations
34
+
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
+ - 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
+ - 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "6.18.5",
3
+ "version": "6.18.7",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/service.ts"
@@ -225,17 +225,19 @@ export class AgentManager {
225
225
 
226
226
  const runConfig = this.getRunConfig?.();
227
227
  const promise = this.runner.run(snapshot, type, prompt, {
228
- exec: this.exec,
228
+ context: {
229
+ exec: this.exec,
230
+ registry: this.registry,
231
+ cwd: worktreeCwd,
232
+ parentSession: options.parentSession,
233
+ },
229
234
  model: options.model,
230
235
  maxTurns: options.maxTurns,
231
236
  defaultMaxTurns: runConfig?.defaultMaxTurns,
232
237
  graceTurns: runConfig?.graceTurns,
233
238
  isolated: options.isolated,
234
239
  thinkingLevel: options.thinkingLevel,
235
- cwd: worktreeCwd,
236
- parentSession: options.parentSession,
237
240
  signal: record.abortController!.signal,
238
- registry: this.registry,
239
241
  onSessionCreated: (session) => {
240
242
  // Capture the session file path early so it's available for display
241
243
  // before the run completes (e.g. in background agent status messages).
@@ -152,18 +152,31 @@ export type RunnerIO = EnvironmentIO & SessionFactoryIO;
152
152
 
153
153
  // ── Public interfaces ─────────────────────────────────────────────────────────
154
154
 
155
- export interface RunOptions {
155
+ /**
156
+ * Parent execution context — where/who is running.
157
+ *
158
+ * Groups the four fields that describe the parent environment and identity,
159
+ * separating them from the per-call execution parameters in RunOptions.
160
+ */
161
+ export interface RunContext {
156
162
  /** Shell-exec callback for detectEnv — injected from pi.exec(). */
157
163
  exec: ShellExec;
164
+ /** Agent config lookup — provides resolveAgentConfig and getToolNamesForType. */
165
+ registry: AgentConfigLookup;
166
+ /** Override working directory (e.g. for worktree isolation). */
167
+ cwd?: string;
168
+ /** Parent session identity (file path + session ID). */
169
+ parentSession?: ParentSessionInfo;
170
+ }
171
+
172
+ export interface RunOptions {
173
+ /** Parent execution context — where/who is running. */
174
+ context: RunContext;
158
175
  model?: Model<any>;
159
176
  maxTurns?: number;
160
177
  signal?: AbortSignal;
161
178
  isolated?: boolean;
162
179
  thinkingLevel?: ThinkingLevel;
163
- /** Override working directory (e.g. for worktree isolation). */
164
- cwd?: string;
165
- /** Parent session identity (file path + session ID). */
166
- parentSession?: ParentSessionInfo;
167
180
  /** Called once after session creation — session delivery mechanism. */
168
181
  onSessionCreated?: (session: AgentSession) => void;
169
182
  /**
@@ -177,8 +190,6 @@ export interface RunOptions {
177
190
  * module-scope `graceTurns` during migration.
178
191
  */
179
192
  graceTurns?: number;
180
- /** Agent config lookup — provides resolveAgentConfig and getToolNamesForType. */
181
- registry: AgentConfigLookup;
182
193
  }
183
194
 
184
195
  export interface RunResult {
@@ -275,8 +286,8 @@ export async function runAgent(
275
286
  io: RunnerIO,
276
287
  ): Promise<RunResult> {
277
288
  // Resolve working directory upfront — needed for detectEnv before assembly.
278
- const effectiveCwd = options.cwd ?? snapshot.cwd;
279
- const env = await io.detectEnv(options.exec, effectiveCwd);
289
+ const effectiveCwd = options.context.cwd ?? snapshot.cwd;
290
+ const env = await io.detectEnv(options.context.exec, effectiveCwd);
280
291
 
281
292
  // Assemble session configuration (synchronous, no SDK objects).
282
293
  const cfg = assembleSessionConfig(
@@ -288,13 +299,13 @@ export async function runAgent(
288
299
  modelRegistry: snapshot.modelRegistry,
289
300
  },
290
301
  {
291
- cwd: options.cwd,
302
+ cwd: options.context.cwd,
292
303
  isolated: options.isolated,
293
304
  model: options.model,
294
305
  thinkingLevel: options.thinkingLevel,
295
306
  },
296
307
  env,
297
- options.registry,
308
+ options.context.registry,
298
309
  io.assemblerIO,
299
310
  );
300
311
 
@@ -322,9 +333,9 @@ export async function runAgent(
322
333
  // Create a persisted SessionManager so transcripts are written in Pi's
323
334
  // official JSONL format. Falls back to a temp directory when the parent
324
335
  // session is not persisted (e.g. headless/API mode).
325
- const sessionDir = io.deriveSessionDir(options.parentSession?.parentSessionFile, cfg.effectiveCwd);
336
+ const sessionDir = io.deriveSessionDir(options.context.parentSession?.parentSessionFile, cfg.effectiveCwd);
326
337
  const sessionManager = io.createSessionManager(cfg.effectiveCwd, sessionDir);
327
- sessionManager.newSession({ parentSession: options.parentSession?.parentSessionId });
338
+ sessionManager.newSession({ parentSession: options.context.parentSession?.parentSessionId });
328
339
 
329
340
  const { session } = await io.createSession({
330
341
  cwd: cfg.effectiveCwd,
package/src/runtime.ts CHANGED
@@ -22,7 +22,7 @@ export interface WidgetLike {
22
22
  }
23
23
 
24
24
  /**
25
- * Narrow config subset read by AgentManager when constructing RunOptions.
25
+ * Narrow config subset read by AgentManager when constructing RunOptions execution fields.
26
26
  * Kept separate so callers can satisfy it without depending on the full runtime.
27
27
  */
28
28
  export interface RunConfig {
@@ -91,7 +91,7 @@ export interface AssemblerContext {
91
91
  }
92
92
 
93
93
  /**
94
- * Narrow slice of RunOptions consumed by the assembler.
94
+ * Narrow slice of RunOptions execution fields consumed by the assembler.
95
95
  * All fields are optional — callers pass only what they have.
96
96
  */
97
97
  export interface AssemblerOptions {
@@ -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, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel, type Theme } from "#src/ui/display";
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
- lines.push(th.fg("dim", "(waiting for first message...)"));
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
- if (msg.role === "user") {
262
- const text = typeof msg.content === "string"
263
- ? msg.content
264
- : extractText(msg.content);
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
- const act = describeActivity(this.activity.activeTools, this.activity.responseText);
319
- lines.push("");
320
- lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
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
+ }