@youngjurry/pi-agents 0.7.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/viewer.ts ADDED
@@ -0,0 +1,414 @@
1
+ import type { KeybindingsManager, Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ AssistantMessageComponent,
4
+ BashExecutionComponent,
5
+ BranchSummaryMessageComponent,
6
+ CompactionSummaryMessageComponent,
7
+ CustomMessageComponent,
8
+ getMarkdownTheme,
9
+ ToolExecutionComponent,
10
+ UserMessageComponent,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import {
13
+ Container,
14
+ Spacer,
15
+ Text,
16
+ truncateToWidth,
17
+ visibleWidth,
18
+ type TUI,
19
+ } from "@earendil-works/pi-tui";
20
+ import { ROOT_PATH, type AgentTranscriptView, type AgentView } from "./types.ts";
21
+
22
+ type ChangeSubscriber = (listener: () => void) => () => void;
23
+
24
+ function statusIcon(status: AgentView["status"]): string {
25
+ switch (status) {
26
+ case "queued": return "◷";
27
+ case "running": return "●";
28
+ case "completed": return "✓";
29
+ case "errored": return "✗";
30
+ case "interrupted": return "■";
31
+ case "pending_init": return "○";
32
+ case "shutdown": return "×";
33
+ }
34
+ }
35
+
36
+ function statusColor(status: AgentView["status"]): "success" | "error" | "warning" | "muted" | "dim" {
37
+ switch (status) {
38
+ case "queued": return "dim";
39
+ case "running": return "warning";
40
+ case "completed": return "success";
41
+ case "errored": return "error";
42
+ case "interrupted": return "muted";
43
+ case "pending_init": return "dim";
44
+ case "shutdown": return "muted";
45
+ }
46
+ }
47
+
48
+ function framedRow(theme: Theme, content: string, innerWidth: number, selected = false): string {
49
+ let body = truncateToWidth(content, innerWidth, "", true);
50
+ if (selected) body = theme.bg("selectedBg", body);
51
+ return `${theme.fg("border", "│")}${body}${theme.fg("border", "│")}`;
52
+ }
53
+
54
+ function framedRule(theme: Theme, innerWidth: number, left: "├" | "╭" | "╰", right: "┤" | "╮" | "╯"): string {
55
+ return theme.fg("border", `${left}${"─".repeat(innerWidth)}${right}`);
56
+ }
57
+
58
+ function itemLine(agent: AgentView, theme: Theme, width: number): string {
59
+ const depth = Math.max(0, agent.path.split("/").filter(Boolean).length - 2);
60
+ const name = agent.path.split("/").at(-1) || agent.path;
61
+ const branch = `${" ".repeat(depth)}${depth > 0 ? "└─ " : ""}`;
62
+ const icon = theme.fg(statusColor(agent.status), statusIcon(agent.status));
63
+ const nickname = agent.nickname ? theme.fg("muted", ` (${agent.nickname})`) : "";
64
+ const residency = agent.status === "queued"
65
+ ? theme.fg("warning", ` [waiting #${agent.queuePosition ?? "?"}]`)
66
+ : agent.loaded ? "" : theme.fg("dim", " [unloaded]");
67
+ const runtime = theme.fg("dim", ` · ${agent.model}`);
68
+ const left = `${branch}${icon} ${theme.fg("accent", name)}${nickname}${residency}${runtime}`;
69
+ const status = agent.status === "queued" ? `queued #${agent.queuePosition ?? "?"}` : agent.status;
70
+ const right = theme.fg("dim", `thinking ${agent.thinkingLevel ?? "unknown"} · ${status}`);
71
+ const available = Math.max(1, width - visibleWidth(right) - 2);
72
+ const clipped = truncateToWidth(left, available, "…");
73
+ return `${clipped}${" ".repeat(Math.max(1, width - visibleWidth(clipped) - visibleWidth(right)))}${right}`;
74
+ }
75
+
76
+ export class AgentPickerComponent {
77
+ private agents: AgentView[] = [];
78
+ private selectedIndex = 0;
79
+ private readonly unsubscribe: () => void;
80
+
81
+ constructor(
82
+ private readonly tui: TUI,
83
+ private readonly theme: Theme,
84
+ private readonly keybindings: KeybindingsManager,
85
+ private readonly loadAgents: () => AgentView[],
86
+ subscribe: ChangeSubscriber,
87
+ private readonly done: (path: string | undefined) => void,
88
+ ) {
89
+ this.refresh();
90
+ this.unsubscribe = subscribe(() => {
91
+ this.refresh();
92
+ this.tui.requestRender();
93
+ });
94
+ }
95
+
96
+ private refresh(): void {
97
+ const selectedPath = this.agents[this.selectedIndex]?.path;
98
+ this.agents = this.loadAgents()
99
+ .filter((agent) => agent.path !== ROOT_PATH)
100
+ .sort((left, right) => (right.lastAssignedAt ?? 0) - (left.lastAssignedAt ?? 0));
101
+ const nextIndex = selectedPath ? this.agents.findIndex((agent) => agent.path === selectedPath) : -1;
102
+ this.selectedIndex = nextIndex >= 0
103
+ ? nextIndex
104
+ : Math.min(this.selectedIndex, Math.max(0, this.agents.length - 1));
105
+ }
106
+
107
+ handleInput(data: string): void {
108
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
109
+ this.done(undefined);
110
+ return;
111
+ }
112
+ if (this.keybindings.matches(data, "tui.select.up")) {
113
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
114
+ } else if (this.keybindings.matches(data, "tui.select.down")) {
115
+ this.selectedIndex = Math.min(this.agents.length - 1, this.selectedIndex + 1);
116
+ } else if (this.keybindings.matches(data, "tui.select.pageUp")) {
117
+ this.selectedIndex = Math.max(0, this.selectedIndex - 8);
118
+ } else if (this.keybindings.matches(data, "tui.select.pageDown")) {
119
+ this.selectedIndex = Math.min(this.agents.length - 1, this.selectedIndex + 8);
120
+ } else if (this.keybindings.matches(data, "tui.select.confirm")) {
121
+ const selected = this.agents[this.selectedIndex];
122
+ if (selected) this.done(selected.path);
123
+ return;
124
+ } else if (data === "r") {
125
+ this.refresh();
126
+ }
127
+ this.tui.requestRender();
128
+ }
129
+
130
+ render(width: number): string[] {
131
+ const innerWidth = Math.max(1, width - 2);
132
+ const maxVisible = Math.max(1, Math.min(14, this.tui.terminal.rows - 9));
133
+ const maxStart = Math.max(0, this.agents.length - maxVisible);
134
+ const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), maxStart));
135
+ const visible = this.agents.slice(start, start + maxVisible);
136
+ const lines = [
137
+ framedRule(this.theme, innerWidth, "╭", "╮"),
138
+ framedRow(this.theme, ` ${this.theme.fg("accent", this.theme.bold("Sub-agent sessions"))}`, innerWidth),
139
+ framedRow(this.theme, ` ${this.theme.fg("dim", "Select an agent to inspect its read-only transcript")}`, innerWidth),
140
+ framedRule(this.theme, innerWidth, "├", "┤"),
141
+ ];
142
+ if (visible.length === 0) {
143
+ lines.push(framedRow(this.theme, ` ${this.theme.fg("muted", "No sub-agents in this root session")}`, innerWidth));
144
+ } else {
145
+ for (let index = 0; index < visible.length; index++) {
146
+ const absoluteIndex = start + index;
147
+ const prefix = absoluteIndex === this.selectedIndex ? this.theme.fg("accent", "› ") : " ";
148
+ lines.push(framedRow(
149
+ this.theme,
150
+ `${prefix}${itemLine(visible[index]!, this.theme, Math.max(1, innerWidth - 2))}`,
151
+ innerWidth,
152
+ absoluteIndex === this.selectedIndex,
153
+ ));
154
+ }
155
+ }
156
+ if (this.agents.length > maxVisible) {
157
+ lines.push(framedRow(this.theme, ` ${this.theme.fg("dim", `${this.selectedIndex + 1}/${this.agents.length}`)}`, innerWidth));
158
+ }
159
+ lines.push(
160
+ framedRule(this.theme, innerWidth, "├", "┤"),
161
+ framedRow(this.theme, ` ${this.theme.fg("dim", "↑↓ navigate · Enter inspect · r refresh · Esc close")}`, innerWidth),
162
+ framedRule(this.theme, innerWidth, "╰", "╯"),
163
+ );
164
+ return lines;
165
+ }
166
+
167
+ invalidate(): void {}
168
+
169
+ dispose(): void {
170
+ this.unsubscribe();
171
+ }
172
+ }
173
+
174
+ function contentText(content: string | Array<{ type: string; text?: string }>): string {
175
+ if (typeof content === "string") return content;
176
+ const parts = content.map((part) => part.type === "text" ? (part.text ?? "") : `[${part.type}]`);
177
+ return parts.filter(Boolean).join("\n");
178
+ }
179
+
180
+ function formatTimestamp(timestamp: number): string {
181
+ if (!Number.isFinite(timestamp)) return "unknown";
182
+ return new Date(timestamp).toLocaleString();
183
+ }
184
+
185
+ export class AgentTranscriptViewer {
186
+ private snapshot?: AgentTranscriptView;
187
+ private error?: string;
188
+ private content = new Container();
189
+ private scrollOffset = 0;
190
+ private followTail = true;
191
+ private expandedTools = false;
192
+ private hideThinking = false;
193
+ private lastBodyHeight = 1;
194
+ private lastBodyLines = 0;
195
+ private refreshTimer?: ReturnType<typeof setTimeout>;
196
+ private readonly unsubscribe: () => void;
197
+ private toolDefinitions = new Map<string, ToolDefinition>();
198
+
199
+ constructor(
200
+ private readonly tui: TUI,
201
+ private readonly theme: Theme,
202
+ private readonly keybindings: KeybindingsManager,
203
+ private readonly loadTranscript: () => AgentTranscriptView,
204
+ subscribe: ChangeSubscriber,
205
+ private readonly done: () => void,
206
+ ) {
207
+ this.refresh();
208
+ this.unsubscribe = subscribe(() => this.scheduleRefresh());
209
+ }
210
+
211
+ private scheduleRefresh(): void {
212
+ if (this.refreshTimer) return;
213
+ this.refreshTimer = setTimeout(() => {
214
+ this.refreshTimer = undefined;
215
+ this.refresh();
216
+ this.tui.requestRender();
217
+ }, 200);
218
+ }
219
+
220
+ private refresh(): void {
221
+ try {
222
+ this.snapshot = this.loadTranscript();
223
+ this.toolDefinitions = new Map(this.snapshot.toolDefinitions.map((tool) => [tool.name, tool]));
224
+ this.error = undefined;
225
+ this.rebuildContent();
226
+ } catch (error) {
227
+ this.error = error instanceof Error ? error.message : String(error);
228
+ }
229
+ }
230
+
231
+ private rebuildContent(): void {
232
+ this.content = new Container();
233
+ const snapshot = this.snapshot;
234
+ if (!snapshot) return;
235
+ const markdownTheme = getMarkdownTheme();
236
+ const pendingTools = new Map<string, ToolExecutionComponent>();
237
+
238
+ for (const message of snapshot.messages) {
239
+ switch (message.role) {
240
+ case "user": {
241
+ const text = contentText(message.content);
242
+ if (text) {
243
+ if (this.content.children.length > 0) this.content.addChild(new Spacer(1));
244
+ this.content.addChild(new UserMessageComponent(text, markdownTheme, 0));
245
+ }
246
+ break;
247
+ }
248
+ case "assistant": {
249
+ this.content.addChild(new AssistantMessageComponent(message, this.hideThinking, markdownTheme, "thinking hidden", 0));
250
+ for (const block of message.content) {
251
+ if (block.type !== "toolCall") continue;
252
+ const component = new ToolExecutionComponent(
253
+ block.name,
254
+ block.id,
255
+ block.arguments,
256
+ { showImages: false },
257
+ this.toolDefinitions.get(block.name),
258
+ this.tui,
259
+ snapshot.cwd,
260
+ );
261
+ component.markExecutionStarted();
262
+ component.setArgsComplete();
263
+ component.setExpanded(this.expandedTools);
264
+ this.content.addChild(component);
265
+ if (message.stopReason === "aborted" || message.stopReason === "error") {
266
+ component.updateResult({
267
+ content: [{ type: "text", text: message.errorMessage || (message.stopReason === "aborted" ? "Operation aborted" : "Error") }],
268
+ isError: true,
269
+ });
270
+ } else {
271
+ pendingTools.set(block.id, component);
272
+ }
273
+ }
274
+ break;
275
+ }
276
+ case "toolResult": {
277
+ const component = pendingTools.get(message.toolCallId);
278
+ if (component) {
279
+ component.updateResult(message);
280
+ pendingTools.delete(message.toolCallId);
281
+ } else {
282
+ const text = contentText(message.content);
283
+ this.content.addChild(new Text(
284
+ this.theme.fg(message.isError ? "error" : "toolOutput", `[${message.toolName}] ${text}`),
285
+ 1,
286
+ 0,
287
+ ));
288
+ }
289
+ break;
290
+ }
291
+ case "custom": {
292
+ if (message.display) {
293
+ const component = new CustomMessageComponent(message, undefined, markdownTheme, 0);
294
+ component.setExpanded(this.expandedTools);
295
+ this.content.addChild(component);
296
+ }
297
+ break;
298
+ }
299
+ case "bashExecution": {
300
+ const component = new BashExecutionComponent(message.command, this.tui, message.excludeFromContext);
301
+ component.appendOutput(message.output + (message.truncated ? "\n[output truncated]" : ""));
302
+ component.setExpanded(this.expandedTools);
303
+ component.setComplete(message.exitCode, message.cancelled, undefined, message.fullOutputPath);
304
+ this.content.addChild(component);
305
+ break;
306
+ }
307
+ case "compactionSummary": {
308
+ const component = new CompactionSummaryMessageComponent(message, markdownTheme);
309
+ component.setExpanded(this.expandedTools);
310
+ this.content.addChild(component);
311
+ break;
312
+ }
313
+ case "branchSummary": {
314
+ const component = new BranchSummaryMessageComponent(message, markdownTheme);
315
+ component.setExpanded(this.expandedTools);
316
+ this.content.addChild(component);
317
+ break;
318
+ }
319
+ }
320
+ }
321
+ }
322
+
323
+ private maxScroll(): number {
324
+ return Math.max(0, this.lastBodyLines - this.lastBodyHeight);
325
+ }
326
+
327
+ handleInput(data: string): void {
328
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
329
+ this.done();
330
+ return;
331
+ }
332
+ if (this.keybindings.matches(data, "app.tools.expand")) {
333
+ this.expandedTools = !this.expandedTools;
334
+ this.rebuildContent();
335
+ } else if (this.keybindings.matches(data, "app.thinking.toggle")) {
336
+ this.hideThinking = !this.hideThinking;
337
+ this.rebuildContent();
338
+ } else if (this.keybindings.matches(data, "tui.select.up")) {
339
+ this.followTail = false;
340
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
341
+ } else if (this.keybindings.matches(data, "tui.select.down")) {
342
+ this.scrollOffset = Math.min(this.maxScroll(), this.scrollOffset + 1);
343
+ this.followTail = this.scrollOffset >= this.maxScroll();
344
+ } else if (this.keybindings.matches(data, "tui.editor.cursorLeft")) {
345
+ this.followTail = false;
346
+ this.scrollOffset = Math.max(0, this.scrollOffset - this.lastBodyHeight);
347
+ } else if (this.keybindings.matches(data, "tui.editor.cursorRight")) {
348
+ this.scrollOffset = Math.min(this.maxScroll(), this.scrollOffset + this.lastBodyHeight);
349
+ this.followTail = this.scrollOffset >= this.maxScroll();
350
+ } else if (data === "t") {
351
+ this.followTail = false;
352
+ this.scrollOffset = 0;
353
+ } else if (data === "b") {
354
+ this.followTail = true;
355
+ this.scrollOffset = this.maxScroll();
356
+ } else if (data === "r") {
357
+ this.refresh();
358
+ }
359
+ this.tui.requestRender();
360
+ }
361
+
362
+ render(width: number): string[] {
363
+ const innerWidth = Math.max(1, width - 2);
364
+ const bodyHeight = Math.max(1, Math.floor(this.tui.terminal.rows * 0.84) - 7);
365
+ const bodyLines = this.error
366
+ ? [this.theme.fg("error", this.error)]
367
+ : this.content.render(innerWidth);
368
+ this.lastBodyHeight = bodyHeight;
369
+ this.lastBodyLines = bodyLines.length;
370
+ const maxScroll = Math.max(0, bodyLines.length - bodyHeight);
371
+ if (this.followTail) this.scrollOffset = maxScroll;
372
+ else this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
373
+ const visible = bodyLines.slice(this.scrollOffset, this.scrollOffset + bodyHeight);
374
+ const snapshot = this.snapshot;
375
+ const agent = snapshot?.agent;
376
+ const title = agent
377
+ ? `${statusIcon(agent.status)} ${agent.path}${agent.nickname ? ` (${agent.nickname})` : ""}`
378
+ : "Sub-agent transcript";
379
+ const metadata = agent
380
+ ? `${agent.status}${agent.status === "queued" ? ` #${agent.queuePosition ?? "?"} (waiting for execution slot)` : ""} · thinking ${agent.thinkingLevel ?? "unknown"} · ${agent.model}${agent.role ? ` · ${agent.role}` : ""} · ${snapshot.messages.length} messages`
381
+ : "unavailable";
382
+ const scroll = bodyLines.length > bodyHeight
383
+ ? ` · lines ${this.scrollOffset + 1}-${Math.min(bodyLines.length, this.scrollOffset + bodyHeight)}/${bodyLines.length}`
384
+ : "";
385
+ const lines = [
386
+ framedRule(this.theme, innerWidth, "╭", "╮"),
387
+ framedRow(this.theme, ` ${this.theme.fg("accent", this.theme.bold(title))}`, innerWidth),
388
+ framedRow(this.theme, ` ${this.theme.fg("dim", `${metadata}${scroll}`)}`, innerWidth),
389
+ framedRule(this.theme, innerWidth, "├", "┤"),
390
+ ];
391
+ for (const line of visible) lines.push(framedRow(this.theme, line, innerWidth));
392
+ for (let index = visible.length; index < bodyHeight; index++) lines.push(framedRow(this.theme, "", innerWidth));
393
+ lines.push(
394
+ framedRule(this.theme, innerWidth, "├", "┤"),
395
+ framedRow(
396
+ this.theme,
397
+ ` ${this.theme.fg("dim", `↑/↓ move · ←/→ page · t top · b bottom · Ctrl+O tools ${this.expandedTools ? "on" : "off"} · Ctrl+T thinking ${this.hideThinking ? "hidden" : "shown"} · r refresh · Esc back`)}`,
398
+ innerWidth,
399
+ ),
400
+ framedRow(this.theme, ` ${this.theme.fg("dim", snapshot ? `Read-only · created ${formatTimestamp(snapshot.createdAt)}` : "Read-only")}`, innerWidth),
401
+ framedRule(this.theme, innerWidth, "╰", "╯"),
402
+ );
403
+ return lines;
404
+ }
405
+
406
+ invalidate(): void {
407
+ this.content.invalidate();
408
+ }
409
+
410
+ dispose(): void {
411
+ if (this.refreshTimer) clearTimeout(this.refreshTimer);
412
+ this.unsubscribe();
413
+ }
414
+ }