@pi-unipi/subagents 0.1.13 → 0.2.3

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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/badge-generation.test.ts +244 -0
  3. package/src/agent-manager.ts +12 -1
  4. package/src/agent-runner.ts +23 -8
  5. package/src/conversation-viewer.ts +299 -0
  6. package/src/index.ts +432 -49
  7. package/src/types.ts +49 -0
  8. package/src/widget.ts +332 -72
  9. package/dist/agent-manager.d.ts +0 -72
  10. package/dist/agent-manager.d.ts.map +0 -1
  11. package/dist/agent-manager.js +0 -258
  12. package/dist/agent-manager.js.map +0 -1
  13. package/dist/agent-runner.d.ts +0 -50
  14. package/dist/agent-runner.d.ts.map +0 -1
  15. package/dist/agent-runner.js +0 -238
  16. package/dist/agent-runner.js.map +0 -1
  17. package/dist/config.d.ts +0 -24
  18. package/dist/config.d.ts.map +0 -1
  19. package/dist/config.js +0 -132
  20. package/dist/config.js.map +0 -1
  21. package/dist/custom-agents.d.ts +0 -14
  22. package/dist/custom-agents.d.ts.map +0 -1
  23. package/dist/custom-agents.js +0 -106
  24. package/dist/custom-agents.js.map +0 -1
  25. package/dist/file-lock.d.ts +0 -42
  26. package/dist/file-lock.d.ts.map +0 -1
  27. package/dist/file-lock.js +0 -91
  28. package/dist/file-lock.js.map +0 -1
  29. package/dist/index.d.ts +0 -9
  30. package/dist/index.d.ts.map +0 -1
  31. package/dist/index.js +0 -301
  32. package/dist/index.js.map +0 -1
  33. package/dist/model-resolver.d.ts +0 -19
  34. package/dist/model-resolver.d.ts.map +0 -1
  35. package/dist/model-resolver.js +0 -61
  36. package/dist/model-resolver.js.map +0 -1
  37. package/dist/prompts.d.ts +0 -13
  38. package/dist/prompts.d.ts.map +0 -1
  39. package/dist/prompts.js +0 -31
  40. package/dist/prompts.js.map +0 -1
  41. package/dist/types.d.ts +0 -79
  42. package/dist/types.d.ts.map +0 -1
  43. package/dist/types.js +0 -6
  44. package/dist/types.js.map +0 -1
  45. package/dist/widget.d.ts +0 -26
  46. package/dist/widget.d.ts.map +0 -1
  47. package/dist/widget.js +0 -162
  48. package/dist/widget.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/subagents",
3
- "version": "0.1.13",
3
+ "version": "0.2.3",
4
4
  "description": "Subagents for UniPi — parallel execution, file locking, workflow integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Test: Badge Generation Flow
3
+ *
4
+ * Tests the full badge name generation flow to identify and verify
5
+ * fixes for "Generating session name..." getting stuck.
6
+ *
7
+ * BUG 1 — Tool mismatch:
8
+ * Background agent was told to "Call the set_session_name tool" but the tool
9
+ * doesn't exist in the agent's session (only builtin tools available).
10
+ * FIX: Changed prompt to output title directly, parse in onComplete callback.
11
+ *
12
+ * BUG 2 — Wrong event bus:
13
+ * Cross-module events emitted via pi.events.emit() but listeners used pi.on()
14
+ * (extension lifecycle events) — completely different event bus.
15
+ * FIX: Changed all cross-module listeners to pi.events.on().
16
+ */
17
+
18
+ import { describe, it } from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { readFileSync, existsSync } from "node:fs";
21
+ import { join } from "node:path";
22
+
23
+ const ROOT = join(import.meta.dirname, "../../../..");
24
+
25
+ // ─── Helpers ────────────────────────────────────────────────────────
26
+
27
+ function readSource(relativePath: string): string {
28
+ const fullPath = join(ROOT, relativePath);
29
+ if (!existsSync(fullPath)) throw new Error(`File not found: ${fullPath}`);
30
+ return readFileSync(fullPath, "utf-8");
31
+ }
32
+
33
+ // ─── Test: Tool availability in spawned agent ──────────────────────
34
+
35
+ describe("Badge generation — tool availability", () => {
36
+ it("agent-runner uses only builtin tools, NOT extension-registered tools", () => {
37
+ const src = readSource("packages/subagents/src/agent-runner.ts");
38
+
39
+ const builtinMatch = src.match(
40
+ /const BUILTIN_TOOL_NAMES\s*=\s*(\[.*?\])/s,
41
+ );
42
+ assert.ok(builtinMatch, "BUILTIN_TOOL_NAMES should be defined");
43
+
44
+ const builtinTools: string[] = eval(builtinMatch[1]);
45
+ assert.deepStrictEqual(builtinTools, [
46
+ "read", "bash", "edit", "write", "grep", "find", "ls",
47
+ ]);
48
+ });
49
+
50
+ it("set_session_name is NOT in the agent's tool list", () => {
51
+ const src = readSource("packages/subagents/src/agent-runner.ts");
52
+ const builtinMatch = src.match(
53
+ /const BUILTIN_TOOL_NAMES\s*=\s*(\[.*?\])/s,
54
+ );
55
+ const tools: string[] = eval(builtinMatch![1]);
56
+ assert.ok(!tools.includes("set_session_name"));
57
+ });
58
+ });
59
+
60
+ // ─── Test: Prompt no longer references non-existent tool ───────────
61
+
62
+ describe("Badge generation — prompt fix", () => {
63
+ it("prompt asks agent to OUTPUT the title directly (not call a tool)", () => {
64
+ const src = readSource("packages/subagents/src/index.ts");
65
+
66
+ assert.ok(
67
+ src.includes("Reply with ONLY the title"),
68
+ "Prompt should ask agent to reply with only the title",
69
+ );
70
+
71
+ assert.ok(
72
+ !src.includes("Call the set_session_name tool"),
73
+ "Prompt should NOT tell agent to call set_session_name",
74
+ );
75
+ });
76
+ });
77
+
78
+ // ─── Test: onComplete extracts name from result ────────────────────
79
+
80
+ describe("Badge generation — onComplete callback", () => {
81
+ it("onComplete extracts name from agent result and calls pi.setSessionName", () => {
82
+ const src = readSource("packages/subagents/src/index.ts");
83
+
84
+ assert.ok(
85
+ src.includes('record.description === "Generate session name"'),
86
+ "Should detect badge generation agents by description",
87
+ );
88
+
89
+ assert.ok(
90
+ src.includes("pi.setSessionName(name)"),
91
+ "Should call pi.setSessionName with extracted name",
92
+ );
93
+ });
94
+ });
95
+
96
+ // ─── Test: Cross-module event bus — the critical fix ───────────────
97
+
98
+ describe("Badge generation — event bus (CRITICAL FIX)", () => {
99
+ it("emitEvent uses pi.events.emit (not pi.on)", () => {
100
+ const src = readSource("packages/core/utils.ts");
101
+
102
+ assert.ok(
103
+ src.includes("pi.events.emit(eventName, payload)"),
104
+ "emitEvent should use pi.events.emit()",
105
+ );
106
+ });
107
+
108
+ it("subagents listens via pi.events.on (NOT pi.on)", () => {
109
+ const src = readSource("packages/subagents/src/index.ts");
110
+
111
+ // Must use pi.events.on for cross-module events
112
+ assert.ok(
113
+ src.includes("pi.events.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST"),
114
+ "Subagents should listen via pi.events.on",
115
+ );
116
+
117
+ // Should NOT use pi.on for custom events
118
+ const piOnMatch = src.match(/pi\.on\(UNIPI_EVENTS\.BADGE_GENERATE_REQUEST/g);
119
+ assert.ok(!piOnMatch, "Should NOT use pi.on() for cross-module events");
120
+ });
121
+
122
+ it("utility BADGE_GENERATE_REQUEST listener is removed (input handler already shows overlay)", () => {
123
+ const src = readSource("packages/utility/src/index.ts");
124
+
125
+ // Should NOT have a separate BADGE_GENERATE_REQUEST listener
126
+ // The input handler already shows the overlay and emits the event
127
+ assert.ok(
128
+ !src.includes("pi.events.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST"),
129
+ "Utility should NOT have a separate BADGE_GENERATE_REQUEST listener",
130
+ );
131
+ });
132
+
133
+ it("workflow listens for MODULE_READY via pi.events.on (NOT pi.on)", () => {
134
+ const src = readSource("packages/workflow/index.ts");
135
+
136
+ assert.ok(
137
+ src.includes("pi.events.on(UNIPI_EVENTS.MODULE_READY"),
138
+ "Workflow should listen via pi.events.on",
139
+ );
140
+
141
+ const piOnMatch = src.match(/pi\.on\(UNIPI_EVENTS\.MODULE_READY/g);
142
+ assert.ok(!piOnMatch, "Should NOT use pi.on() for cross-module events");
143
+ });
144
+
145
+ it("pi.on() is ONLY used for known lifecycle events", () => {
146
+ const subagentsSrc = readSource("packages/subagents/src/index.ts");
147
+ const utilitySrc = readSource("packages/utility/src/index.ts");
148
+
149
+ // These are valid lifecycle events that should use pi.on()
150
+ const validLifecycleEvents = [
151
+ "session_start", "session_shutdown", "input",
152
+ "tool_call", "tool_execution_start",
153
+ ];
154
+
155
+ // Check that pi.on() is only used with lifecycle events
156
+ const piOnPattern = /pi\.on\("([^"]+)"/g;
157
+ let match;
158
+ while ((match = piOnPattern.exec(subagentsSrc)) !== null) {
159
+ assert.ok(
160
+ validLifecycleEvents.includes(match[1]),
161
+ `subagents: pi.on("${match[1]}") should be a lifecycle event, use pi.events.on() for custom events`,
162
+ );
163
+ }
164
+ while ((match = piOnPattern.exec(utilitySrc)) !== null) {
165
+ assert.ok(
166
+ validLifecycleEvents.includes(match[1]),
167
+ `utility: pi.on("${match[1]}") should be a lifecycle event`,
168
+ );
169
+ }
170
+ });
171
+ });
172
+
173
+ // ─── Test: Event flow ──────────────────────────────────────────────
174
+
175
+ describe("Badge generation — event flow", () => {
176
+ it("utility emits BADGE_GENERATE_REQUEST on first input", () => {
177
+ const src = readSource("packages/utility/src/index.ts");
178
+
179
+ assert.ok(src.includes("BADGE_GENERATE_REQUEST"));
180
+ assert.ok(src.includes('source: "input-hook"'));
181
+ });
182
+
183
+ it("BADGE_GENERATE_REQUEST event is defined in core", () => {
184
+ const src = readSource("packages/core/events.ts");
185
+ assert.ok(src.includes("BADGE_GENERATE_REQUEST"));
186
+ });
187
+ });
188
+
189
+ // ─── Test: Model resolution ────────────────────────────────────────
190
+
191
+ describe("Badge generation — model resolution", () => {
192
+ it("reads generationModel from badge.json instead of hardcoding", () => {
193
+ const src = readSource("packages/subagents/src/index.ts");
194
+
195
+ assert.ok(!src.includes('"openai/gpt-oss-20b"'));
196
+ assert.ok(src.includes(".unipi/config/badge.json"));
197
+ assert.ok(src.includes("parsed.generationModel"));
198
+ });
199
+ });
200
+
201
+ // ─── Summary ───────────────────────────────────────────────────────
202
+
203
+ describe("Badge generation — ROOT CAUSE SUMMARY", () => {
204
+ it("BUG 1 FIXED: prompt no longer references non-existent tool", () => {
205
+ const src = readSource("packages/subagents/src/index.ts");
206
+
207
+ assert.ok(!src.includes("Call the set_session_name tool"),
208
+ "FIXED: prompt no longer tells agent to call set_session_name");
209
+ assert.ok(src.includes("Reply with ONLY the title"),
210
+ "FIXED: prompt asks agent to reply with only the title");
211
+ });
212
+
213
+ it("BUG 1 FIXED: onComplete extracts name and sets it directly", () => {
214
+ const src = readSource("packages/subagents/src/index.ts");
215
+
216
+ assert.ok(src.includes('record.description === "Generate session name"'),
217
+ "FIXED: onComplete detects badge generation agents");
218
+ assert.ok(src.includes("pi.setSessionName(name)"),
219
+ "FIXED: onComplete calls pi.setSessionName directly");
220
+ });
221
+
222
+ it("BUG 2 FIXED: cross-module events use pi.events.on, not pi.on", () => {
223
+ const subagentsSrc = readSource("packages/subagents/src/index.ts");
224
+ const utilitySrc = readSource("packages/utility/src/index.ts");
225
+ const workflowSrc = readSource("packages/workflow/index.ts");
226
+
227
+ // Subagents: correct event bus
228
+ assert.ok(
229
+ subagentsSrc.includes("pi.events.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST"),
230
+ "subagents: must use pi.events.on for BADGE_GENERATE_REQUEST",
231
+ );
232
+
233
+ // Utility: no duplicate listener (input handler already handles it)
234
+ assert.ok(
235
+ !utilitySrc.includes("pi.events.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST"),
236
+ "utility: no duplicate BADGE_GENERATE_REQUEST listener", );
237
+
238
+ // Workflow: correct event bus
239
+ assert.ok(
240
+ workflowSrc.includes("pi.events.on(UNIPI_EVENTS.MODULE_READY"),
241
+ "workflow: must use pi.events.on for MODULE_READY",
242
+ );
243
+ });
244
+ });
@@ -10,7 +10,9 @@ import type { Model } from "@mariozechner/pi-ai";
10
10
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
11
11
  import { runAgent, type ToolActivity } from "./agent-runner.js";
12
12
  import { resolveModel, type ModelRegistry } from "./model-resolver.js";
13
- import type { AgentRecord, AgentType, ThinkingLevel } from "./types.js";
13
+ import type { AgentRecord, AgentConfig, AgentType, ThinkingLevel } from "./types.js";
14
+ import { BUILTIN_CONFIGS } from "./types.js";
15
+ import { loadCustomAgents } from "./custom-agents.js";
14
16
  import { FileLock } from "./file-lock.js";
15
17
 
16
18
  export type OnAgentComplete = (record: AgentRecord) => void;
@@ -49,6 +51,7 @@ export class AgentManager {
49
51
  private onComplete?: OnAgentComplete;
50
52
  private onStart?: OnAgentStart;
51
53
  private maxConcurrent: number;
54
+ private customAgents: Map<string, AgentConfig>;
52
55
 
53
56
  /** Per-file transparent locking for write agents. */
54
57
  readonly fileLock = new FileLock();
@@ -62,9 +65,15 @@ export class AgentManager {
62
65
  this.onComplete = onComplete;
63
66
  this.onStart = onStart;
64
67
  this.maxConcurrent = maxConcurrent;
68
+ this.customAgents = loadCustomAgents(process.cwd());
65
69
  this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
66
70
  }
67
71
 
72
+ /** Get resolved agent config for a type. */
73
+ getAgentConfig(type: AgentType): AgentConfig | undefined {
74
+ return this.customAgents.get(type) ?? BUILTIN_CONFIGS[type];
75
+ }
76
+
68
77
  setMaxConcurrent(n: number) {
69
78
  this.maxConcurrent = Math.max(1, n);
70
79
  this.drainQueue();
@@ -134,9 +143,11 @@ export class AgentManager {
134
143
  model = resolved;
135
144
  }
136
145
 
146
+ const agentConfig = this.getAgentConfig(type);
137
147
  const promise = runAgent(ctx, type, prompt, {
138
148
  pi,
139
149
  model,
150
+ agentConfig,
140
151
  maxTurns: options.maxTurns,
141
152
  isolated: options.isolated,
142
153
  inheritContext: options.inheritContext,
@@ -17,7 +17,7 @@ import {
17
17
  SessionManager,
18
18
  SettingsManager,
19
19
  } from "@mariozechner/pi-coding-agent";
20
- import type { AgentConfig, AgentType, ThinkingLevel } from "./types.js";
20
+ import { BUILTIN_CONFIGS, type AgentConfig, type AgentType, type ThinkingLevel } from "./types.js";
21
21
 
22
22
  /** Tools excluded from subagents to prevent nesting. */
23
23
  const EXCLUDED_TOOL_NAMES = ["Agent", "get_result"];
@@ -57,6 +57,7 @@ export interface ToolActivity {
57
57
  export interface RunOptions {
58
58
  pi: ExtensionAPI;
59
59
  model?: Model<any>;
60
+ agentConfig?: AgentConfig;
60
61
  maxTurns?: number;
61
62
  signal?: AbortSignal;
62
63
  isolated?: boolean;
@@ -122,6 +123,15 @@ function getToolNamesForType(type: AgentType, config?: AgentConfig): string[] {
122
123
  return [...BUILTIN_TOOL_NAMES];
123
124
  }
124
125
 
126
+ /**
127
+ * Resolve agent config for a type.
128
+ * Priority: explicit config > builtin config > default
129
+ */
130
+ function resolveAgentConfig(type: AgentType, explicitConfig?: AgentConfig): AgentConfig | undefined {
131
+ if (explicitConfig) return explicitConfig;
132
+ return BUILTIN_CONFIGS[type];
133
+ }
134
+
125
135
  /** Resolve model from config. */
126
136
  function resolveDefaultModel(
127
137
  parentModel: Model<any> | undefined,
@@ -142,19 +152,24 @@ export async function runAgent(
142
152
  ): Promise<RunResult> {
143
153
  const effectiveCwd = options.cwd ?? ctx.cwd;
144
154
 
145
- // Build system prompt
146
- const agentConfig = options as any; // Will be properly typed later
155
+ // Resolve agent config
156
+ const agentConfig = resolveAgentConfig(type, options.agentConfig);
147
157
  const parentSystemPrompt = ctx.getSystemPrompt();
148
158
 
159
+ // Build system prompt using config or defaults
149
160
  let systemPrompt: string;
150
- if (options.isolated) {
151
- systemPrompt = `You are a ${type} agent. Follow the task instructions precisely. Do not ask questions.`;
161
+ if (agentConfig?.systemPrompt && agentConfig.promptMode === "replace") {
162
+ systemPrompt = agentConfig.systemPrompt;
163
+ } else if (options.isolated) {
164
+ const base = agentConfig?.systemPrompt ?? `You are a ${type} agent.`;
165
+ systemPrompt = `${base} Follow the task instructions precisely. Do not ask questions.`;
152
166
  } else {
153
- systemPrompt = parentSystemPrompt + `\n\nYou are a ${type} agent. Follow the task instructions precisely.`;
167
+ const agentPrompt = agentConfig?.systemPrompt ?? `You are a ${type} agent.`;
168
+ systemPrompt = parentSystemPrompt + `\n\n${agentPrompt}`;
154
169
  }
155
170
 
156
- // Get tool names
157
- let toolNames = getToolNamesForType(type);
171
+ // Get tool names from config
172
+ let toolNames = getToolNamesForType(type, agentConfig);
158
173
 
159
174
  // Create resource loader
160
175
  const agentDir = getAgentDir();
@@ -0,0 +1,299 @@
1
+ /**
2
+ * @pi-unipi/subagents — Conversation Viewer
3
+ *
4
+ * Live-scrolling overlay for viewing agent conversations.
5
+ * Subscribes to session events for real-time streaming updates.
6
+ * Supports keyboard navigation: ↑↓, PgUp/PgDn, Home/End, Esc/q to close.
7
+ */
8
+
9
+ import type { AgentSession } from "@mariozechner/pi-coding-agent";
10
+ import {
11
+ type Component,
12
+ matchesKey,
13
+ type TUI,
14
+ truncateToWidth,
15
+ visibleWidth,
16
+ wrapTextWithAnsi,
17
+ } from "@mariozechner/pi-tui";
18
+ import type { AgentActivity } from "./types.js";
19
+
20
+ /** Lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
21
+ const CHROME_LINES = 6;
22
+ const MIN_VIEWPORT = 3;
23
+
24
+ /** Extract text from content array. */
25
+ function extractText(content: string | Array<{ type: string; text?: string }>): string {
26
+ if (typeof content === "string") return content;
27
+ return content
28
+ .filter((p): p is { type: "text"; text: string } => p.type === "text" && typeof p.text === "string")
29
+ .map((p) => p.text)
30
+ .join("");
31
+ }
32
+
33
+ /** Format duration. */
34
+ function formatMs(ms: number): string {
35
+ if (ms >= 60_000) return `${(ms / 60_000).toFixed(1)}m`;
36
+ if (ms >= 1_000) return `${(ms / 1_000).toFixed(1)}s`;
37
+ return `${ms}ms`;
38
+ }
39
+
40
+ /** Format tokens compactly. */
41
+ function formatTokens(count: number): string {
42
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`;
43
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`;
44
+ return `${count} token`;
45
+ }
46
+
47
+ /** Describe current activity from active tools. */
48
+ function describeActivity(activeTools: Map<string, string>, responseText?: string): string {
49
+ if (activeTools.size > 0) {
50
+ const names = [...new Set(activeTools.values())];
51
+ return names.join(", ") + "…";
52
+ }
53
+ if (responseText && responseText.trim().length > 0) {
54
+ const lastLine = responseText.split("\n").find((l) => l.trim())?.trim() ?? "";
55
+ if (lastLine.length > 60) return lastLine.slice(0, 60) + "…";
56
+ if (lastLine.length > 0) return lastLine;
57
+ }
58
+ return "thinking…";
59
+ }
60
+
61
+ interface ViewerRecord {
62
+ type: string;
63
+ description: string;
64
+ status: string;
65
+ toolUses: number;
66
+ startedAt: number;
67
+ completedAt?: number;
68
+ }
69
+
70
+ export class ConversationViewer implements Component {
71
+ private scrollOffset = 0;
72
+ private autoScroll = true;
73
+ private unsubscribe: (() => void) | undefined;
74
+ private lastInnerW = 0;
75
+ private closed = false;
76
+
77
+ constructor(
78
+ private tui: TUI,
79
+ private session: AgentSession,
80
+ private record: ViewerRecord,
81
+ private activity: AgentActivity | undefined,
82
+ private theme: any,
83
+ private done: (result: undefined) => void,
84
+ ) {
85
+ this.unsubscribe = session.subscribe(() => {
86
+ if (this.closed) return;
87
+ this.tui.requestRender();
88
+ });
89
+ }
90
+
91
+ handleInput(data: string): void {
92
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
93
+ this.closed = true;
94
+ this.done(undefined);
95
+ return;
96
+ }
97
+
98
+ const totalLines = this.buildContentLines(this.lastInnerW).length;
99
+ const viewportHeight = this.viewportHeight();
100
+ const maxScroll = Math.max(0, totalLines - viewportHeight);
101
+
102
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
103
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
104
+ this.autoScroll = this.scrollOffset >= maxScroll;
105
+ } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
106
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
107
+ this.autoScroll = this.scrollOffset >= maxScroll;
108
+ } else if (matchesKey(data, "pageUp")) {
109
+ this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
110
+ this.autoScroll = false;
111
+ } else if (matchesKey(data, "pageDown")) {
112
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
113
+ this.autoScroll = this.scrollOffset >= maxScroll;
114
+ } else if (matchesKey(data, "home")) {
115
+ this.scrollOffset = 0;
116
+ this.autoScroll = false;
117
+ } else if (matchesKey(data, "end")) {
118
+ this.scrollOffset = maxScroll;
119
+ this.autoScroll = true;
120
+ }
121
+ }
122
+
123
+ render(width: number): string[] {
124
+ if (width < 6) return [];
125
+ const th = this.theme;
126
+ const innerW = width - 4; // border + padding
127
+ this.lastInnerW = innerW;
128
+ const lines: string[] = [];
129
+
130
+ const pad = (s: string, len: number) => {
131
+ const vis = visibleWidth(s);
132
+ return s + " ".repeat(Math.max(0, len - vis));
133
+ };
134
+ const row = (content: string) =>
135
+ th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW) + " " + th.fg("border", "│");
136
+ const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
137
+ const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
138
+ const hrMid = row(th.fg("dim", "─".repeat(innerW)));
139
+
140
+ // Header
141
+ lines.push(hrTop);
142
+ const name = this.record.type;
143
+ const statusIcon =
144
+ this.record.status === "running"
145
+ ? th.fg("accent", "●")
146
+ : this.record.status === "completed"
147
+ ? th.fg("success", "✓")
148
+ : this.record.status === "error"
149
+ ? th.fg("error", "✗")
150
+ : th.fg("dim", "○");
151
+
152
+ const duration = this.record.completedAt
153
+ ? formatMs(this.record.completedAt - this.record.startedAt)
154
+ : `${formatMs(Date.now() - this.record.startedAt)} (running)`;
155
+
156
+ const headerParts: string[] = [duration];
157
+ const toolUses = this.activity?.toolUses ?? this.record.toolUses;
158
+ if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
159
+ if (this.activity?.session) {
160
+ try {
161
+ const tokens = (this.activity.session as any).getSessionStats().tokens.total;
162
+ if (tokens > 0) headerParts.push(formatTokens(tokens));
163
+ } catch {
164
+ /* */
165
+ }
166
+ }
167
+
168
+ lines.push(
169
+ row(
170
+ `${statusIcon} ${th.bold(name)} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`,
171
+ ),
172
+ );
173
+ lines.push(hrMid);
174
+
175
+ // Content area
176
+ const contentLines = this.buildContentLines(innerW);
177
+ const viewportHeight = this.viewportHeight();
178
+ const maxScroll = Math.max(0, contentLines.length - viewportHeight);
179
+
180
+ if (this.autoScroll) {
181
+ this.scrollOffset = maxScroll;
182
+ }
183
+
184
+ const visibleStart = Math.min(this.scrollOffset, maxScroll);
185
+ const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
186
+
187
+ for (let i = 0; i < viewportHeight; i++) {
188
+ lines.push(row(visible[i] ?? ""));
189
+ }
190
+
191
+ // Footer
192
+ lines.push(hrMid);
193
+ const scrollPct =
194
+ contentLines.length <= viewportHeight
195
+ ? "100%"
196
+ : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
197
+ const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
198
+ const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
199
+ const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
200
+ lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
201
+ lines.push(hrBot);
202
+
203
+ return lines;
204
+ }
205
+
206
+ invalidate(): void {
207
+ /* no cached state to clear */
208
+ }
209
+
210
+ dispose(): void {
211
+ this.closed = true;
212
+ if (this.unsubscribe) {
213
+ this.unsubscribe();
214
+ this.unsubscribe = undefined;
215
+ }
216
+ }
217
+
218
+ // ---- Private ----
219
+
220
+ private viewportHeight(): number {
221
+ return Math.max(MIN_VIEWPORT, this.tui.terminal.rows - CHROME_LINES);
222
+ }
223
+
224
+ private buildContentLines(width: number): string[] {
225
+ if (width <= 0) return [];
226
+
227
+ const th = this.theme;
228
+ const messages = (this.session as any).messages;
229
+ const lines: string[] = [];
230
+
231
+ if (!messages || messages.length === 0) {
232
+ lines.push(th.fg("dim", "(waiting for first message...)"));
233
+ return lines;
234
+ }
235
+
236
+ let needsSeparator = false;
237
+ for (const msg of messages) {
238
+ if (msg.role === "user") {
239
+ const text = typeof msg.content === "string" ? msg.content : extractText(msg.content);
240
+ if (!text.trim()) continue;
241
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
242
+ lines.push(th.fg("accent", "[User]"));
243
+ for (const line of wrapTextWithAnsi(text.trim(), width)) {
244
+ lines.push(line);
245
+ }
246
+ } else if (msg.role === "assistant") {
247
+ const textParts: string[] = [];
248
+ const toolCalls: string[] = [];
249
+ for (const c of msg.content) {
250
+ if (c.type === "text" && c.text) textParts.push(c.text);
251
+ else if (c.type === "tool_use" || c.type === "toolCall") {
252
+ toolCalls.push((c as any).name ?? (c as any).toolName ?? "unknown");
253
+ }
254
+ }
255
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
256
+ lines.push(th.bold("[Assistant]"));
257
+ if (textParts.length > 0) {
258
+ for (const line of wrapTextWithAnsi(textParts.join("\n").trim(), width)) {
259
+ lines.push(line);
260
+ }
261
+ }
262
+ for (const name of toolCalls) {
263
+ lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width));
264
+ }
265
+ } else if (msg.role === "toolResult") {
266
+ const text = extractText(msg.content);
267
+ const truncated = text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text;
268
+ if (!truncated.trim()) continue;
269
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
270
+ lines.push(th.fg("dim", "[Result]"));
271
+ for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
272
+ lines.push(th.fg("dim", line));
273
+ }
274
+ } else if ((msg as any).role === "bashExecution") {
275
+ const bash = msg as any;
276
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
277
+ lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width));
278
+ if (bash.output?.trim()) {
279
+ const out = bash.output.length > 500 ? bash.output.slice(0, 500) + "... (truncated)" : bash.output;
280
+ for (const line of wrapTextWithAnsi(out.trim(), width)) {
281
+ lines.push(th.fg("dim", line));
282
+ }
283
+ }
284
+ } else {
285
+ continue;
286
+ }
287
+ needsSeparator = true;
288
+ }
289
+
290
+ // Streaming indicator for running agents
291
+ if (this.record.status === "running" && this.activity) {
292
+ const act = describeActivity(this.activity.activeTools, this.activity.responseText);
293
+ lines.push("");
294
+ lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
295
+ }
296
+
297
+ return lines.map((l) => truncateToWidth(l, width));
298
+ }
299
+ }