@tintinweb/pi-subagents 0.3.0 → 0.3.1
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 +5 -0
- package/README.md +4 -2
- package/package.json +1 -1
- package/src/index.ts +32 -2
- package/src/ui/conversation-viewer.ts +241 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,11 @@ 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.3.1] - 2026-03-09
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **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.
|
|
12
|
+
|
|
8
13
|
## [0.3.0] - 2026-03-08
|
|
9
14
|
|
|
10
15
|
### Added
|
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/
|
|
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
|
|
@@ -264,7 +265,8 @@ src/
|
|
|
264
265
|
context.ts # Parent conversation context for inherit_context
|
|
265
266
|
env.ts # Environment detection (git, platform)
|
|
266
267
|
ui/
|
|
267
|
-
agent-widget.ts
|
|
268
|
+
agent-widget.ts # Persistent widget: spinners, activity, status icons, theming
|
|
269
|
+
conversation-viewer.ts # Live conversation overlay for viewing agent sessions
|
|
268
270
|
```
|
|
269
271
|
|
|
270
272
|
## License
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -951,14 +951,44 @@ Guidelines:
|
|
|
951
951
|
return;
|
|
952
952
|
}
|
|
953
953
|
|
|
954
|
-
// Show as a selectable list for potential future actions
|
|
955
954
|
const options = agents.map(a => {
|
|
956
955
|
const dn = getDisplayName(a.type);
|
|
957
956
|
const dur = formatDuration(a.startedAt, a.completedAt);
|
|
958
957
|
return `${dn} (${a.description}) · ${a.toolUses} tools · ${a.status} · ${dur}`;
|
|
959
958
|
});
|
|
960
959
|
|
|
961
|
-
await ctx.ui.select("Running agents", options);
|
|
960
|
+
const choice = await ctx.ui.select("Running agents", options);
|
|
961
|
+
if (!choice) return;
|
|
962
|
+
|
|
963
|
+
// Find the selected agent by matching the option index
|
|
964
|
+
const idx = options.indexOf(choice);
|
|
965
|
+
if (idx < 0) return;
|
|
966
|
+
const record = agents[idx];
|
|
967
|
+
|
|
968
|
+
await viewAgentConversation(ctx, record);
|
|
969
|
+
// Back-navigation: re-show the list
|
|
970
|
+
await showRunningAgents(ctx);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async function viewAgentConversation(ctx: ExtensionCommandContext, record: AgentRecord) {
|
|
974
|
+
if (!record.session) {
|
|
975
|
+
ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const { ConversationViewer } = await import("./ui/conversation-viewer.js");
|
|
980
|
+
const session = record.session;
|
|
981
|
+
const activity = agentActivity.get(record.id);
|
|
982
|
+
|
|
983
|
+
await ctx.ui.custom<undefined>(
|
|
984
|
+
(tui, theme, _keybindings, done) => {
|
|
985
|
+
return new ConversationViewer(tui, session, record, activity, theme, done);
|
|
986
|
+
},
|
|
987
|
+
{
|
|
988
|
+
overlay: true,
|
|
989
|
+
overlayOptions: { anchor: "center", width: "90%" },
|
|
990
|
+
},
|
|
991
|
+
);
|
|
962
992
|
}
|
|
963
993
|
|
|
964
994
|
async function showAgentDetail(ctx: ExtensionCommandContext, name: string) {
|
|
@@ -0,0 +1,241 @@
|
|
|
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, 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 statusIcon = this.record.status === "running"
|
|
93
|
+
? th.fg("accent", "●")
|
|
94
|
+
: this.record.status === "completed"
|
|
95
|
+
? th.fg("success", "✓")
|
|
96
|
+
: this.record.status === "error"
|
|
97
|
+
? th.fg("error", "✗")
|
|
98
|
+
: th.fg("dim", "○");
|
|
99
|
+
const duration = formatDuration(this.record.startedAt, this.record.completedAt);
|
|
100
|
+
|
|
101
|
+
const headerParts: string[] = [duration];
|
|
102
|
+
const toolUses = this.activity?.toolUses ?? this.record.toolUses;
|
|
103
|
+
if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
|
|
104
|
+
if (this.activity?.session) {
|
|
105
|
+
try {
|
|
106
|
+
const tokens = this.activity.session.getSessionStats().tokens.total;
|
|
107
|
+
if (tokens > 0) headerParts.push(formatTokens(tokens));
|
|
108
|
+
} catch { /* */ }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
lines.push(row(
|
|
112
|
+
`${statusIcon} ${th.bold(name)} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`,
|
|
113
|
+
));
|
|
114
|
+
lines.push(hrMid);
|
|
115
|
+
|
|
116
|
+
// Content area — rebuild every render (live data, no cache needed)
|
|
117
|
+
const contentLines = this.buildContentLines(innerW);
|
|
118
|
+
const viewportHeight = this.viewportHeight();
|
|
119
|
+
const maxScroll = Math.max(0, contentLines.length - viewportHeight);
|
|
120
|
+
|
|
121
|
+
if (this.autoScroll) {
|
|
122
|
+
this.scrollOffset = maxScroll;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const visibleStart = Math.min(this.scrollOffset, maxScroll);
|
|
126
|
+
const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
|
|
127
|
+
|
|
128
|
+
for (let i = 0; i < viewportHeight; i++) {
|
|
129
|
+
lines.push(row(visible[i] ?? ""));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Footer
|
|
133
|
+
lines.push(hrMid);
|
|
134
|
+
const scrollPct = contentLines.length <= viewportHeight
|
|
135
|
+
? "100%"
|
|
136
|
+
: `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
|
|
137
|
+
const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
|
|
138
|
+
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
|
|
139
|
+
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
|
|
140
|
+
lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
|
|
141
|
+
lines.push(hrBot);
|
|
142
|
+
|
|
143
|
+
return lines;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
invalidate(): void { /* no cached state to clear */ }
|
|
147
|
+
|
|
148
|
+
dispose(): void {
|
|
149
|
+
this.closed = true;
|
|
150
|
+
if (this.unsubscribe) {
|
|
151
|
+
this.unsubscribe();
|
|
152
|
+
this.unsubscribe = undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---- Private ----
|
|
157
|
+
|
|
158
|
+
private viewportHeight(): number {
|
|
159
|
+
return Math.max(MIN_VIEWPORT, this.tui.terminal.rows - CHROME_LINES);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private buildContentLines(width: number): string[] {
|
|
163
|
+
if (width <= 0) return [];
|
|
164
|
+
|
|
165
|
+
const th = this.theme;
|
|
166
|
+
const messages = this.session.messages;
|
|
167
|
+
const lines: string[] = [];
|
|
168
|
+
|
|
169
|
+
if (messages.length === 0) {
|
|
170
|
+
lines.push(th.fg("dim", "(waiting for first message...)"));
|
|
171
|
+
return lines;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let needsSeparator = false;
|
|
175
|
+
for (const msg of messages) {
|
|
176
|
+
if (msg.role === "user") {
|
|
177
|
+
const text = typeof msg.content === "string"
|
|
178
|
+
? msg.content
|
|
179
|
+
: extractText(msg.content);
|
|
180
|
+
if (!text.trim()) continue;
|
|
181
|
+
if (needsSeparator) lines.push(th.fg("dim", "───"));
|
|
182
|
+
lines.push(th.fg("accent", "[User]"));
|
|
183
|
+
for (const line of wrapTextWithAnsi(text.trim(), width)) {
|
|
184
|
+
lines.push(line);
|
|
185
|
+
}
|
|
186
|
+
} else if (msg.role === "assistant") {
|
|
187
|
+
const textParts: string[] = [];
|
|
188
|
+
const toolCalls: string[] = [];
|
|
189
|
+
for (const c of msg.content) {
|
|
190
|
+
if (c.type === "text" && c.text) textParts.push(c.text);
|
|
191
|
+
else if (c.type === "toolCall") {
|
|
192
|
+
toolCalls.push((c as any).toolName ?? "unknown");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (needsSeparator) lines.push(th.fg("dim", "───"));
|
|
196
|
+
lines.push(th.bold("[Assistant]"));
|
|
197
|
+
if (textParts.length > 0) {
|
|
198
|
+
for (const line of wrapTextWithAnsi(textParts.join("\n").trim(), width)) {
|
|
199
|
+
lines.push(line);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const name of toolCalls) {
|
|
203
|
+
lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width));
|
|
204
|
+
}
|
|
205
|
+
} else if (msg.role === "toolResult") {
|
|
206
|
+
const text = extractText(msg.content);
|
|
207
|
+
const truncated = text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text;
|
|
208
|
+
if (!truncated.trim()) continue;
|
|
209
|
+
if (needsSeparator) lines.push(th.fg("dim", "───"));
|
|
210
|
+
lines.push(th.fg("dim", "[Result]"));
|
|
211
|
+
for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
|
|
212
|
+
lines.push(th.fg("dim", line));
|
|
213
|
+
}
|
|
214
|
+
} else if ((msg as any).role === "bashExecution") {
|
|
215
|
+
const bash = msg as any;
|
|
216
|
+
if (needsSeparator) lines.push(th.fg("dim", "───"));
|
|
217
|
+
lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width));
|
|
218
|
+
if (bash.output?.trim()) {
|
|
219
|
+
const out = bash.output.length > 500
|
|
220
|
+
? bash.output.slice(0, 500) + "... (truncated)"
|
|
221
|
+
: bash.output;
|
|
222
|
+
for (const line of wrapTextWithAnsi(out.trim(), width)) {
|
|
223
|
+
lines.push(th.fg("dim", line));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
needsSeparator = true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Streaming indicator for running agents
|
|
233
|
+
if (this.record.status === "running" && this.activity) {
|
|
234
|
+
const act = describeActivity(this.activity.activeTools, this.activity.responseText);
|
|
235
|
+
lines.push("");
|
|
236
|
+
lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return lines;
|
|
240
|
+
}
|
|
241
|
+
}
|