@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 +23 -0
- package/docs/architecture/architecture.md +18 -17
- package/docs/plans/0169-extract-run-context-from-run-options.md +194 -0
- package/docs/plans/0170-reduce-build-content-lines-complexity.md +225 -0
- package/docs/retro/0168-extract-tool-filter-config.md +37 -0
- package/docs/retro/0169-extract-run-context-from-run-options.md +68 -0
- package/docs/retro/0170-reduce-build-content-lines-complexity.md +36 -0
- package/package.json +1 -1
- package/src/lifecycle/agent-manager.ts +6 -4
- package/src/lifecycle/agent-runner.ts +24 -13
- package/src/runtime.ts +1 -1
- package/src/session/session-config.ts +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,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
|
|
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 |
|
|
@@ -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` |
|
|
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
|
|
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
|
-
|
|
636
|
-
|
|
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
|
-
|
|
641
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|