@bacnh85/pi-subagent 0.10.1 → 0.12.2

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.
@@ -17,8 +17,8 @@ import type { SubagentThread } from "./threads.ts";
17
17
  // ---------------------------------------------------------------------------
18
18
 
19
19
  interface ViewerTheme {
20
- fg: (color: string, text: string) => string;
21
- bold: (text: string) => string;
20
+ fg: (color: string, text: string) => string;
21
+ bold: (text: string) => string;
22
22
  }
23
23
 
24
24
  // ---------------------------------------------------------------------------
@@ -26,254 +26,254 @@ interface ViewerTheme {
26
26
  // ---------------------------------------------------------------------------
27
27
 
28
28
  export interface ThreadViewerCallbacks {
29
- onClose: () => void;
30
- onPrev: () => void;
31
- onNext: () => void;
32
- hasPrev: boolean;
33
- hasNext: boolean;
29
+ onClose: () => void;
30
+ onPrev: () => void;
31
+ onNext: () => void;
32
+ hasPrev: boolean;
33
+ hasNext: boolean;
34
34
  }
35
35
 
36
36
  /** Viewport height for the overlay (estimated lines). Must be > 3. */
37
37
  const OVERLAY_HEIGHT = 24;
38
38
 
39
39
  export class ThreadViewer {
40
- private thread: SubagentThread;
41
- private callbacks: ThreadViewerCallbacks;
42
- private theme: ViewerTheme;
43
- private scrollOffset = 0;
44
- private cachedWidth?: number;
45
- private cachedUpdatedAt?: number;
46
- private cachedLines?: string[];
47
-
48
- constructor(thread: SubagentThread, callbacks: ThreadViewerCallbacks, theme: ViewerTheme) {
49
- this.thread = thread;
50
- this.callbacks = callbacks;
51
- this.theme = theme;
52
- }
53
-
54
- handleInput(data: string): void {
55
- if (matchesKey(data, Key.escape)) {
56
- this.callbacks.onClose();
57
- return;
58
- }
59
- if (matchesKey(data, Key.alt("left"))) {
60
- if (this.callbacks.hasPrev) {
61
- this.scrollOffset = 0;
62
- this.callbacks.onPrev();
63
- }
64
- return;
65
- }
66
- if (matchesKey(data, Key.alt("right"))) {
67
- if (this.callbacks.hasNext) {
68
- this.scrollOffset = 0;
69
- this.callbacks.onNext();
70
- }
71
- return;
72
- }
73
- if (matchesKey(data, Key.up)) {
74
- if (this.scrollOffset > 0) {
75
- this.scrollOffset--;
76
- this.invalidate();
77
- }
78
- return;
79
- }
80
- if (matchesKey(data, Key.down)) {
81
- this.scrollOffset++;
82
- this.invalidate();
83
- return;
84
- }
85
- if (matchesKey(data, Key.pageUp)) {
86
- this.scrollOffset = Math.max(0, this.scrollOffset - OVERLAY_HEIGHT);
87
- this.invalidate();
88
- return;
89
- }
90
- if (matchesKey(data, Key.pageDown)) {
91
- this.scrollOffset += OVERLAY_HEIGHT;
92
- this.invalidate();
93
- return;
94
- }
95
- }
96
-
97
- render(width: number): string[] {
98
- // Use updatedAt in cache key so running→completed transitions bust the cache
99
- if (
100
- this.cachedLines &&
101
- this.cachedWidth === width &&
102
- this.cachedUpdatedAt === this.thread.updatedAt
103
- ) {
104
- return this.renderVisible(this.cachedLines, width);
105
- }
106
-
107
- const t = this.theme;
108
- const lines: string[] = [];
109
- const result = this.thread.result;
110
- const isErr = result ? isFailedResult(result) : false;
111
- const status = this.thread.status;
112
-
113
- // Status icon
114
- let icon: string;
115
- if (status === "running") icon = t.fg("warning", "⏳");
116
- else if (status === "aborted") icon = t.fg("error", "✗");
117
- else if (isErr) icon = t.fg("error", "✗");
118
- else icon = t.fg("success", "✓");
119
-
120
- // Mode label
121
- let modeLabel = "";
122
- if (this.thread.mode === "parallel-task") modeLabel = t.fg("muted", " [parallel]");
123
- else if (this.thread.mode === "chain-step") modeLabel = t.fg("muted", " [chain]");
124
-
125
- // Header
126
- const agentColor = this.thread.color ?? "accent";
127
- let header = `${icon} ${t.fg(agentColor, t.bold(this.thread.agentName))}${modeLabel}`;
128
- if (status === "running") header += ` ${t.fg("warning", "(running...)")}`;
129
- else if (status === "aborted") header += ` ${t.fg("error", "[aborted]")}`;
130
- if (result && isErr && result.stopReason && result.stopReason !== "error" && result.stopReason !== "aborted") {
131
- const reasonColor = result.stopReason === "timeout" ? "warning" : "error";
132
- header += ` ${t.fg(reasonColor, `[${result.stopReason}]`)}`;
133
- }
134
- lines.push(truncateToWidth(header, width));
135
-
136
- // Error message
137
- if (result && isErr && result.errorMessage) {
138
- const msgColor = result.stopReason === "timeout" ? "warning" : "error";
139
- lines.push(truncateToWidth(t.fg(msgColor, `Error: ${result.errorMessage}`), width));
140
- }
141
-
142
- lines.push("");
143
-
144
- // Task
145
- lines.push(truncateToWidth(t.fg("muted", "─── Task ───"), width));
146
- lines.push(truncateToWidth(t.fg("dim", this.thread.task), width));
147
- lines.push("");
148
-
149
- if (status === "running") {
150
- const now = Date.now();
151
- const elapsed = Math.floor((now - this.thread.createdAt) / 1000);
152
- const activity = this.thread.lastActivityAt ? `${Math.floor((now - this.thread.lastActivityAt) / 1000)}s ago (${this.thread.lastActivityLabel})` : "none yet";
153
- const idleMs = this.thread.inactivityDeadline ? this.thread.inactivityDeadline - now : 0;
154
- const idle = this.thread.inactivityDeadline ? `${Math.max(0, Math.ceil(idleMs / 1000))}s remaining` : "pending";
155
- lines.push(truncateToWidth(t.fg(idleMs < 30_000 ? "warning" : "muted", `Elapsed ${elapsed}s · last activity ${activity} · idle ${idle}`), width));
156
- }
157
- if (status === "running" && (!result || result.messages.length === 0)) {
158
- lines.push(truncateToWidth(t.fg("muted", "(waiting for first message...)"), width));
159
- } else if (result) {
160
- const displayItems = getDisplayItems(result.messages);
161
- const finalOutput = getFinalOutput(result.messages);
162
-
163
- lines.push(truncateToWidth(t.fg("muted", "─── Output ───"), width));
164
-
165
- if (displayItems.length === 0 && !finalOutput) {
166
- lines.push(truncateToWidth(t.fg("muted", "(no output)"), width));
167
- } else {
168
- const mdTheme = getMarkdownTheme();
169
-
170
- // Show all display items: text + tool calls
171
- for (const item of displayItems) {
172
- if (item.type === "toolCall") {
173
- lines.push(
174
- truncateToWidth(
175
- t.fg("muted", "→ ") + formatToolCall(item.name, item.args, t.fg.bind(t)),
176
- width,
177
- ),
178
- );
179
- } else {
180
- // Assistant text — render as markdown
181
- const contentWidth = Math.max(1, width - 2);
182
- const md = new Markdown(item.text.trim(), 0, 0, mdTheme);
183
- const mdLines = md.render(contentWidth);
184
- for (const mdLine of mdLines) {
185
- lines.push(` ${truncateToWidth(mdLine, contentWidth)}`);
186
- }
187
- }
188
- }
189
- // Check if final output not already shown
190
- const finalAlreadyShown = finalOutput && displayItems.some(
191
- (it) => it.type === "text" && it.text.includes(finalOutput.slice(0, 100)),
192
- );
193
- if (finalOutput && !finalAlreadyShown) {
194
- const contentWidth = Math.max(1, width - 2);
195
- const md = new Markdown(finalOutput.trim(), 0, 0, mdTheme);
196
- for (const mdLine of md.render(contentWidth)) {
197
- lines.push(` ${truncateToWidth(mdLine, contentWidth)}`);
198
- }
199
- }
200
- }
201
-
202
- // Usage stats
203
- const usageStr = formatUsageStats(result.usage, result.model);
204
- if (usageStr) {
205
- lines.push("");
206
- lines.push(truncateToWidth(t.fg("dim", usageStr), width));
207
- }
208
- }
209
-
210
- lines.push("");
211
-
212
- // Footer navigation hints
213
- const navParts: string[] = [];
214
- navParts.push("Esc close");
215
- if (this.callbacks.hasPrev) navParts.push("alt+← prev");
216
- if (this.callbacks.hasNext) navParts.push("alt+→ next");
217
- navParts.push("↑↓ scroll");
218
- lines.push(truncateToWidth(t.fg("dim", navParts.join(" · ")), width));
219
-
220
- this.cachedLines = lines;
221
- this.cachedWidth = width;
222
- this.cachedUpdatedAt = this.thread.updatedAt;
223
-
224
- return this.renderVisible(lines, width);
225
- }
226
-
227
- private renderVisible(allLines: string[], width: number): string[] {
228
- const total = allLines.length;
229
- const maxVisible = Math.max(3, OVERLAY_HEIGHT);
230
-
231
- // Clamp scrollOffset so the last page shows a full viewport minus one indicator line
232
- const maxOffset =
233
- total > maxVisible
234
- ? Math.max(0, total - (maxVisible - 1))
235
- : 0;
236
- const offset = Math.max(0, Math.min(this.scrollOffset, maxOffset));
237
- this.scrollOffset = offset;
238
-
239
- // Reserve space for scroll indicators
240
- const aboveShown = offset > 0;
241
- const belowShown = offset + maxVisible < total;
242
- const indicatorLines = (aboveShown ? 1 : 0) + (belowShown ? 1 : 0);
243
- const bodyHeight = Math.max(1, maxVisible - indicatorLines);
244
-
245
- const visible = allLines.slice(offset, offset + bodyHeight);
246
-
247
- // Scroll indicator at top
248
- if (aboveShown) {
249
- visible.unshift(truncateToWidth(
250
- this.theme.fg("muted", `↑ ${offset} more lines above`),
251
- width,
252
- ));
253
- }
254
- // Scroll indicator at bottom
255
- if (belowShown) {
256
- const remaining = total - offset - bodyHeight;
257
- visible.push(truncateToWidth(
258
- this.theme.fg("muted", `↓ ${remaining} more lines below`),
259
- width,
260
- ));
261
- }
262
-
263
- return visible;
264
- }
265
-
266
- invalidate(): void {
267
- this.cachedWidth = undefined;
268
- this.cachedUpdatedAt = undefined;
269
- this.cachedLines = undefined;
270
- }
271
-
272
- /** Update the thread being displayed (for prev/next navigation). */
273
- setThread(thread: SubagentThread, callbacks: ThreadViewerCallbacks): void {
274
- this.thread = thread;
275
- this.callbacks = callbacks;
276
- this.scrollOffset = 0;
277
- this.invalidate();
278
- }
40
+ private thread: SubagentThread;
41
+ private callbacks: ThreadViewerCallbacks;
42
+ private theme: ViewerTheme;
43
+ private scrollOffset = 0;
44
+ private cachedWidth?: number;
45
+ private cachedUpdatedAt?: number;
46
+ private cachedLines?: string[];
47
+
48
+ constructor(thread: SubagentThread, callbacks: ThreadViewerCallbacks, theme: ViewerTheme) {
49
+ this.thread = thread;
50
+ this.callbacks = callbacks;
51
+ this.theme = theme;
52
+ }
53
+
54
+ handleInput(data: string): void {
55
+ if (matchesKey(data, Key.escape)) {
56
+ this.callbacks.onClose();
57
+ return;
58
+ }
59
+ if (matchesKey(data, Key.alt("left"))) {
60
+ if (this.callbacks.hasPrev) {
61
+ this.scrollOffset = 0;
62
+ this.callbacks.onPrev();
63
+ }
64
+ return;
65
+ }
66
+ if (matchesKey(data, Key.alt("right"))) {
67
+ if (this.callbacks.hasNext) {
68
+ this.scrollOffset = 0;
69
+ this.callbacks.onNext();
70
+ }
71
+ return;
72
+ }
73
+ if (matchesKey(data, Key.up)) {
74
+ if (this.scrollOffset > 0) {
75
+ this.scrollOffset--;
76
+ this.invalidate();
77
+ }
78
+ return;
79
+ }
80
+ if (matchesKey(data, Key.down)) {
81
+ this.scrollOffset++;
82
+ this.invalidate();
83
+ return;
84
+ }
85
+ if (matchesKey(data, Key.pageUp)) {
86
+ this.scrollOffset = Math.max(0, this.scrollOffset - OVERLAY_HEIGHT);
87
+ this.invalidate();
88
+ return;
89
+ }
90
+ if (matchesKey(data, Key.pageDown)) {
91
+ this.scrollOffset += OVERLAY_HEIGHT;
92
+ this.invalidate();
93
+ return;
94
+ }
95
+ }
96
+
97
+ render(width: number): string[] {
98
+ // Use updatedAt in cache key so running→completed transitions bust the cache
99
+ if (
100
+ this.cachedLines &&
101
+ this.cachedWidth === width &&
102
+ this.cachedUpdatedAt === this.thread.updatedAt
103
+ ) {
104
+ return this.renderVisible(this.cachedLines, width);
105
+ }
106
+
107
+ const t = this.theme;
108
+ const lines: string[] = [];
109
+ const result = this.thread.result;
110
+ const isErr = result ? isFailedResult(result) : false;
111
+ const status = this.thread.status;
112
+
113
+ // Status icon
114
+ let icon: string;
115
+ if (status === "running") icon = t.fg("warning", "⏳");
116
+ else if (status === "aborted") icon = t.fg("error", "✗");
117
+ else if (isErr) icon = t.fg("error", "✗");
118
+ else icon = t.fg("success", "✓");
119
+
120
+ // Mode label
121
+ let modeLabel = "";
122
+ if (this.thread.mode === "parallel-task") modeLabel = t.fg("muted", " [parallel]");
123
+ else if (this.thread.mode === "chain-step") modeLabel = t.fg("muted", " [chain]");
124
+
125
+ // Header
126
+ const agentColor = this.thread.color ?? "accent";
127
+ let header = `${icon} ${t.fg(agentColor, t.bold(this.thread.agentName))}${modeLabel}`;
128
+ if (status === "running") header += ` ${t.fg("warning", "(running...)")}`;
129
+ else if (status === "aborted") header += ` ${t.fg("error", "[aborted]")}`;
130
+ if (result && isErr && result.stopReason && result.stopReason !== "error" && result.stopReason !== "aborted") {
131
+ const reasonColor = result.stopReason === "timeout" ? "warning" : "error";
132
+ header += ` ${t.fg(reasonColor, `[${result.stopReason}]`)}`;
133
+ }
134
+ lines.push(truncateToWidth(header, width));
135
+
136
+ // Error message
137
+ if (result && isErr && result.errorMessage) {
138
+ const msgColor = result.stopReason === "timeout" ? "warning" : "error";
139
+ lines.push(truncateToWidth(t.fg(msgColor, `Error: ${result.errorMessage}`), width));
140
+ }
141
+
142
+ lines.push("");
143
+
144
+ // Task
145
+ lines.push(truncateToWidth(t.fg("muted", "─── Task ───"), width));
146
+ lines.push(truncateToWidth(t.fg("dim", this.thread.task), width));
147
+ lines.push("");
148
+
149
+ if (status === "running") {
150
+ const now = Date.now();
151
+ const elapsed = Math.floor((now - this.thread.createdAt) / 1000);
152
+ const activity = this.thread.lastActivityAt ? `${Math.floor((now - this.thread.lastActivityAt) / 1000)}s ago (${this.thread.lastActivityLabel})` : "none yet";
153
+ const idleMs = this.thread.inactivityDeadline ? this.thread.inactivityDeadline - now : 0;
154
+ const idle = this.thread.inactivityDeadline ? `${Math.max(0, Math.ceil(idleMs / 1000))}s remaining` : "pending";
155
+ lines.push(truncateToWidth(t.fg(idleMs < 30_000 ? "warning" : "muted", `Elapsed ${elapsed}s · last activity ${activity} · idle ${idle}`), width));
156
+ }
157
+ if (status === "running" && (!result || result.messages.length === 0)) {
158
+ lines.push(truncateToWidth(t.fg("muted", "(waiting for first message...)"), width));
159
+ } else if (result) {
160
+ const displayItems = getDisplayItems(result.messages);
161
+ const finalOutput = getFinalOutput(result.messages);
162
+
163
+ lines.push(truncateToWidth(t.fg("muted", "─── Output ───"), width));
164
+
165
+ if (displayItems.length === 0 && !finalOutput) {
166
+ lines.push(truncateToWidth(t.fg("muted", "(no output)"), width));
167
+ } else {
168
+ const mdTheme = getMarkdownTheme();
169
+
170
+ // Show all display items: text + tool calls
171
+ for (const item of displayItems) {
172
+ if (item.type === "toolCall") {
173
+ lines.push(
174
+ truncateToWidth(
175
+ t.fg("muted", "→ ") + formatToolCall(item.name, item.args, t.fg.bind(t)),
176
+ width,
177
+ ),
178
+ );
179
+ } else {
180
+ // Assistant text — render as markdown
181
+ const contentWidth = Math.max(1, width - 2);
182
+ const md = new Markdown(item.text.trim(), 0, 0, mdTheme);
183
+ const mdLines = md.render(contentWidth);
184
+ for (const mdLine of mdLines) {
185
+ lines.push(` ${truncateToWidth(mdLine, contentWidth)}`);
186
+ }
187
+ }
188
+ }
189
+ // Check if final output not already shown
190
+ const finalAlreadyShown = finalOutput && displayItems.some(
191
+ (it) => it.type === "text" && it.text.includes(finalOutput.slice(0, 100)),
192
+ );
193
+ if (finalOutput && !finalAlreadyShown) {
194
+ const contentWidth = Math.max(1, width - 2);
195
+ const md = new Markdown(finalOutput.trim(), 0, 0, mdTheme);
196
+ for (const mdLine of md.render(contentWidth)) {
197
+ lines.push(` ${truncateToWidth(mdLine, contentWidth)}`);
198
+ }
199
+ }
200
+ }
201
+
202
+ // Usage stats
203
+ const usageStr = formatUsageStats(result.usage, result.model);
204
+ if (usageStr) {
205
+ lines.push("");
206
+ lines.push(truncateToWidth(t.fg("dim", usageStr), width));
207
+ }
208
+ }
209
+
210
+ lines.push("");
211
+
212
+ // Footer navigation hints
213
+ const navParts: string[] = [];
214
+ navParts.push("Esc close");
215
+ if (this.callbacks.hasPrev) navParts.push("alt+← prev");
216
+ if (this.callbacks.hasNext) navParts.push("alt+→ next");
217
+ navParts.push("↑↓ scroll");
218
+ lines.push(truncateToWidth(t.fg("dim", navParts.join(" · ")), width));
219
+
220
+ this.cachedLines = lines;
221
+ this.cachedWidth = width;
222
+ this.cachedUpdatedAt = this.thread.updatedAt;
223
+
224
+ return this.renderVisible(lines, width);
225
+ }
226
+
227
+ private renderVisible(allLines: string[], width: number): string[] {
228
+ const total = allLines.length;
229
+ const maxVisible = Math.max(3, OVERLAY_HEIGHT);
230
+
231
+ // Clamp scrollOffset so the last page shows a full viewport minus one indicator line
232
+ const maxOffset =
233
+ total > maxVisible
234
+ ? Math.max(0, total - (maxVisible - 1))
235
+ : 0;
236
+ const offset = Math.max(0, Math.min(this.scrollOffset, maxOffset));
237
+ this.scrollOffset = offset;
238
+
239
+ // Reserve space for scroll indicators
240
+ const aboveShown = offset > 0;
241
+ const belowShown = offset + maxVisible < total;
242
+ const indicatorLines = (aboveShown ? 1 : 0) + (belowShown ? 1 : 0);
243
+ const bodyHeight = Math.max(1, maxVisible - indicatorLines);
244
+
245
+ const visible = allLines.slice(offset, offset + bodyHeight);
246
+
247
+ // Scroll indicator at top
248
+ if (aboveShown) {
249
+ visible.unshift(truncateToWidth(
250
+ this.theme.fg("muted", `↑ ${offset} more lines above`),
251
+ width,
252
+ ));
253
+ }
254
+ // Scroll indicator at bottom
255
+ if (belowShown) {
256
+ const remaining = total - offset - bodyHeight;
257
+ visible.push(truncateToWidth(
258
+ this.theme.fg("muted", `↓ ${remaining} more lines below`),
259
+ width,
260
+ ));
261
+ }
262
+
263
+ return visible;
264
+ }
265
+
266
+ invalidate(): void {
267
+ this.cachedWidth = undefined;
268
+ this.cachedUpdatedAt = undefined;
269
+ this.cachedLines = undefined;
270
+ }
271
+
272
+ /** Update the thread being displayed (for prev/next navigation). */
273
+ setThread(thread: SubagentThread, callbacks: ThreadViewerCallbacks): void {
274
+ this.thread = thread;
275
+ this.callbacks = callbacks;
276
+ this.scrollOffset = 0;
277
+ this.invalidate();
278
+ }
279
279
  }