@tintinweb/pi-subagents 0.4.0 → 0.4.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.
@@ -0,0 +1,31 @@
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
+ import { type Component, type TUI } from "@mariozechner/pi-tui";
8
+ import type { AgentSession } from "@mariozechner/pi-coding-agent";
9
+ import type { Theme } from "./agent-widget.js";
10
+ import { type AgentActivity } from "./agent-widget.js";
11
+ import type { AgentRecord } from "../types.js";
12
+ export declare class ConversationViewer implements Component {
13
+ private tui;
14
+ private session;
15
+ private record;
16
+ private activity;
17
+ private theme;
18
+ private done;
19
+ private scrollOffset;
20
+ private autoScroll;
21
+ private unsubscribe;
22
+ private lastInnerW;
23
+ private closed;
24
+ constructor(tui: TUI, session: AgentSession, record: AgentRecord, activity: AgentActivity | undefined, theme: Theme, done: (result: undefined) => void);
25
+ handleInput(data: string): void;
26
+ render(width: number): string[];
27
+ invalidate(): void;
28
+ dispose(): void;
29
+ private viewportHeight;
30
+ private buildContentLines;
31
+ }
@@ -0,0 +1,236 @@
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
+ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
8
+ import { formatTokens, formatDuration, getDisplayName, getPromptModeLabel, describeActivity } from "./agent-widget.js";
9
+ import { extractText } from "../context.js";
10
+ /** Lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
11
+ const CHROME_LINES = 6;
12
+ const MIN_VIEWPORT = 3;
13
+ export class ConversationViewer {
14
+ tui;
15
+ session;
16
+ record;
17
+ activity;
18
+ theme;
19
+ done;
20
+ scrollOffset = 0;
21
+ autoScroll = true;
22
+ unsubscribe;
23
+ lastInnerW = 0;
24
+ closed = false;
25
+ constructor(tui, session, record, activity, theme, done) {
26
+ this.tui = tui;
27
+ this.session = session;
28
+ this.record = record;
29
+ this.activity = activity;
30
+ this.theme = theme;
31
+ this.done = done;
32
+ this.unsubscribe = session.subscribe(() => {
33
+ if (this.closed)
34
+ return;
35
+ this.tui.requestRender();
36
+ });
37
+ }
38
+ handleInput(data) {
39
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
40
+ this.closed = true;
41
+ this.done(undefined);
42
+ return;
43
+ }
44
+ const totalLines = this.buildContentLines(this.lastInnerW).length;
45
+ const viewportHeight = this.viewportHeight();
46
+ const maxScroll = Math.max(0, totalLines - viewportHeight);
47
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
48
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
49
+ this.autoScroll = this.scrollOffset >= maxScroll;
50
+ }
51
+ else if (matchesKey(data, "down") || matchesKey(data, "j")) {
52
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
53
+ this.autoScroll = this.scrollOffset >= maxScroll;
54
+ }
55
+ else if (matchesKey(data, "pageUp")) {
56
+ this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
57
+ this.autoScroll = false;
58
+ }
59
+ else if (matchesKey(data, "pageDown")) {
60
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
61
+ this.autoScroll = this.scrollOffset >= maxScroll;
62
+ }
63
+ else if (matchesKey(data, "home")) {
64
+ this.scrollOffset = 0;
65
+ this.autoScroll = false;
66
+ }
67
+ else if (matchesKey(data, "end")) {
68
+ this.scrollOffset = maxScroll;
69
+ this.autoScroll = true;
70
+ }
71
+ }
72
+ render(width) {
73
+ if (width < 6)
74
+ return []; // too narrow for any meaningful rendering
75
+ const th = this.theme;
76
+ const innerW = width - 4; // border + padding
77
+ this.lastInnerW = innerW;
78
+ const lines = [];
79
+ const pad = (s, len) => {
80
+ const vis = visibleWidth(s);
81
+ return s + " ".repeat(Math.max(0, len - vis));
82
+ };
83
+ const row = (content) => th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW) + " " + th.fg("border", "│");
84
+ const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
85
+ const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
86
+ const hrMid = row(th.fg("dim", "─".repeat(innerW)));
87
+ // Header
88
+ lines.push(hrTop);
89
+ const name = getDisplayName(this.record.type);
90
+ const modeLabel = getPromptModeLabel(this.record.type);
91
+ const modeTag = modeLabel ? ` ${th.fg("dim", `(${modeLabel})`)}` : "";
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
+ const headerParts = [duration];
101
+ const toolUses = this.activity?.toolUses ?? this.record.toolUses;
102
+ if (toolUses > 0)
103
+ 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)
108
+ headerParts.push(formatTokens(tokens));
109
+ }
110
+ catch { /* */ }
111
+ }
112
+ lines.push(row(`${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`));
113
+ lines.push(hrMid);
114
+ // Content area — rebuild every render (live data, no cache needed)
115
+ const contentLines = this.buildContentLines(innerW);
116
+ const viewportHeight = this.viewportHeight();
117
+ const maxScroll = Math.max(0, contentLines.length - viewportHeight);
118
+ if (this.autoScroll) {
119
+ this.scrollOffset = maxScroll;
120
+ }
121
+ const visibleStart = Math.min(this.scrollOffset, maxScroll);
122
+ const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
123
+ for (let i = 0; i < viewportHeight; i++) {
124
+ lines.push(row(visible[i] ?? ""));
125
+ }
126
+ // Footer
127
+ lines.push(hrMid);
128
+ const scrollPct = contentLines.length <= viewportHeight
129
+ ? "100%"
130
+ : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
131
+ const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
132
+ const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
133
+ const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
134
+ lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
135
+ lines.push(hrBot);
136
+ return lines;
137
+ }
138
+ invalidate() { }
139
+ dispose() {
140
+ this.closed = true;
141
+ if (this.unsubscribe) {
142
+ this.unsubscribe();
143
+ this.unsubscribe = undefined;
144
+ }
145
+ }
146
+ // ---- Private ----
147
+ viewportHeight() {
148
+ return Math.max(MIN_VIEWPORT, this.tui.terminal.rows - CHROME_LINES);
149
+ }
150
+ buildContentLines(width) {
151
+ if (width <= 0)
152
+ return [];
153
+ const th = this.theme;
154
+ const messages = this.session.messages;
155
+ const lines = [];
156
+ if (messages.length === 0) {
157
+ lines.push(th.fg("dim", "(waiting for first message...)"));
158
+ return lines;
159
+ }
160
+ let needsSeparator = false;
161
+ for (const msg of messages) {
162
+ if (msg.role === "user") {
163
+ const text = typeof msg.content === "string"
164
+ ? msg.content
165
+ : extractText(msg.content);
166
+ if (!text.trim())
167
+ continue;
168
+ if (needsSeparator)
169
+ lines.push(th.fg("dim", "───"));
170
+ lines.push(th.fg("accent", "[User]"));
171
+ for (const line of wrapTextWithAnsi(text.trim(), width)) {
172
+ lines.push(line);
173
+ }
174
+ }
175
+ else if (msg.role === "assistant") {
176
+ const textParts = [];
177
+ const toolCalls = [];
178
+ for (const c of msg.content) {
179
+ if (c.type === "text" && c.text)
180
+ textParts.push(c.text);
181
+ else if (c.type === "toolCall") {
182
+ toolCalls.push(c.toolName ?? "unknown");
183
+ }
184
+ }
185
+ if (needsSeparator)
186
+ lines.push(th.fg("dim", "───"));
187
+ lines.push(th.bold("[Assistant]"));
188
+ if (textParts.length > 0) {
189
+ for (const line of wrapTextWithAnsi(textParts.join("\n").trim(), width)) {
190
+ lines.push(line);
191
+ }
192
+ }
193
+ for (const name of toolCalls) {
194
+ lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width));
195
+ }
196
+ }
197
+ else if (msg.role === "toolResult") {
198
+ const text = extractText(msg.content);
199
+ const truncated = text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text;
200
+ if (!truncated.trim())
201
+ continue;
202
+ if (needsSeparator)
203
+ lines.push(th.fg("dim", "───"));
204
+ lines.push(th.fg("dim", "[Result]"));
205
+ for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
206
+ lines.push(th.fg("dim", line));
207
+ }
208
+ }
209
+ else if (msg.role === "bashExecution") {
210
+ const bash = msg;
211
+ if (needsSeparator)
212
+ lines.push(th.fg("dim", "───"));
213
+ lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width));
214
+ if (bash.output?.trim()) {
215
+ const out = bash.output.length > 500
216
+ ? bash.output.slice(0, 500) + "... (truncated)"
217
+ : bash.output;
218
+ for (const line of wrapTextWithAnsi(out.trim(), width)) {
219
+ lines.push(th.fg("dim", line));
220
+ }
221
+ }
222
+ }
223
+ else {
224
+ continue;
225
+ }
226
+ needsSeparator = true;
227
+ }
228
+ // Streaming indicator for running agents
229
+ if (this.record.status === "running" && this.activity) {
230
+ const act = describeActivity(this.activity.activeTools, this.activity.responseText);
231
+ lines.push("");
232
+ lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
233
+ }
234
+ return lines;
235
+ }
236
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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",
@@ -271,6 +271,28 @@ export class AgentManager {
271
271
  }
272
272
  }
273
273
 
274
+ /** Whether any agents are still running or queued. */
275
+ hasRunning(): boolean {
276
+ return [...this.agents.values()].some(
277
+ r => r.status === "running" || r.status === "queued",
278
+ );
279
+ }
280
+
281
+ /** Wait for all running and queued agents to complete (including queued ones). */
282
+ async waitForAll(): Promise<void> {
283
+ // Loop because drainQueue respects the concurrency limit — as running
284
+ // agents finish they start queued ones, which need awaiting too.
285
+ while (true) {
286
+ this.drainQueue();
287
+ const pending = [...this.agents.values()]
288
+ .filter(r => r.status === "running" || r.status === "queued")
289
+ .map(r => r.promise)
290
+ .filter(Boolean);
291
+ if (pending.length === 0) break;
292
+ await Promise.allSettled(pending);
293
+ }
294
+ }
295
+
274
296
  dispose() {
275
297
  clearInterval(this.cleanupInterval);
276
298
  // Clear queue
package/src/index.ts CHANGED
@@ -230,6 +230,21 @@ export default function (pi: ExtensionAPI) {
230
230
  widget.update();
231
231
  });
232
232
 
233
+ // Expose manager via Symbol.for() global registry for cross-package access.
234
+ // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
235
+ const MANAGER_KEY = Symbol.for("pi-subagents:manager");
236
+ (globalThis as any)[MANAGER_KEY] = {
237
+ waitForAll: () => manager.waitForAll(),
238
+ hasRunning: () => manager.hasRunning(),
239
+ };
240
+
241
+ // Wait for all subagents on shutdown, then dispose the manager
242
+ pi.on("session_shutdown", async () => {
243
+ delete (globalThis as any)[MANAGER_KEY];
244
+ await manager.waitForAll();
245
+ manager.dispose();
246
+ });
247
+
233
248
  // Live widget: show running agents above editor
234
249
  const widget = new AgentWidget(manager, agentActivity);
235
250