@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 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/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
@@ -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 # Persistent widget: spinners, activity, status icons, theming
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.3.0",
3
+ "version": "0.3.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",
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
+ }