@tintinweb/pi-subagents 0.3.0 → 0.4.0

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,27 @@ 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
+ ## [0.4.0] - 2026-03-11
9
+
10
+ ### Added
11
+ - **XML-delimited prompt sections** — append-mode agents now wrap inherited content in `<inherited_system_prompt>`, `<sub_agent_context>`, and `<agent_instructions>` XML tags, giving the model explicit structure to distinguish inherited rules from sub-agent-specific instructions. Replace mode is unchanged.
12
+ - **Token count in agent results** — foreground agent results, background completion notifications, and `get_subagent_result` now include the token count alongside tool uses and duration (e.g. `Agent completed in 4.2s (12 tool uses, 33.8k token)`).
13
+ - **Widget overflow cap** — the running agents widget now caps at 12 lines. When exceeded, running agents are prioritized over finished ones and an overflow summary line shows hidden counts (e.g. `+3 more (1 running, 2 finished)`).
14
+
15
+ ### Changed - **changing behavior**
16
+ - **General-purpose agent inherits parent prompt** — the default `general-purpose` agent now uses `promptMode: "append"` with an empty system prompt, making it a "parent twin" that inherits the full parent system prompt (including CLAUDE.md rules, project conventions, and safety guardrails). Previously it used a standalone prompt that duplicated a subset of the parent's rules. Explore and Plan are unchanged (standalone prompts). To customize: eject via `/agents` → select `general-purpose` → Eject, then edit the resulting `.md` file. Set `prompt_mode: replace` to go back to a standalone prompt, or keep `prompt_mode: append` and add extra instructions in the body.
17
+ - **Append-mode agents receive parent system prompt** — `buildAgentPrompt` now accepts the parent's system prompt and threads it into append-mode agents (env header + parent prompt + sub-agent context bridge + optional custom instructions). Replace-mode agents are unchanged.
18
+ - **Prompt pipeline simplified** — removed `systemPromptOverride`/`systemPromptAppend` from `SpawnOptions` and `RunOptions`. These were a separate code path where `index.ts` pre-resolved the prompt mode and passed raw strings into the runner, bypassing `buildAgentPrompt`. Now all prompt assembly flows through `buildAgentPrompt` using the agent's `promptMode` config — one code path, no special cases.
19
+
20
+ ### Removed
21
+ - Deprecated backwards-compat aliases: `registerCustomAgents`, `getCustomAgentConfig`, `getCustomAgentNames` (use `registerAgents`, `getAgentConfig`, `getUserAgentNames`).
22
+ - `resolveCustomPrompt()` helper in index.ts — no longer needed now that prompt routing is config-driven.
23
+
24
+ ## [0.3.1] - 2026-03-09
25
+
26
+ ### Added
27
+ - **Live conversation viewer** — selecting a running (or completed) agent in `/agents` → "Running agents" now opens a scrollable overlay showing the agent's full conversation in real time. Auto-scrolls to follow new content; scroll up to pause, End to resume. Press Esc to close.
28
+
8
29
  ## [0.3.0] - 2026-03-08
9
30
 
10
31
  ### Added
@@ -134,7 +155,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
134
155
  ### Added
135
156
  - **Claude Code-style UI rendering** — `renderCall`/`renderResult`/`onUpdate` for live streaming progress
136
157
  - Live activity descriptions: "searching, reading 3 files…"
137
- - Token count display: "33.8k tokens"
158
+ - Token count display: "33.8k token"
138
159
  - Per-agent tool use counter
139
160
  - Expandable completed results (ctrl+o)
140
161
  - Distinct states: running, background, completed, error, aborted
@@ -167,6 +188,8 @@ Initial release.
167
188
  - **Thinking level** — per-agent extended thinking control
168
189
  - **`/agent` and `/agents` commands**
169
190
 
191
+ [0.4.0]: https://github.com/tintinweb/pi-subagents/compare/v0.3.1...v0.4.0
192
+ [0.3.1]: https://github.com/tintinweb/pi-subagents/compare/v0.3.0...v0.3.1
170
193
  [0.3.0]: https://github.com/tintinweb/pi-subagents/compare/v0.2.7...v0.3.0
171
194
  [0.2.7]: https://github.com/tintinweb/pi-subagents/compare/v0.2.6...v0.2.7
172
195
  [0.2.6]: https://github.com/tintinweb/pi-subagents/compare/v0.2.5...v0.2.6
package/README.md CHANGED
@@ -7,7 +7,7 @@ A [pi](https://pi.dev) extension that brings **Claude Code-style autonomous sub-
7
7
  <img width="600" alt="pi-subagents screenshot" src="https://github.com/tintinweb/pi-subagents/raw/master/media/screenshot.png" />
8
8
 
9
9
 
10
- https://github.com/user-attachments/assets/5d1331e8-6d02-420b-b30a-dcbf838b1660
10
+ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
11
11
 
12
12
 
13
13
  ## Features
@@ -15,6 +15,7 @@ https://github.com/user-attachments/assets/5d1331e8-6d02-420b-b30a-dcbf838b1660
15
15
  - **Claude Code look & feel** — same tool names, calling conventions, and UI patterns (`Agent`, `get_subagent_result`, `steer_subagent`) — feels native
16
16
  - **Parallel background agents** — spawn multiple agents that run concurrently with automatic queuing (configurable concurrency limit, default 4) and smart group join (consolidated notifications)
17
17
  - **Live widget UI** — persistent above-editor widget with animated spinners, live tool activity, token counts, and colored status icons
18
+ - **Conversation viewer** — select any agent in `/agents` to open a live-scrolling overlay of its full conversation (auto-follows new content, scroll up to pause)
18
19
  - **Custom agent types** — define agents in `.pi/agents/<name>.md` with YAML frontmatter: custom system prompts, model selection, thinking levels, tool restrictions
19
20
  - **Mid-run steering** — inject messages into running agents to redirect their work without restarting
20
21
  - **Session resume** — pick up where an agent left off, preserving full conversation context
@@ -56,9 +57,9 @@ The extension renders a persistent widget above the editor showing all active ag
56
57
 
57
58
  ```
58
59
  ● Agents
59
- ├─ ⠹ Agent Refactor auth module · 5 tool uses · 33.8k tokens · 12.3s
60
+ ├─ ⠹ Agent Refactor auth module · 5 tool uses · 33.8k token · 12.3s
60
61
  │ ⎿ editing 2 files…
61
- ├─ ⠹ Explore Find auth files · 3 tool uses · 12.4k tokens · 4.1s
62
+ ├─ ⠹ Explore Find auth files · 3 tool uses · 12.4k token · 4.1s
62
63
  │ ⎿ searching…
63
64
  └─ 2 queued
64
65
  ```
@@ -67,24 +68,26 @@ Individual agent results render Claude Code-style in the conversation:
67
68
 
68
69
  | State | Example |
69
70
  |-------|---------|
70
- | **Running** | `⠹ 3 tool uses · 12.4k tokens` / `⎿ searching, reading 3 files…` |
71
- | **Completed** | `✓ 5 tool uses · 33.8k tokens · 12.3s` / `⎿ Done` |
72
- | **Wrapped up** | `✓ 50 tool uses · 89.1k tokens · 45.2s` / `⎿ Wrapped up (turn limit)` |
73
- | **Stopped** | `■ 3 tool uses · 12.4k tokens` / `⎿ Stopped` |
74
- | **Error** | `✗ 3 tool uses · 12.4k tokens` / `⎿ Error: timeout` |
75
- | **Aborted** | `✗ 55 tool uses · 102.3k tokens` / `⎿ Aborted (max turns exceeded)` |
71
+ | **Running** | `⠹ 3 tool uses · 12.4k token` / `⎿ searching, reading 3 files…` |
72
+ | **Completed** | `✓ 5 tool uses · 33.8k token · 12.3s` / `⎿ Done` |
73
+ | **Wrapped up** | `✓ 50 tool uses · 89.1k token · 45.2s` / `⎿ Wrapped up (turn limit)` |
74
+ | **Stopped** | `■ 3 tool uses · 12.4k token` / `⎿ Stopped` |
75
+ | **Error** | `✗ 3 tool uses · 12.4k token` / `⎿ Error: timeout` |
76
+ | **Aborted** | `✗ 55 tool uses · 102.3k token` / `⎿ Aborted (max turns exceeded)` |
76
77
 
77
78
  Completed results can be expanded (ctrl+o in pi) to show the full agent output inline.
78
79
 
79
80
  ## Default Agent Types
80
81
 
81
- | Type | Tools | Model | Description |
82
- |------|-------|-------|-------------|
83
- | `general-purpose` | all 7 | inherit | Full read/write access for complex multi-step tasks |
84
- | `Explore` | read, bash, grep, find, ls | haiku (falls back to inherit) | Fast codebase exploration (read-only) |
85
- | `Plan` | read, bash, grep, find, ls | inherit | Software architect for implementation planning (read-only) |
82
+ | Type | Tools | Model | Prompt Mode | Description |
83
+ |------|-------|-------|-------------|-------------|
84
+ | `general-purpose` | all 7 | inherit | `append` (parent twin) | Inherits the parent's full system prompt — same rules, CLAUDE.md, project conventions |
85
+ | `Explore` | read, bash, grep, find, ls | haiku (falls back to inherit) | `replace` (standalone) | Fast codebase exploration (read-only) |
86
+ | `Plan` | read, bash, grep, find, ls | inherit | `replace` (standalone) | Software architect for implementation planning (read-only) |
86
87
 
87
- Default agents can be **overridden** by creating a `.md` file with the same name (e.g. `.pi/agents/Explore.md`), or **disabled** per-project by creating a `.md` file with `enabled: false` frontmatter.
88
+ The `general-purpose` agent is a **parent twin** it receives the parent's entire system prompt plus a sub-agent context bridge, so it follows the same rules the parent does. Explore and Plan use standalone prompts tailored to their read-only roles.
89
+
90
+ Default agents can be **ejected** (`/agents` → select agent → Eject) to export them as `.md` files for customization, **overridden** by creating a `.md` file with the same name (e.g. `.pi/agents/general-purpose.md`), or **disabled** per-project with `enabled: false` frontmatter.
88
91
 
89
92
  ## Custom Agents
90
93
 
@@ -139,7 +142,7 @@ All fields are optional — sensible defaults for everything.
139
142
  | `model` | inherit parent | Model — `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`) |
140
143
  | `thinking` | inherit | off, minimal, low, medium, high, xhigh |
141
144
  | `max_turns` | 50 | Max agentic turns before graceful shutdown |
142
- | `prompt_mode` | `replace` | `replace`: body is the full system prompt. `append`: body appended to default prompt |
145
+ | `prompt_mode` | `replace` | `replace`: body is the full system prompt. `append`: body appended to parent's prompt (agent acts as a "parent twin" with optional extra instructions) |
143
146
  | `inherit_context` | `false` | Fork parent conversation into agent |
144
147
  | `run_in_background` | `false` | Run in background by default |
145
148
  | `isolated` | `false` | No extension/MCP tools, only built-in |
@@ -264,7 +267,8 @@ src/
264
267
  context.ts # Parent conversation context for inherit_context
265
268
  env.ts # Environment detection (git, platform)
266
269
  ui/
267
- agent-widget.ts # Persistent widget: spinners, activity, status icons, theming
270
+ agent-widget.ts # Persistent widget: spinners, activity, status icons, theming
271
+ conversation-viewer.ts # Live conversation overlay for viewing agent sessions
268
272
  ```
269
273
 
270
274
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
5
5
  "author": "tintinweb",
6
6
  "license": "MIT",
@@ -33,8 +33,6 @@ interface SpawnOptions {
33
33
  isolated?: boolean;
34
34
  inheritContext?: boolean;
35
35
  thinkingLevel?: ThinkingLevel;
36
- systemPromptOverride?: string;
37
- systemPromptAppend?: string;
38
36
  isBackground?: boolean;
39
37
  /** Called on tool start/end with activity info (for streaming progress to UI). */
40
38
  onToolActivity?: (activity: ToolActivity) => void;
@@ -122,8 +120,6 @@ export class AgentManager {
122
120
  isolated: options.isolated,
123
121
  inheritContext: options.inheritContext,
124
122
  thinkingLevel: options.thinkingLevel,
125
- systemPromptOverride: options.systemPromptOverride,
126
- systemPromptAppend: options.systemPromptAppend,
127
123
  signal: record.abortController!.signal,
128
124
  onToolActivity: (activity) => {
129
125
  if (activity.type === "end") record.toolUses++;
@@ -84,10 +84,6 @@ export interface RunOptions {
84
84
  isolated?: boolean;
85
85
  inheritContext?: boolean;
86
86
  thinkingLevel?: ThinkingLevel;
87
- /** Override system prompt entirely (for custom agents with promptMode: "replace"). */
88
- systemPromptOverride?: string;
89
- /** Append to default system prompt (for custom agents with promptMode: "append"). */
90
- systemPromptAppend?: string;
91
87
  /** Called on tool start/end with activity info. */
92
88
  onToolActivity?: (activity: ToolActivity) => void;
93
89
  /** Called on streaming text deltas from the assistant response. */
@@ -142,57 +138,27 @@ export async function runAgent(
142
138
  const agentConfig = getAgentConfig(type);
143
139
  const env = await detectEnv(options.pi, ctx.cwd);
144
140
 
145
- // Build system prompt: custom override > custom append > config-driven
141
+ // Get parent system prompt for append-mode agents
142
+ const parentSystemPrompt = ctx.getSystemPrompt();
143
+
144
+ // Build system prompt from agent config
146
145
  let systemPrompt: string;
147
- if (options.systemPromptOverride) {
148
- systemPrompt = options.systemPromptOverride;
149
- } else if (options.systemPromptAppend) {
150
- // Build a default prompt and append to it
151
- const defaultConfig = agentConfig ?? {
152
- name: type,
153
- description: "",
154
- builtinToolNames: [],
155
- extensions: true,
156
- skills: true,
157
- systemPrompt: "",
158
- promptMode: "replace" as const,
159
- inheritContext: false,
160
- runInBackground: false,
161
- isolated: false,
162
- };
163
- systemPrompt = buildAgentPrompt(defaultConfig, ctx.cwd, env) + "\n\n" + options.systemPromptAppend;
164
- } else if (agentConfig) {
165
- systemPrompt = buildAgentPrompt(agentConfig, ctx.cwd, env);
146
+ if (agentConfig) {
147
+ systemPrompt = buildAgentPrompt(agentConfig, ctx.cwd, env, parentSystemPrompt);
166
148
  } else {
167
- // Unknown type use a minimal general-purpose prompt
149
+ // Unknown type fallback: general-purpose (defensive — unreachable in practice
150
+ // since index.ts resolves unknown types to "general-purpose" before calling runAgent)
168
151
  systemPrompt = buildAgentPrompt({
169
152
  name: type,
170
153
  description: "General-purpose agent",
171
- builtinToolNames: [],
154
+ systemPrompt: "",
155
+ promptMode: "append",
172
156
  extensions: true,
173
157
  skills: true,
174
- systemPrompt: `# Role
175
- You are a general-purpose coding agent for complex, multi-step tasks.
176
- You have full access to read, write, edit files, and execute commands.
177
- Do what has been asked; nothing more, nothing less.
178
-
179
- # Tool Usage
180
- - Use the read tool instead of cat/head/tail
181
- - Use the edit tool instead of sed/awk
182
- - Use the write tool instead of echo/heredoc
183
- - Use the find tool instead of bash find/ls for file search
184
- - Use the grep tool instead of bash grep/rg for content search
185
- - Make independent tool calls in parallel
186
-
187
- # Output
188
- - Use absolute file paths
189
- - Do not use emojis
190
- - Be concise but complete`,
191
- promptMode: "replace",
192
158
  inheritContext: false,
193
159
  runInBackground: false,
194
160
  isolated: false,
195
- }, ctx.cwd, env);
161
+ }, ctx.cwd, env, parentSystemPrompt);
196
162
  }
197
163
 
198
164
  const tools = getToolsForType(type, ctx.cwd);
@@ -125,6 +125,7 @@ export function getConfig(type: string): {
125
125
  builtinToolNames: string[];
126
126
  extensions: true | string[] | false;
127
127
  skills: true | string[] | false;
128
+ promptMode: "replace" | "append";
128
129
  } {
129
130
  const key = resolveKey(type);
130
131
  const config = key ? agents.get(key) : undefined;
@@ -135,6 +136,7 @@ export function getConfig(type: string): {
135
136
  builtinToolNames: config.builtinToolNames ?? BUILTIN_TOOL_NAMES,
136
137
  extensions: config.extensions,
137
138
  skills: config.skills,
139
+ promptMode: config.promptMode,
138
140
  };
139
141
  }
140
142
 
@@ -147,6 +149,7 @@ export function getConfig(type: string): {
147
149
  builtinToolNames: gp.builtinToolNames ?? BUILTIN_TOOL_NAMES,
148
150
  extensions: gp.extensions,
149
151
  skills: gp.skills,
152
+ promptMode: gp.promptMode,
150
153
  };
151
154
  }
152
155
 
@@ -157,21 +160,7 @@ export function getConfig(type: string): {
157
160
  builtinToolNames: BUILTIN_TOOL_NAMES,
158
161
  extensions: true,
159
162
  skills: true,
163
+ promptMode: "append",
160
164
  };
161
165
  }
162
166
 
163
- // ---- Backwards-compatible aliases ----
164
-
165
- /** @deprecated Use registerAgents instead */
166
- export const registerCustomAgents = registerAgents;
167
-
168
- /** @deprecated Use getAgentConfig instead */
169
- export function getCustomAgentConfig(name: string): AgentConfig | undefined {
170
- const key = resolveKey(name);
171
- return key ? agents.get(key) : undefined;
172
- }
173
-
174
- /** @deprecated Use getUserAgentNames instead */
175
- export function getCustomAgentNames(): string[] {
176
- return getUserAgentNames();
177
- }
@@ -18,42 +18,8 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
18
18
  // builtinToolNames omitted — means "all available tools" (resolved at lookup time)
19
19
  extensions: true,
20
20
  skills: true,
21
- systemPrompt: `# Role
22
- You are a general-purpose coding agent for complex, multi-step tasks.
23
- You have full access to read, write, edit files, and execute commands.
24
- Do what has been asked; nothing more, nothing less.
25
-
26
- # Tool Usage
27
- - Use the read tool instead of cat/head/tail
28
- - Use the edit tool instead of sed/awk
29
- - Use the write tool instead of echo/heredoc
30
- - Use the find tool instead of bash find/ls for file search
31
- - Use the grep tool instead of bash grep/rg for content search
32
- - Make independent tool calls in parallel
33
-
34
- # File Operations
35
- - NEVER create files unless absolutely necessary
36
- - Prefer editing existing files over creating new ones
37
- - NEVER create documentation files unless explicitly requested
38
-
39
- # Git Safety
40
- - NEVER update git config
41
- - NEVER run destructive git commands (push --force, reset --hard, checkout ., restore ., clean -f, branch -D) without explicit request
42
- - NEVER skip hooks (--no-verify, --no-gpg-sign) unless explicitly asked
43
- - NEVER force push to main/master — warn the user if they request it
44
- - Always create NEW commits, never amend existing ones. When a pre-commit hook fails, the commit did NOT happen — so --amend would modify the PREVIOUS commit. Fix the issue, re-stage, and create a NEW commit
45
- - Stage specific files by name, not git add -A or git add .
46
- - NEVER commit changes unless the user explicitly asks
47
- - NEVER push unless the user explicitly asks
48
- - NEVER use git commands with the -i flag (like git rebase -i or git add -i) — they require interactive input
49
- - Do not use --no-edit with git rebase commands
50
- - Do not commit files that likely contain secrets (.env, credentials.json, etc); warn the user if they request it
51
-
52
- # Output
53
- - Use absolute file paths
54
- - Do not use emojis
55
- - Be concise but complete`,
56
- promptMode: "replace",
21
+ systemPrompt: "",
22
+ promptMode: "append",
57
23
  inheritContext: false,
58
24
  runInBackground: false,
59
25
  isolated: false,
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  formatMs,
31
31
  formatDuration,
32
32
  getDisplayName,
33
+ getPromptModeLabel,
33
34
  describeActivity,
34
35
  type AgentDetails,
35
36
  type AgentActivity,
@@ -120,20 +121,6 @@ function buildDetails(
120
121
  };
121
122
  }
122
123
 
123
- /** Resolve system prompt overrides from an agent config. */
124
- function resolveCustomPrompt(config: AgentConfig | undefined): {
125
- systemPromptOverride?: string;
126
- systemPromptAppend?: string;
127
- } {
128
- if (!config?.systemPrompt) return {};
129
- // Default agents use their systemPrompt via buildAgentPrompt in agent-runner,
130
- // not via override/append. Only non-default agents use this path.
131
- if (config.isDefault) return {};
132
- if (config.promptMode === "append") return { systemPromptAppend: config.systemPrompt };
133
- return { systemPromptOverride: config.systemPrompt };
134
- }
135
-
136
-
137
124
  export default function (pi: ExtensionAPI) {
138
125
  /** Reload agents from .pi/agents/*.md and merge with defaults (called on init and each Agent invocation). */
139
126
  const reloadCustomAgents = () => {
@@ -161,9 +148,11 @@ export default function (pi: ExtensionAPI) {
161
148
  agentActivity.delete(record.id);
162
149
  widget.markFinished(record.id);
163
150
 
151
+ const tokens = safeFormatTokens(record.session);
152
+ const toolStats = tokens ? `Tool uses: ${record.toolUses} | ${tokens}` : `Tool uses: ${record.toolUses}`;
164
153
  pi.sendUserMessage(
165
154
  `Background agent completed: ${displayName} (${record.description})\n` +
166
- `Agent ID: ${record.id} | Status: ${status} | Tool uses: ${record.toolUses} | Duration: ${duration}\n\n` +
155
+ `Agent ID: ${record.id} | Status: ${status} | ${toolStats} | Duration: ${duration}\n\n` +
167
156
  resultPreview,
168
157
  { deliverAs: "followUp" },
169
158
  );
@@ -180,7 +169,9 @@ export default function (pi: ExtensionAPI) {
180
169
  ? record.result.slice(0, 300) + "\n...(truncated)"
181
170
  : record.result
182
171
  : "No output.";
183
- return `- ${displayName} (${record.description})\n ID: ${record.id} | Status: ${status} | Tools: ${record.toolUses} | Duration: ${duration}\n ${resultPreview}`;
172
+ const tokens = safeFormatTokens(record.session);
173
+ const toolStats = tokens ? `Tools: ${record.toolUses} | ${tokens}` : `Tools: ${record.toolUses}`;
174
+ return `- ${displayName} (${record.description})\n ID: ${record.id} | Status: ${status} | ${toolStats} | Duration: ${duration}\n ${resultPreview}`;
184
175
  }
185
176
 
186
177
  // ---- Group join manager ----
@@ -539,8 +530,6 @@ Guidelines:
539
530
  const runInBackground = params.run_in_background ?? customConfig?.runInBackground ?? false;
540
531
  const isolated = params.isolated ?? customConfig?.isolated ?? false;
541
532
 
542
- const { systemPromptOverride, systemPromptAppend } = resolveCustomPrompt(customConfig);
543
-
544
533
  // Build display tags for non-default config
545
534
  const parentModelId = ctx.model?.id;
546
535
  const effectiveModelId = model?.id;
@@ -548,6 +537,8 @@ Guidelines:
548
537
  ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
549
538
  : undefined;
550
539
  const agentTags: string[] = [];
540
+ const modeLabel = getPromptModeLabel(subagentType);
541
+ if (modeLabel) agentTags.push(modeLabel);
551
542
  if (thinking) agentTags.push(`thinking: ${thinking}`);
552
543
  if (isolated) agentTags.push("isolated");
553
544
  // Shared base fields for all AgentDetails in this call
@@ -589,8 +580,6 @@ Guidelines:
589
580
  isolated,
590
581
  inheritContext,
591
582
  thinkingLevel: thinking,
592
- systemPromptOverride,
593
- systemPromptAppend,
594
583
  isBackground: true,
595
584
  ...bgCallbacks,
596
585
  });
@@ -680,8 +669,6 @@ Guidelines:
680
669
  isolated,
681
670
  inheritContext,
682
671
  thinkingLevel: thinking,
683
- systemPromptOverride,
684
- systemPromptAppend,
685
672
  ...fgCallbacks,
686
673
  });
687
674
 
@@ -707,8 +694,10 @@ Guidelines:
707
694
  }
708
695
 
709
696
  const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
697
+ const statsParts = [`${record.toolUses} tool uses`];
698
+ if (tokenText) statsParts.push(tokenText);
710
699
  return textResult(
711
- `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${record.toolUses} tool uses)${getStatusNote(record.status)}.\n\n` +
700
+ `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
712
701
  (record.result ?? "No output."),
713
702
  details,
714
703
  );
@@ -750,10 +739,12 @@ Guidelines:
750
739
 
751
740
  const displayName = getDisplayName(record.type);
752
741
  const duration = formatDuration(record.startedAt, record.completedAt);
742
+ const tokens = safeFormatTokens(record.session);
743
+ const toolStats = tokens ? `Tool uses: ${record.toolUses} | ${tokens}` : `Tool uses: ${record.toolUses}`;
753
744
 
754
745
  let output =
755
746
  `Agent: ${record.id}\n` +
756
- `Type: ${displayName} | Status: ${record.status} | Tool uses: ${record.toolUses} | Duration: ${duration}\n` +
747
+ `Type: ${displayName} | Status: ${record.status} | ${toolStats} | Duration: ${duration}\n` +
757
748
  `Description: ${record.description}\n\n`;
758
749
 
759
750
  if (record.status === "running") {
@@ -951,14 +942,44 @@ Guidelines:
951
942
  return;
952
943
  }
953
944
 
954
- // Show as a selectable list for potential future actions
955
945
  const options = agents.map(a => {
956
946
  const dn = getDisplayName(a.type);
957
947
  const dur = formatDuration(a.startedAt, a.completedAt);
958
948
  return `${dn} (${a.description}) · ${a.toolUses} tools · ${a.status} · ${dur}`;
959
949
  });
960
950
 
961
- await ctx.ui.select("Running agents", options);
951
+ const choice = await ctx.ui.select("Running agents", options);
952
+ if (!choice) return;
953
+
954
+ // Find the selected agent by matching the option index
955
+ const idx = options.indexOf(choice);
956
+ if (idx < 0) return;
957
+ const record = agents[idx];
958
+
959
+ await viewAgentConversation(ctx, record);
960
+ // Back-navigation: re-show the list
961
+ await showRunningAgents(ctx);
962
+ }
963
+
964
+ async function viewAgentConversation(ctx: ExtensionCommandContext, record: AgentRecord) {
965
+ if (!record.session) {
966
+ ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
967
+ return;
968
+ }
969
+
970
+ const { ConversationViewer } = await import("./ui/conversation-viewer.js");
971
+ const session = record.session;
972
+ const activity = agentActivity.get(record.id);
973
+
974
+ await ctx.ui.custom<undefined>(
975
+ (tui, theme, _keybindings, done) => {
976
+ return new ConversationViewer(tui, session, record, activity, theme, done);
977
+ },
978
+ {
979
+ overlay: true,
980
+ overlayOptions: { anchor: "center", width: "90%" },
981
+ },
982
+ );
962
983
  }
963
984
 
964
985
  async function showAgentDetail(ctx: ExtensionCommandContext, name: string) {
package/src/prompts.ts CHANGED
@@ -7,42 +7,57 @@ import type { AgentConfig, EnvInfo } from "./types.js";
7
7
  /**
8
8
  * Build the system prompt for an agent from its config.
9
9
  *
10
- * - "replace" mode: common header + config.systemPrompt
11
- * - "append" mode: common header + generic base + config.systemPrompt
10
+ * - "replace" mode: env header + config.systemPrompt (full control, no parent identity)
11
+ * - "append" mode: env header + parent system prompt + sub-agent context + config.systemPrompt
12
+ * - "append" with empty systemPrompt: pure parent clone
13
+ *
14
+ * @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
12
15
  */
13
- export function buildAgentPrompt(config: AgentConfig, cwd: string, env: EnvInfo): string {
14
- const commonHeader = `You are a pi coding agent sub-agent.
15
- You have been invoked to handle a specific task autonomously.
16
-
17
- # Environment
16
+ export function buildAgentPrompt(
17
+ config: AgentConfig,
18
+ cwd: string,
19
+ env: EnvInfo,
20
+ parentSystemPrompt?: string,
21
+ ): string {
22
+ const envBlock = `# Environment
18
23
  Working directory: ${cwd}
19
24
  ${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
20
25
  Platform: ${env.platform}`;
21
26
 
22
27
  if (config.promptMode === "append") {
23
- const genericBase = `
28
+ const identity = parentSystemPrompt || genericBase;
24
29
 
25
- # Role
26
- You are a general-purpose coding agent for complex, multi-step tasks.
27
- You have full access to read, write, edit files, and execute commands.
28
- Do what has been asked; nothing more, nothing less.
29
-
30
- # Tool Usage
30
+ const bridge = `<sub_agent_context>
31
+ You are operating as a sub-agent invoked to handle a specific task.
31
32
  - Use the read tool instead of cat/head/tail
32
33
  - Use the edit tool instead of sed/awk
33
34
  - Use the write tool instead of echo/heredoc
34
35
  - Use the find tool instead of bash find/ls for file search
35
36
  - Use the grep tool instead of bash grep/rg for content search
36
37
  - Make independent tool calls in parallel
37
-
38
- # Output
39
38
  - Use absolute file paths
40
39
  - Do not use emojis
41
- - Be concise but complete`;
40
+ - Be concise but complete
41
+ </sub_agent_context>`;
42
42
 
43
- return commonHeader + genericBase + "\n\n" + config.systemPrompt;
43
+ const customSection = config.systemPrompt?.trim()
44
+ ? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
45
+ : "";
46
+
47
+ return envBlock + "\n\n<inherited_system_prompt>\n" + identity + "\n</inherited_system_prompt>\n\n" + bridge + customSection;
44
48
  }
45
49
 
46
- // "replace" mode — header + the config's full system prompt
47
- return commonHeader + "\n\n" + config.systemPrompt;
50
+ // "replace" mode — env header + the config's full system prompt
51
+ const replaceHeader = `You are a pi coding agent sub-agent.
52
+ You have been invoked to handle a specific task autonomously.
53
+
54
+ ${envBlock}`;
55
+
56
+ return replaceHeader + "\n\n" + config.systemPrompt;
48
57
  }
58
+
59
+ /** Fallback base prompt when parent system prompt is unavailable in append mode. */
60
+ const genericBase = `# Role
61
+ You are a general-purpose coding agent for complex, multi-step tasks.
62
+ You have full access to read, write, edit files, and execute commands.
63
+ Do what has been asked; nothing more, nothing less.`;
@@ -12,6 +12,9 @@ import { getConfig } from "../agent-types.js";
12
12
 
13
13
  // ---- Constants ----
14
14
 
15
+ /** Maximum number of rendered lines before overflow collapse kicks in. */
16
+ const MAX_WIDGET_LINES = 12;
17
+
15
18
  /** Braille spinner frames for animated running indicator. */
16
19
  export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
17
20
 
@@ -77,11 +80,11 @@ export interface AgentDetails {
77
80
 
78
81
  // ---- Formatting helpers ----
79
82
 
80
- /** Format a token count as "33.8k tokens" or "1.2M tokens". */
83
+ /** Format a token count compactly: "33.8k token", "1.2M token". */
81
84
  export function formatTokens(count: number): string {
82
- if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M tokens`;
83
- if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k tokens`;
84
- return `${count} tokens`;
85
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`;
86
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`;
87
+ return `${count} token`;
85
88
  }
86
89
 
87
90
  /** Format milliseconds as human-readable duration. */
@@ -100,6 +103,12 @@ export function getDisplayName(type: SubagentType): string {
100
103
  return getConfig(type).displayName;
101
104
  }
102
105
 
106
+ /** Short label for prompt mode: "twin" for append, nothing for replace (the default). */
107
+ export function getPromptModeLabel(type: SubagentType): string | undefined {
108
+ const config = getConfig(type);
109
+ return config.promptMode === "append" ? "twin" : undefined;
110
+ }
111
+
103
112
  /** Truncate text to a single line, max `len` chars. */
104
113
  function truncateLine(text: string, len = 60): string {
105
114
  const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
@@ -193,6 +202,7 @@ export class AgentWidget {
193
202
  /** Render a finished agent line. */
194
203
  private renderFinishedLine(a: { type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string }, theme: Theme): string {
195
204
  const name = getDisplayName(a.type);
205
+ const modeLabel = getPromptModeLabel(a.type);
196
206
  const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
197
207
 
198
208
  let icon: string;
@@ -220,7 +230,8 @@ export class AgentWidget {
220
230
  if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
221
231
  parts.push(duration);
222
232
 
223
- return `${icon} ${theme.fg("dim", name)} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
233
+ const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
234
+ return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
224
235
  }
225
236
 
226
237
  /** Force an immediate widget update. */
@@ -268,23 +279,20 @@ export class AgentWidget {
268
279
  const truncate = (line: string) => truncateToWidth(line, w);
269
280
  const headingColor = hasActive ? "accent" : "dim";
270
281
  const headingIcon = hasActive ? "●" : "○";
271
- const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
272
282
 
273
- // --- Finished agents (shown first, dimmed) ---
274
- for (let i = 0; i < finished.length; i++) {
275
- const a = finished[i];
276
- const isLast = !hasActive && i === finished.length - 1;
277
- const connector = isLast ? "└─" : "├─";
278
- lines.push(truncate(theme.fg("dim", connector) + " " + this.renderFinishedLine(a, theme)));
283
+ // Build sections separately for overflow-aware assembly.
284
+ // Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
285
+
286
+ const finishedLines: string[] = [];
287
+ for (const a of finished) {
288
+ finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme)));
279
289
  }
280
290
 
281
- // --- Running agents ---
282
- const isLastSection = queued.length === 0;
283
- for (let i = 0; i < running.length; i++) {
284
- const a = running[i];
285
- const isLast = isLastSection && i === running.length - 1;
286
- const connector = isLast ? "└─" : "├─";
291
+ const runningLines: string[][] = []; // each entry is [header, activity]
292
+ for (const a of running) {
287
293
  const name = getDisplayName(a.type);
294
+ const modeLabel = getPromptModeLabel(a.type);
295
+ const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
288
296
  const elapsed = formatMs(Date.now() - a.startedAt);
289
297
 
290
298
  const bg = this.agentActivity.get(a.id);
@@ -302,14 +310,82 @@ export class AgentWidget {
302
310
 
303
311
  const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
304
312
 
305
- lines.push(truncate(theme.fg("dim", connector) + ` ${theme.fg("accent", frame)} ${theme.bold(name)} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`));
306
- const indent = isLast ? " " : "";
307
- lines.push(truncate(theme.fg("dim", indent) + theme.fg("dim", ` ⎿ ${activity}`)));
313
+ runningLines.push([
314
+ truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`),
315
+ truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
316
+ ]);
308
317
  }
309
318
 
310
- // --- Queued agents (collapsed) ---
311
- if (queued.length > 0) {
312
- lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`));
319
+ const queuedLine = queued.length > 0
320
+ ? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
321
+ : undefined;
322
+
323
+ // Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
324
+ const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
325
+ const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
326
+
327
+ const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
328
+
329
+ if (totalBody <= maxBody) {
330
+ // Everything fits — add all lines and fix up connectors for the last item.
331
+ lines.push(...finishedLines);
332
+ for (const pair of runningLines) lines.push(...pair);
333
+ if (queuedLine) lines.push(queuedLine);
334
+
335
+ // Fix last connector: swap ├─ → └─ and │ → space for activity lines.
336
+ if (lines.length > 1) {
337
+ const last = lines.length - 1;
338
+ lines[last] = lines[last].replace("├─", "└─");
339
+ // If last item is a running agent activity line, fix indent of that line
340
+ // and fix the header line above it.
341
+ if (runningLines.length > 0 && !queuedLine) {
342
+ // The last two lines are the last running agent's header + activity.
343
+ if (last >= 2) {
344
+ lines[last - 1] = lines[last - 1].replace("├─", "└─");
345
+ lines[last] = lines[last].replace("│ ", " ");
346
+ }
347
+ }
348
+ }
349
+ } else {
350
+ // Overflow — prioritize: running > queued > finished.
351
+ // Reserve 1 line for overflow indicator.
352
+ let budget = maxBody - 1;
353
+ let hiddenRunning = 0;
354
+ let hiddenFinished = 0;
355
+
356
+ // 1. Running agents (2 lines each)
357
+ for (const pair of runningLines) {
358
+ if (budget >= 2) {
359
+ lines.push(...pair);
360
+ budget -= 2;
361
+ } else {
362
+ hiddenRunning++;
363
+ }
364
+ }
365
+
366
+ // 2. Queued line
367
+ if (queuedLine && budget >= 1) {
368
+ lines.push(queuedLine);
369
+ budget--;
370
+ }
371
+
372
+ // 3. Finished agents
373
+ for (const fl of finishedLines) {
374
+ if (budget >= 1) {
375
+ lines.push(fl);
376
+ budget--;
377
+ } else {
378
+ hiddenFinished++;
379
+ }
380
+ }
381
+
382
+ // Overflow summary
383
+ const overflowParts: string[] = [];
384
+ if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
385
+ if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
386
+ const overflowText = overflowParts.join(", ");
387
+ lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`)
388
+ );
313
389
  }
314
390
 
315
391
  return { render: () => lines, invalidate: () => {} };
@@ -0,0 +1,243 @@
1
+ /**
2
+ * conversation-viewer.ts — Live conversation overlay for viewing agent sessions.
3
+ *
4
+ * Displays a scrollable, live-updating view of an agent's conversation.
5
+ * Subscribes to session events for real-time streaming updates.
6
+ */
7
+
8
+ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type TUI } from "@mariozechner/pi-tui";
9
+ import type { AgentSession } from "@mariozechner/pi-coding-agent";
10
+ import type { Theme } from "./agent-widget.js";
11
+ import { formatTokens, formatDuration, getDisplayName, getPromptModeLabel, describeActivity, type AgentActivity } from "./agent-widget.js";
12
+ import type { AgentRecord } from "../types.js";
13
+ import { extractText } from "../context.js";
14
+
15
+ /** Lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
16
+ const CHROME_LINES = 6;
17
+ const MIN_VIEWPORT = 3;
18
+
19
+ export class ConversationViewer implements Component {
20
+ private scrollOffset = 0;
21
+ private autoScroll = true;
22
+ private unsubscribe: (() => void) | undefined;
23
+ private lastInnerW = 0;
24
+ private closed = false;
25
+
26
+ constructor(
27
+ private tui: TUI,
28
+ private session: AgentSession,
29
+ private record: AgentRecord,
30
+ private activity: AgentActivity | undefined,
31
+ private theme: Theme,
32
+ private done: (result: undefined) => void,
33
+ ) {
34
+ this.unsubscribe = session.subscribe(() => {
35
+ if (this.closed) return;
36
+ this.tui.requestRender();
37
+ });
38
+ }
39
+
40
+ handleInput(data: string): void {
41
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
42
+ this.closed = true;
43
+ this.done(undefined);
44
+ return;
45
+ }
46
+
47
+ const totalLines = this.buildContentLines(this.lastInnerW).length;
48
+ const viewportHeight = this.viewportHeight();
49
+ const maxScroll = Math.max(0, totalLines - viewportHeight);
50
+
51
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
52
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
53
+ this.autoScroll = this.scrollOffset >= maxScroll;
54
+ } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
55
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
56
+ this.autoScroll = this.scrollOffset >= maxScroll;
57
+ } else if (matchesKey(data, "pageUp")) {
58
+ this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
59
+ this.autoScroll = false;
60
+ } else if (matchesKey(data, "pageDown")) {
61
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
62
+ this.autoScroll = this.scrollOffset >= maxScroll;
63
+ } else if (matchesKey(data, "home")) {
64
+ this.scrollOffset = 0;
65
+ this.autoScroll = false;
66
+ } else if (matchesKey(data, "end")) {
67
+ this.scrollOffset = maxScroll;
68
+ this.autoScroll = true;
69
+ }
70
+ }
71
+
72
+ render(width: number): string[] {
73
+ if (width < 6) return []; // too narrow for any meaningful rendering
74
+ const th = this.theme;
75
+ const innerW = width - 4; // border + padding
76
+ this.lastInnerW = innerW;
77
+ const lines: string[] = [];
78
+
79
+ const pad = (s: string, len: number) => {
80
+ const vis = visibleWidth(s);
81
+ return s + " ".repeat(Math.max(0, len - vis));
82
+ };
83
+ const row = (content: string) =>
84
+ th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW) + " " + th.fg("border", "│");
85
+ const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
86
+ const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
87
+ const hrMid = row(th.fg("dim", "─".repeat(innerW)));
88
+
89
+ // Header
90
+ lines.push(hrTop);
91
+ const name = getDisplayName(this.record.type);
92
+ const modeLabel = getPromptModeLabel(this.record.type);
93
+ const modeTag = modeLabel ? ` ${th.fg("dim", `(${modeLabel})`)}` : "";
94
+ const statusIcon = this.record.status === "running"
95
+ ? th.fg("accent", "●")
96
+ : this.record.status === "completed"
97
+ ? th.fg("success", "✓")
98
+ : this.record.status === "error"
99
+ ? th.fg("error", "✗")
100
+ : th.fg("dim", "○");
101
+ const duration = formatDuration(this.record.startedAt, this.record.completedAt);
102
+
103
+ const headerParts: string[] = [duration];
104
+ const toolUses = this.activity?.toolUses ?? this.record.toolUses;
105
+ if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
106
+ if (this.activity?.session) {
107
+ try {
108
+ const tokens = this.activity.session.getSessionStats().tokens.total;
109
+ if (tokens > 0) headerParts.push(formatTokens(tokens));
110
+ } catch { /* */ }
111
+ }
112
+
113
+ lines.push(row(
114
+ `${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`,
115
+ ));
116
+ lines.push(hrMid);
117
+
118
+ // Content area — rebuild every render (live data, no cache needed)
119
+ const contentLines = this.buildContentLines(innerW);
120
+ const viewportHeight = this.viewportHeight();
121
+ const maxScroll = Math.max(0, contentLines.length - viewportHeight);
122
+
123
+ if (this.autoScroll) {
124
+ this.scrollOffset = maxScroll;
125
+ }
126
+
127
+ const visibleStart = Math.min(this.scrollOffset, maxScroll);
128
+ const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
129
+
130
+ for (let i = 0; i < viewportHeight; i++) {
131
+ lines.push(row(visible[i] ?? ""));
132
+ }
133
+
134
+ // Footer
135
+ lines.push(hrMid);
136
+ const scrollPct = contentLines.length <= viewportHeight
137
+ ? "100%"
138
+ : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
139
+ const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
140
+ const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
141
+ const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
142
+ lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
143
+ lines.push(hrBot);
144
+
145
+ return lines;
146
+ }
147
+
148
+ invalidate(): void { /* no cached state to clear */ }
149
+
150
+ dispose(): void {
151
+ this.closed = true;
152
+ if (this.unsubscribe) {
153
+ this.unsubscribe();
154
+ this.unsubscribe = undefined;
155
+ }
156
+ }
157
+
158
+ // ---- Private ----
159
+
160
+ private viewportHeight(): number {
161
+ return Math.max(MIN_VIEWPORT, this.tui.terminal.rows - CHROME_LINES);
162
+ }
163
+
164
+ private buildContentLines(width: number): string[] {
165
+ if (width <= 0) return [];
166
+
167
+ const th = this.theme;
168
+ const messages = this.session.messages;
169
+ const lines: string[] = [];
170
+
171
+ if (messages.length === 0) {
172
+ lines.push(th.fg("dim", "(waiting for first message...)"));
173
+ return lines;
174
+ }
175
+
176
+ let needsSeparator = false;
177
+ for (const msg of messages) {
178
+ if (msg.role === "user") {
179
+ const text = typeof msg.content === "string"
180
+ ? msg.content
181
+ : extractText(msg.content);
182
+ if (!text.trim()) continue;
183
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
184
+ lines.push(th.fg("accent", "[User]"));
185
+ for (const line of wrapTextWithAnsi(text.trim(), width)) {
186
+ lines.push(line);
187
+ }
188
+ } else if (msg.role === "assistant") {
189
+ const textParts: string[] = [];
190
+ const toolCalls: string[] = [];
191
+ for (const c of msg.content) {
192
+ if (c.type === "text" && c.text) textParts.push(c.text);
193
+ else if (c.type === "toolCall") {
194
+ toolCalls.push((c as any).toolName ?? "unknown");
195
+ }
196
+ }
197
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
198
+ lines.push(th.bold("[Assistant]"));
199
+ if (textParts.length > 0) {
200
+ for (const line of wrapTextWithAnsi(textParts.join("\n").trim(), width)) {
201
+ lines.push(line);
202
+ }
203
+ }
204
+ for (const name of toolCalls) {
205
+ lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width));
206
+ }
207
+ } else if (msg.role === "toolResult") {
208
+ const text = extractText(msg.content);
209
+ const truncated = text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text;
210
+ if (!truncated.trim()) continue;
211
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
212
+ lines.push(th.fg("dim", "[Result]"));
213
+ for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
214
+ lines.push(th.fg("dim", line));
215
+ }
216
+ } else if ((msg as any).role === "bashExecution") {
217
+ const bash = msg as any;
218
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
219
+ lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width));
220
+ if (bash.output?.trim()) {
221
+ const out = bash.output.length > 500
222
+ ? bash.output.slice(0, 500) + "... (truncated)"
223
+ : bash.output;
224
+ for (const line of wrapTextWithAnsi(out.trim(), width)) {
225
+ lines.push(th.fg("dim", line));
226
+ }
227
+ }
228
+ } else {
229
+ continue;
230
+ }
231
+ needsSeparator = true;
232
+ }
233
+
234
+ // Streaming indicator for running agents
235
+ if (this.record.status === "running" && this.activity) {
236
+ const act = describeActivity(this.activity.activeTools, this.activity.responseText);
237
+ lines.push("");
238
+ lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
239
+ }
240
+
241
+ return lines;
242
+ }
243
+ }