@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.
- package/package.json +1 -1
- package/src/__tests__/badge-generation.test.ts +244 -0
- package/src/agent-manager.ts +12 -1
- package/src/agent-runner.ts +23 -8
- package/src/conversation-viewer.ts +299 -0
- package/src/index.ts +432 -49
- package/src/types.ts +49 -0
- package/src/widget.ts +332 -72
- package/dist/agent-manager.d.ts +0 -72
- package/dist/agent-manager.d.ts.map +0 -1
- package/dist/agent-manager.js +0 -258
- package/dist/agent-manager.js.map +0 -1
- package/dist/agent-runner.d.ts +0 -50
- package/dist/agent-runner.d.ts.map +0 -1
- package/dist/agent-runner.js +0 -238
- package/dist/agent-runner.js.map +0 -1
- package/dist/config.d.ts +0 -24
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js +0 -132
- package/dist/config.js.map +0 -1
- package/dist/custom-agents.d.ts +0 -14
- package/dist/custom-agents.d.ts.map +0 -1
- package/dist/custom-agents.js +0 -106
- package/dist/custom-agents.js.map +0 -1
- package/dist/file-lock.d.ts +0 -42
- package/dist/file-lock.d.ts.map +0 -1
- package/dist/file-lock.js +0 -91
- package/dist/file-lock.js.map +0 -1
- package/dist/index.d.ts +0 -9
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -301
- package/dist/index.js.map +0 -1
- package/dist/model-resolver.d.ts +0 -19
- package/dist/model-resolver.d.ts.map +0 -1
- package/dist/model-resolver.js +0 -61
- package/dist/model-resolver.js.map +0 -1
- package/dist/prompts.d.ts +0 -13
- package/dist/prompts.d.ts.map +0 -1
- package/dist/prompts.js +0 -31
- package/dist/prompts.js.map +0 -1
- package/dist/types.d.ts +0 -79
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -6
- package/dist/types.js.map +0 -1
- package/dist/widget.d.ts +0 -26
- package/dist/widget.d.ts.map +0 -1
- package/dist/widget.js +0 -162
- package/dist/widget.js.map +0 -1
package/package.json
CHANGED
|
@@ -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
|
+
});
|
package/src/agent-manager.ts
CHANGED
|
@@ -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,
|
package/src/agent-runner.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
SessionManager,
|
|
18
18
|
SettingsManager,
|
|
19
19
|
} from "@mariozechner/pi-coding-agent";
|
|
20
|
-
import type
|
|
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
|
-
//
|
|
146
|
-
const agentConfig = options
|
|
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 (
|
|
151
|
-
systemPrompt =
|
|
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
|
-
|
|
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
|
+
}
|