@tshu-w/pi-agents 0.2.2 → 0.4.0

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/src/ui/viewer.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  getMarkdownTheme,
11
11
  getSelectListTheme,
12
12
  keyHint,
13
+ keyText,
13
14
  parseSkillBlock,
14
15
  SkillInvocationMessageComponent,
15
16
  ToolExecutionComponent,
@@ -24,7 +25,9 @@ import {
24
25
  Container,
25
26
  fuzzyFilter,
26
27
  Input,
28
+ isViewportTUI,
27
29
  Loader,
30
+ ScrollView,
28
31
  SelectList,
29
32
  Spacer,
30
33
  Text,
@@ -35,8 +38,12 @@ import {
35
38
  type KeybindingsManager,
36
39
  type SelectItem,
37
40
  type TUI,
41
+ type TuiMouseEvent,
42
+ type TuiMouseEventResult,
43
+ type ViewportTUI,
44
+ VStack,
38
45
  } from "@earendil-works/pi-tui";
39
- import { MESSAGE_TYPE, nodes, onTreeChange, programLabel, programsOf, type AgentNode, type Entry } from "../agents/registry.ts";
46
+ import { MESSAGE_TYPE, nodes, onTreeChange, programLabel, tasksOf, type AgentNode, type Entry, type Task } from "../agents/registry.ts";
40
47
  import { latestActivity } from "../agents/search.ts";
41
48
  import { PROGRAM_TOOL_NAME } from "../programs/execute.ts";
42
49
  import type { Programs } from "../programs/programs.ts";
@@ -46,6 +53,9 @@ import { execFileSync } from "node:child_process";
46
53
  type Message = AgentSession["messages"][number];
47
54
  type ToolResult = Parameters<ToolExecutionComponent["updateResult"]>[0];
48
55
  type StatusIndicator = Parameters<CustomEditor["setWorkingStatusIndicator"]>[0];
56
+ type Conversation = NonNullable<ReturnType<AgentNode["agents"]["conversation"]>>;
57
+ type ToolCall = Extract<Extract<Message, { role: "assistant" }>["content"][number], { type: "toolCall" }>;
58
+ type Display = ReturnType<AgentViewer["display"]>;
49
59
 
50
60
  /** Stops the elapsed-time timer of a tool component the viewer drops; only a final result stops it otherwise. */
51
61
  function stopTimer(tool: ToolExecutionComponent): void {
@@ -87,6 +97,39 @@ function footerData(cwd: string, ctx: ExtensionContext): ReadonlyFooterDataProvi
87
97
  };
88
98
  }
89
99
 
100
+ /** The lines a scroll key of Pi's fullscreen mode moves the view up, negative for down, or undefined for another key. */
101
+ function scrollKey(keys: KeybindingsManager, data: string, page: number): number | undefined {
102
+ const moves = [
103
+ ["tui.altScreen.pageUp", page], ["tui.altScreen.pageDown", -page],
104
+ ["tui.altScreen.halfPageUp", Math.ceil(page / 2)], ["tui.altScreen.halfPageDown", -Math.ceil(page / 2)],
105
+ ["tui.altScreen.lineUp", 1], ["tui.altScreen.lineDown", -1],
106
+ ["tui.altScreen.top", Infinity], ["tui.altScreen.bottom", -Infinity],
107
+ ] as const;
108
+ return moves.find(([action]) => keys.matches(data, action))?.[1];
109
+ }
110
+
111
+ /**
112
+ * Shows a viewer in place of Pi's chat in fullscreen mode, and returns a function that restores it. The viewer takes
113
+ * Pi's layout, so its transcript above `dock` is browsed as Pi's transcript.
114
+ */
115
+ function mountViewer(tui: ViewportTUI, theme: Theme, ctx: ExtensionContext, transcript: Component, dock: Component[]): () => void {
116
+ const previous = (tui as unknown as { layoutRoot?: Component }).layoutRoot;
117
+ const view = new ScrollView(transcript, {
118
+ follow: "end",
119
+ primary: true,
120
+ overscroll: "chain",
121
+ scrollbar: agentSession(ctx.sessionManager)?.settingsManager.getFullscreenScrollbar() ?? "auto",
122
+ scrollbarTrackStyle: (text) => theme.fg("scrollbarTrack", text),
123
+ scrollbarThumbStyle: (text) => theme.fg("scrollbarThumb", text),
124
+ });
125
+ // As Pi's fullscreen layout: the transcript takes the room the dock leaves.
126
+ tui.setLayoutRoot(new VStack([
127
+ { component: view, basis: 0, grow: 1, shrink: 1, minSize: 1 },
128
+ { component: new VStack(dock.map((component) => ({ component, shrink: 1, minSize: 0 }))), basis: "auto", grow: 0, shrink: 1, minSize: 1 },
129
+ ]));
130
+ return () => tui.setLayoutRoot(previous);
131
+ }
132
+
90
133
  /** The user's view of one owned Agent: its conversation, and an editor whose text goes to it. */
91
134
  class AgentViewer implements Component, Focusable {
92
135
  private readonly editor: CustomEditor;
@@ -94,15 +137,20 @@ class AgentViewer implements Component, Focusable {
94
137
  private readonly footer: FooterComponent;
95
138
  private session?: AgentSession;
96
139
  private streaming?: Message;
97
- /** The streaming message's component and tool calls from the last rebuild, which its updates change in place. */
98
- private live?: { component: AssistantMessageComponent; calls: string };
99
- /** Tool components without a result from the last rebuild. */
140
+ /** The component of the streaming assistant message and its tool calls, which its updates change in place. */
141
+ private live?: { component: AssistantMessageComponent; calls: Set<string> };
142
+ /** Tool components without a result. */
100
143
  private tools = new Map<string, ToolExecutionComponent>();
101
144
  private running = new Map<string, { partial?: ToolResult }>();
145
+ /** The conversation's messages the transcript shows, by their count and the last one. */
146
+ private shown = 0;
147
+ private last?: Message;
148
+ /** Streaming messages that ended in the transcript before reaching the conversation's messages. */
149
+ private ended = 0;
102
150
  private expanded = false;
103
- /** Lines scrolled up from the end. */
151
+ /** Lines scrolled up from the end, in regular mode. */
104
152
  private scroll = 0;
105
- private dirty = true;
153
+ private fullscreen: boolean;
106
154
  private status = "";
107
155
  private indicator?: WorkingStatus;
108
156
  /** Inputs sent from the viewer that the Agent has not received yet, as Pi shows queued messages. */
@@ -136,11 +184,21 @@ class AgentViewer implements Component, Focusable {
136
184
  this.editor.onAction("app.message.followUp", () => this.send(this.editor.getText(), "followUp"));
137
185
  this.editor.onAction("app.tools.expand", () => {
138
186
  this.expanded = !this.expanded;
139
- this.dirty = true;
187
+ // As Pi does, in place.
188
+ for (const child of this.transcript.children) (child as { setExpanded?: (expanded: boolean) => void }).setExpanded?.(this.expanded);
140
189
  this.tui.requestRender();
141
190
  });
142
191
  this.disposers.push(onTreeChange(() => this.refresh()));
143
192
  this.attach();
193
+ this.fullscreen = isViewportTUI(tui);
194
+ if (isViewportTUI(tui)) {
195
+ const part = (render: (width: number) => string[]): Component => ({ render, invalidate: () => this.invalidate() });
196
+ this.disposers.push(mountViewer(tui, theme, ctx, part((width) => this.renderTranscript(width)), [
197
+ part((width) => [" ".repeat(width), ...this.renderQueue(width)]),
198
+ this.editor,
199
+ part((width) => this.renderFooter(width)),
200
+ ]));
201
+ }
144
202
  }
145
203
 
146
204
  get focused(): boolean {
@@ -153,28 +211,20 @@ class AgentViewer implements Component, Focusable {
153
211
  }
154
212
 
155
213
  handleInput(data: string): void {
156
- const page = Math.max(1, this.tui.terminal.rows - 8);
157
- // The page keys of Pi's fullscreen mode.
158
- if (this.keys.matches(data, "tui.altScreen.pageUp")) this.scroll += page;
159
- else if (this.keys.matches(data, "tui.altScreen.pageDown")) this.scroll = Math.max(0, this.scroll - page);
214
+ const lines = this.fullscreen ? undefined : scrollKey(this.keys, data, Math.max(1, this.tui.terminal.rows - 8));
215
+ if (lines !== undefined) this.scroll = Math.max(0, this.scroll + lines);
160
216
  else this.editor.handleInput(data);
161
217
  this.tui.requestRender();
162
218
  }
163
219
 
220
+ /** In fullscreen mode, the viewer draws itself in place of Pi's chat (`mountViewer`); in regular mode, over the screen. */
164
221
  render(width: number): string[] {
165
- const conversation = this.owner.agents.conversation(this.entry.id);
166
- if (this.dirty) this.rebuild(conversation);
167
- this.syncStatus(conversation);
168
- const queue = this.outbox.map(({ text, delivery }) =>
169
- truncateToWidth(this.theme.fg("dim", ` ${delivery === "steer" ? "Steering" : "Follow-up"}: ${text.replace(/\s+/g, " ")}`), width));
222
+ if (this.fullscreen) return [];
223
+ const queue = this.renderQueue(width);
170
224
  const editor = this.editor.render(width);
171
- const [cwd = "", ...footer] = this.footer.render(width);
172
- // The way back sits at the right of the footer's first line, when it fits.
173
- const back = keyHint("app.clear", "back");
174
- const gap = width - visibleWidth(cwd) - visibleWidth(back);
175
- footer.unshift(gap >= 2 ? cwd + " ".repeat(gap) + back : cwd);
225
+ const footer = this.renderFooter(width);
176
226
  const budget = Math.max(1, this.tui.terminal.rows - queue.length - editor.length - footer.length - 1);
177
- const lines = this.transcript.render(width);
227
+ const lines = this.renderTranscript(width);
178
228
  this.scroll = Math.min(this.scroll, Math.max(0, lines.length - budget));
179
229
  const end = lines.length - this.scroll;
180
230
  const shown = lines.slice(Math.max(0, end - budget), end);
@@ -184,8 +234,34 @@ class AgentViewer implements Component, Focusable {
184
234
  return [...padding, ...shown, blank, ...queue, ...editor, ...footer];
185
235
  }
186
236
 
237
+ private renderTranscript(width: number): string[] {
238
+ this.prepare();
239
+ return this.transcript.render(width);
240
+ }
241
+
242
+ /** Brings the transcript, the outbox and the working status up to the conversation, before any of them renders. */
243
+ private prepare(): void {
244
+ const conversation = this.owner.agents.conversation(this.entry.id);
245
+ this.sync(conversation);
246
+ this.syncStatus(conversation);
247
+ }
248
+
249
+ private renderQueue(width: number): string[] {
250
+ this.prepare();
251
+ return this.outbox.map(({ text, delivery }) =>
252
+ truncateToWidth(this.theme.fg("dim", ` ${delivery === "steer" ? "Steering" : "Follow-up"}: ${text.replace(/\s+/g, " ")}`), width));
253
+ }
254
+
255
+ /** Pi's footer for the Agent, with the way back at the right of its first line when it fits, marked so it stands out. */
256
+ private renderFooter(width: number): string[] {
257
+ const [cwd = "", ...footer] = this.footer.render(width);
258
+ const back = this.theme.fg("accent", `${keyText("app.clear")} back`);
259
+ const gap = width - visibleWidth(cwd) - visibleWidth(back);
260
+ return [gap >= 2 ? cwd + " ".repeat(gap) + back : cwd, ...footer];
261
+ }
262
+
187
263
  invalidate(): void {
188
- this.dirty = true;
264
+ this.transcript.invalidate();
189
265
  this.editor.invalidate();
190
266
  }
191
267
 
@@ -206,7 +282,7 @@ class AgentViewer implements Component, Focusable {
206
282
  this.editor.addToHistory(trimmed);
207
283
  this.editor.setText("");
208
284
  this.scroll = 0;
209
- this.dirty = true;
285
+ this.tui.requestRender();
210
286
  } catch (error) {
211
287
  this.ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
212
288
  }
@@ -225,35 +301,32 @@ class AgentViewer implements Component, Focusable {
225
301
  const tool = this.running.get(event.toolCallId);
226
302
  if (tool) tool.partial = event.partialResult as ToolResult;
227
303
  } else if (event.type === "tool_execution_end") this.running.delete(event.toolCallId);
228
- if (!this.update(event)) this.dirty = true;
304
+ this.update(event);
229
305
  this.tui.requestRender();
230
306
  }));
231
307
  }
232
308
 
233
- /** Applies a streaming update to the shown components, as Pi does; false when the transcript needs a rebuild. */
234
- private update(event: AgentSessionEvent): boolean {
235
- if (this.dirty) return false;
236
- if (event.type === "message_update") {
237
- if (!this.live || event.message.role !== "assistant") return false;
238
- const calls = event.message.content.flatMap((part) => part.type === "toolCall" ? [part] : []);
239
- if (calls.map((call) => call.id).join("\n") !== this.live.calls) return false;
309
+ /** Applies a streaming update to the shown components in place, as Pi does; the next render adds new messages. */
310
+ private update(event: AgentSessionEvent): void {
311
+ if (event.type === "message_update" && this.live && event.message.role === "assistant") {
240
312
  this.live.component.updateContent(event.message, true);
241
- for (const call of calls) this.tools.get(call.id)?.updateArgs(call.arguments);
242
- return true;
243
- }
244
- if (event.type === "tool_execution_start" || event.type === "tool_execution_update") {
245
- const tool = this.tools.get(event.toolCallId);
246
- if (!tool) return false;
247
- if (event.type === "tool_execution_start") tool.markExecutionStarted();
248
- else tool.updateResult(event.partialResult as ToolResult, true);
249
- return true;
250
- }
251
- return false;
313
+ const conversation = this.owner.agents.conversation(this.entry.id);
314
+ for (const part of event.message.content) {
315
+ if (part.type !== "toolCall") continue;
316
+ if (this.live.calls.has(part.id)) this.tools.get(part.id)?.updateArgs(part.arguments);
317
+ else if (conversation) this.addTool(part, conversation, this.display(conversation), this.live.calls);
318
+ }
319
+ } else if (event.type === "message_end" && this.live && event.message.role === "assistant") {
320
+ // As Pi does, the streaming message's component ends with it, before the next message streams.
321
+ const conversation = this.owner.agents.conversation(this.entry.id);
322
+ if (conversation) this.end(event.message, conversation, this.display(conversation));
323
+ this.ended++;
324
+ } else if (event.type === "tool_execution_start") this.tools.get(event.toolCallId)?.markExecutionStarted();
325
+ else if (event.type === "tool_execution_update") this.tools.get(event.toolCallId)?.updateResult(event.partialResult as ToolResult, true);
252
326
  }
253
327
 
254
328
  private refresh(): void {
255
329
  this.attach();
256
- this.dirty = true;
257
330
  this.tui.requestRender();
258
331
  }
259
332
 
@@ -261,7 +334,7 @@ class AgentViewer implements Component, Focusable {
261
334
  * The working status in the editor's border, and its color from the Agent's thinking level, as in Pi: `Working`,
262
335
  * spinner and text in the border's color.
263
336
  */
264
- private syncStatus(conversation: ReturnType<AgentNode["agents"]["conversation"]>): void {
337
+ private syncStatus(conversation: Conversation | undefined): void {
265
338
  this.editor.borderColor = this.theme.getThinkingBorderColor((conversation?.thinkingLevel ?? "off") as never);
266
339
  const state = this.owner.agents.counts(this.entry.id)?.state;
267
340
  const text = state === "running" || state === "waiting" ? "Working" : "";
@@ -273,103 +346,145 @@ class AgentViewer implements Component, Focusable {
273
346
  this.editor.setWorkingStatusIndicator(this.indicator as unknown as StatusIndicator);
274
347
  }
275
348
 
276
- private rebuild(conversation: ReturnType<AgentNode["agents"]["conversation"]>): void {
277
- this.dirty = false;
278
- this.transcript.clear();
279
- this.live = undefined;
280
- for (const tool of this.tools.values()) stopTimer(tool);
281
- this.tools.clear();
282
- if (!conversation) return;
283
- const messages = [...conversation.messages];
349
+ /** The transcript's display settings: as Pi's chat shows messages, with the settings of the Session the viewer is in. */
350
+ private display(conversation: Conversation) {
351
+ const current = agentSession(this.ctx.sessionManager);
352
+ const settings = current?.settingsManager;
353
+ const pad = settings?.getOutputPad() ?? 1;
354
+ return {
355
+ settings,
356
+ markdown: settings ? { ...getMarkdownTheme(), codeBlockIndent: settings.getCodeBlockIndent() } : getMarkdownTheme(),
357
+ transformers: current?.extensionRunner.getMarkdownTransformers(),
358
+ pad,
359
+ runner: (conversation.session ?? current)?.extensionRunner,
360
+ images: { showImages: settings?.getShowImages() ?? false, imageWidthCells: settings?.getImageWidthCells(), outputPad: pad },
361
+ };
362
+ }
363
+
364
+ /** Adds the conversation's new messages to the transcript, and starts over when its earlier messages changed. */
365
+ private sync(conversation: Conversation | undefined): void {
366
+ const messages = conversation?.messages ?? [];
367
+ if (messages.length < this.shown || (this.shown > 0 && messages[this.shown - 1] !== this.last)) {
368
+ this.transcript.clear();
369
+ this.live = undefined;
370
+ for (const tool of this.tools.values()) stopTimer(tool);
371
+ this.tools.clear();
372
+ this.shown = 0;
373
+ this.ended = 0;
374
+ }
284
375
  // An input leaves the outbox once it is in the conversation, or when the Agent ends its work without it.
285
376
  const state = this.owner.agents.counts(this.entry.id)?.state;
286
377
  const working = state !== undefined && state !== "idle";
287
378
  this.outbox = this.outbox.filter(({ text, after }) => working && !messages.slice(after).some((message) =>
288
379
  message.role === "custom" && message.content === text && (message.details as { user?: boolean } | undefined)?.user));
289
- if (this.streaming && !messages.includes(this.streaming)) messages.push(this.streaming);
290
- // As Pi's chat shows them, with the display settings of the Session the viewer is in.
291
- const current = agentSession(this.ctx.sessionManager);
292
- const settings = current?.settingsManager;
293
- const markdown = settings ? { ...getMarkdownTheme(), codeBlockIndent: settings.getCodeBlockIndent() } : getMarkdownTheme();
294
- const transformers = current?.extensionRunner.getMarkdownTransformers();
295
- const pad = settings?.getOutputPad() ?? 1;
296
- const runner = (conversation.session ?? current)?.extensionRunner;
297
- const images = { showImages: settings?.getShowImages() ?? false, imageWidthCells: settings?.getImageWidthCells(), outputPad: pad };
298
- for (const message of messages) {
299
- if (message.role === "assistant") {
300
- const component = new AssistantMessageComponent(message, settings?.getHideThinkingBlock() ?? false, markdown, undefined, pad, transformers);
301
- if (message === this.streaming) {
302
- // As Pi does, so Markdown transformers know the message is still streaming.
303
- component.updateContent(message, true);
304
- const calls = message.content.flatMap((part) => part.type === "toolCall" ? [part.id] : []);
305
- this.live = { component, calls: calls.join("\n") };
306
- }
380
+ if (!conversation) return;
381
+ const streaming = this.streaming?.role === "assistant" && !this.live && !messages.includes(this.streaming) ? this.streaming : undefined;
382
+ if (messages.length === this.shown && !streaming) return;
383
+ const display = this.display(conversation);
384
+ for (const message of messages.slice(this.shown)) {
385
+ if (message.role !== "assistant") this.add(message, conversation, display);
386
+ // An ended streaming message is shown already.
387
+ else if (this.ended > 0) this.ended--;
388
+ else if (this.live) this.end(message, conversation, display);
389
+ else this.add(message, conversation, display);
390
+ }
391
+ this.shown = messages.length;
392
+ this.last = messages.at(-1);
393
+ if (streaming?.role === "assistant") {
394
+ const component = new AssistantMessageComponent(streaming, display.settings?.getHideThinkingBlock() ?? false, display.markdown, undefined, display.pad, display.transformers);
395
+ // As Pi does, so Markdown transformers know the message is still streaming.
396
+ component.updateContent(streaming, true);
397
+ this.transcript.addChild(component);
398
+ this.live = { component, calls: new Set() };
399
+ for (const part of streaming.content) if (part.type === "toolCall") this.addTool(part, conversation, display, this.live.calls);
400
+ }
401
+ }
402
+
403
+ /** Shows the streaming message whole as it ended. */
404
+ private end(message: Message, conversation: Conversation, display: Display): void {
405
+ if (!this.live || message.role !== "assistant") return;
406
+ this.live.component.updateContent(message);
407
+ for (const part of message.content) if (part.type === "toolCall" && !this.live.calls.has(part.id)) this.addTool(part, conversation, display);
408
+ this.live = undefined;
409
+ this.settle(message);
410
+ }
411
+
412
+ private addTool(call: ToolCall, conversation: Conversation, display: Display, calls?: Set<string>): void {
413
+ const tool = new ToolExecutionComponent(call.name, call.id, call.arguments, display.images,
414
+ conversation.session?.getToolDefinition(call.name), this.tui, conversation.cwd);
415
+ tool.setExpanded(this.expanded);
416
+ this.transcript.addChild(tool);
417
+ this.tools.set(call.id, tool);
418
+ calls?.add(call.id);
419
+ const running = this.running.get(call.id);
420
+ if (running) {
421
+ tool.markExecutionStarted();
422
+ if (running.partial) tool.updateResult(running.partial, true);
423
+ }
424
+ }
425
+
426
+ /** Ends the tool calls of an assistant message that stopped without running them. */
427
+ private settle(message: Message): void {
428
+ if (message.role !== "assistant" || (message.stopReason !== "aborted" && message.stopReason !== "error")) return;
429
+ const text = message.stopReason === "aborted" ? "Operation aborted" : message.errorMessage || "Error";
430
+ for (const part of message.content) {
431
+ if (part.type !== "toolCall") continue;
432
+ this.tools.get(part.id)?.updateResult({ content: [{ type: "text", text }], isError: true });
433
+ this.tools.delete(part.id);
434
+ }
435
+ }
436
+
437
+ private add(message: Message, conversation: Conversation, display: Display): void {
438
+ const { markdown, transformers, pad, runner } = display;
439
+ if (message.role === "assistant") {
440
+ this.transcript.addChild(new AssistantMessageComponent(message, display.settings?.getHideThinkingBlock() ?? false, markdown, undefined, pad, transformers));
441
+ for (const part of message.content) if (part.type === "toolCall") this.addTool(part, conversation, display);
442
+ this.settle(message);
443
+ } else if (message.role === "toolResult") {
444
+ this.tools.get(message.toolCallId)?.updateResult(message);
445
+ this.tools.delete(message.toolCallId);
446
+ } else if (message.role === "user") {
447
+ const text = typeof message.content === "string"
448
+ ? message.content
449
+ : message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("");
450
+ if (!text) return;
451
+ this.transcript.addChild(new Spacer(1));
452
+ const skill = parseSkillBlock(text);
453
+ if (skill) {
454
+ const component = new SkillInvocationMessageComponent(skill, markdown, pad);
455
+ component.setExpanded(this.expanded);
307
456
  this.transcript.addChild(component);
308
- for (const part of message.content) {
309
- if (part.type !== "toolCall") continue;
310
- const tool = new ToolExecutionComponent(part.name, part.id, part.arguments, images,
311
- conversation.session?.getToolDefinition(part.name), this.tui, conversation.cwd);
312
- tool.setExpanded(this.expanded);
313
- this.transcript.addChild(tool);
314
- if (message.stopReason === "aborted" || message.stopReason === "error") {
315
- const text = message.stopReason === "aborted" ? "Operation aborted" : message.errorMessage || "Error";
316
- tool.updateResult({ content: [{ type: "text", text }], isError: true });
317
- } else {
318
- this.tools.set(part.id, tool);
319
- }
320
- }
321
- } else if (message.role === "toolResult") {
322
- this.tools.get(message.toolCallId)?.updateResult(message);
323
- this.tools.delete(message.toolCallId);
324
- } else if (message.role === "user") {
325
- const text = typeof message.content === "string"
326
- ? message.content
327
- : message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("");
328
- if (!text) continue;
329
- this.transcript.addChild(new Spacer(1));
330
- const skill = parseSkillBlock(text);
331
- if (skill) {
332
- const component = new SkillInvocationMessageComponent(skill, markdown, pad);
333
- component.setExpanded(this.expanded);
334
- this.transcript.addChild(component);
335
- if (skill.userMessage) {
336
- this.transcript.addChild(new Spacer(1));
337
- this.transcript.addChild(new UserMessageComponent(skill.userMessage, markdown, pad, transformers));
338
- }
339
- } else {
340
- this.transcript.addChild(new UserMessageComponent(text, markdown, pad, transformers));
341
- }
342
- } else if (message.role === "custom" && message.display) {
343
- const user = message.customType === MESSAGE_TYPE && (message.details as { user?: boolean } | undefined)?.user;
344
- if (user && typeof message.content === "string") {
457
+ if (skill.userMessage) {
345
458
  this.transcript.addChild(new Spacer(1));
346
- this.transcript.addChild(new UserMessageComponent(message.content, markdown, pad, transformers));
347
- } else {
348
- // The component starts with its own blank line.
349
- const component = new CustomMessageComponent(message, runner?.getMessageRenderer(message.customType), markdown, pad);
350
- component.setExpanded(this.expanded);
351
- this.transcript.addChild(component);
459
+ this.transcript.addChild(new UserMessageComponent(skill.userMessage, markdown, pad, transformers));
352
460
  }
353
- } else if (message.role === "bashExecution") {
354
- const component = new BashExecutionComponent(message.command, this.tui, message.excludeFromContext, pad);
355
- if (message.output) component.appendOutput(message.output);
356
- component.setComplete(message.exitCode, message.cancelled, message.truncated ? { truncated: true } as never : undefined, message.fullOutputPath);
357
- component.setExpanded(this.expanded);
358
- this.transcript.addChild(component);
359
- } else if (message.role === "compactionSummary" || message.role === "branchSummary") {
461
+ } else {
462
+ this.transcript.addChild(new UserMessageComponent(text, markdown, pad, transformers));
463
+ }
464
+ } else if (message.role === "custom" && message.display) {
465
+ const user = message.customType === MESSAGE_TYPE && (message.details as { user?: boolean } | undefined)?.user;
466
+ if (user && typeof message.content === "string") {
360
467
  this.transcript.addChild(new Spacer(1));
361
- const component = message.role === "compactionSummary"
362
- ? new CompactionSummaryMessageComponent(message, markdown, pad)
363
- : new BranchSummaryMessageComponent(message, markdown, pad);
468
+ this.transcript.addChild(new UserMessageComponent(message.content, markdown, pad, transformers));
469
+ } else {
470
+ // The component starts with its own blank line.
471
+ const component = new CustomMessageComponent(message, runner?.getMessageRenderer(message.customType), markdown, pad);
364
472
  component.setExpanded(this.expanded);
365
473
  this.transcript.addChild(component);
366
474
  }
367
- }
368
- for (const [id, tool] of this.tools) {
369
- const running = this.running.get(id);
370
- if (!running) continue;
371
- tool.markExecutionStarted();
372
- if (running.partial) tool.updateResult(running.partial, true);
475
+ } else if (message.role === "bashExecution") {
476
+ const component = new BashExecutionComponent(message.command, this.tui, message.excludeFromContext, pad);
477
+ if (message.output) component.appendOutput(message.output);
478
+ component.setComplete(message.exitCode, message.cancelled, message.truncated ? { truncated: true } as never : undefined, message.fullOutputPath);
479
+ component.setExpanded(this.expanded);
480
+ this.transcript.addChild(component);
481
+ } else if (message.role === "compactionSummary" || message.role === "branchSummary") {
482
+ this.transcript.addChild(new Spacer(1));
483
+ const component = message.role === "compactionSummary"
484
+ ? new CompactionSummaryMessageComponent(message, markdown, pad)
485
+ : new BranchSummaryMessageComponent(message, markdown, pad);
486
+ component.setExpanded(this.expanded);
487
+ this.transcript.addChild(component);
373
488
  }
374
489
  }
375
490
  }
@@ -406,20 +521,25 @@ class ProgramViewer implements Component, Focusable {
406
521
  }
407
522
 
408
523
  handleInput(data: string): void {
409
- const page = Math.max(1, this.tui.terminal.rows - 4);
524
+ const lines = scrollKey(this.keys, data, Math.max(1, this.tui.terminal.rows - 4));
410
525
  if (this.keys.matches(data, "app.clear" as never)) return this.done();
411
526
  if (this.keys.matches(data, "app.interrupt" as never)) this.stop();
412
527
  else if (this.keys.matches(data, "app.tools.expand" as never)) {
413
528
  this.expanded = !this.expanded;
414
529
  this.tool.setExpanded(this.expanded);
415
- } else if (this.keys.matches(data, "tui.altScreen.pageUp")) this.scroll += page;
416
- else if (this.keys.matches(data, "tui.altScreen.pageDown")) this.scroll = Math.max(0, this.scroll - page);
530
+ } else if (lines !== undefined) this.scroll = Math.max(0, this.scroll + lines);
417
531
  this.tui.requestRender();
418
532
  }
419
533
 
534
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
535
+ if (event.type !== "wheel" || !event.wheelDelta) return undefined;
536
+ this.scroll = Math.max(0, this.scroll - event.wheelDelta);
537
+ return { handled: true };
538
+ }
539
+
420
540
  render(width: number): string[] {
421
541
  const running = this.programs.view(this.id)?.record.state === "running";
422
- const hints = [...running ? [keyHint("app.interrupt", "stop")] : [], keyHint("app.clear", "back")].join(" ");
542
+ const hints = [...running ? [keyHint("app.interrupt", "stop")] : [], this.theme.fg("accent", `${keyText("app.clear")} back`)].join(" ");
423
543
  const footer = truncateToWidth(" ".repeat(Math.max(0, width - visibleWidth(hints))) + hints, width);
424
544
  const border = new DynamicBorder().render(width);
425
545
  const budget = Math.max(1, this.tui.terminal.rows - border.length - 2);
@@ -514,27 +634,28 @@ class ListSelector extends Container implements Focusable {
514
634
  /** The Agents in the current Agent's tree, including Programs' Agents, those not idle first and newest first within each group. */
515
635
  export function ownedEntries(self: AgentNode): Entry[] {
516
636
  const entries: Entry[] = [];
517
- const visit = (node: AgentNode) => {
518
- for (const agent of node.agents.owned()) {
519
- entries.push({ ...agent, ownerId: node.id });
520
- const loaded = nodes.get(agent.id);
521
- if (loaded) visit(loaded);
637
+ const visit = (tasks: Task[]) => {
638
+ for (const task of tasks) {
639
+ if ("entry" in task) entries.push(task.entry);
640
+ visit(task.tasks);
522
641
  }
523
- for (const program of programsOf(node)) visit(program);
524
642
  };
525
- visit(self);
643
+ visit(tasksOf(self));
526
644
  return entries.sort((a, b) => Number(b.state !== "idle") - Number(a.state !== "idle") || (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
527
645
  }
528
646
 
529
647
  const PROGRAM_ITEM = "program:";
530
648
 
531
649
  /**
532
- * `/tasks`: picks an Agent in the tree or a background Program, live ones first and newest first, and opens it in
533
- * the viewer. `paddingX` is Pi's editor padding, so the viewer's editor lines up as Pi's does.
650
+ * `/tasks`: picks an Agent in the tree, a background Program of the current Agent, or a running one of another Agent, live ones first and newest first, and opens
651
+ * it in the viewer. `paddingX` is Pi's editor padding, so the viewer's editor lines up as Pi's does.
534
652
  */
535
- export async function openTaskViewer(ctx: ExtensionContext, self: AgentNode, programs: Programs | undefined, paddingX: number): Promise<void> {
653
+ export async function openTaskViewer(ctx: ExtensionContext, self: AgentNode, paddingX: number): Promise<void> {
536
654
  const entries = ownedEntries(self);
537
- const records = programs?.list() ?? [];
655
+ const records = [self, ...entries.flatMap((entry) => nodes.get(entry.id) ?? [])]
656
+ .flatMap((caller) => caller.programs?.list().map((record) => ({ caller, programs: caller.programs!, record })) ?? [])
657
+ // Another Agent's ended Programs show in its conversation; its Session may not be loaded to list them.
658
+ .filter(({ caller, record }) => caller === self || record.state === "running");
538
659
  if (entries.length === 0 && records.length === 0) {
539
660
  ctx.ui.notify("No tasks.", "info");
540
661
  return;
@@ -554,20 +675,25 @@ export async function openTaskViewer(ctx: ExtensionContext, self: AgentNode, pro
554
675
  label: path(entry),
555
676
  description: [entry.state, latestActivity(entry.sessionFile) ?? ""].filter(Boolean).join(" "),
556
677
  } })),
557
- ...records.map((record) => ({ live: record.state === "running", at: new Date(record.startedAt).toISOString(), item: {
678
+ ...records.map(({ caller, programs, record }) => ({ live: record.state === "running", at: new Date(record.startedAt).toISOString(), item: {
558
679
  value: PROGRAM_ITEM + record.id,
559
- label: `Program ${programs!.label(record)}`,
680
+ label: `Program ${programs.label(record)}${caller === self ? "" : ` ‹ ${path(byId.get(caller.id)!)}`}`,
560
681
  description: record.state,
561
682
  } })),
562
683
  ];
563
684
  const items: SelectItem[] = tasks
564
685
  .sort((a, b) => Number(b.live) - Number(a.live) || b.at.localeCompare(a.at))
565
686
  .map((task) => task.item);
566
- const id = await ctx.ui.custom<string | undefined>((_tui, theme, keys, done) =>
567
- new ListSelector("Open task:", items, theme, keys, (value) => done(value), () => done(undefined)));
568
- if (id?.startsWith(PROGRAM_ITEM) && programs) {
687
+ // In regular mode, a viewer shows over the screen; in fullscreen mode, it takes Pi's layout (`mountViewer`).
688
+ let overlay = true;
689
+ const id = await ctx.ui.custom<string | undefined>((tui, theme, keys, done) => {
690
+ overlay = !isViewportTUI(tui);
691
+ return new ListSelector("Open task:", items, theme, keys, (value) => done(value), () => done(undefined));
692
+ });
693
+ const program = id?.startsWith(PROGRAM_ITEM) ? records.find(({ record }) => record.id === id.slice(PROGRAM_ITEM.length)) : undefined;
694
+ if (program) {
569
695
  await ctx.ui.custom<void>(
570
- (tui, theme, keys, done) => new ProgramViewer(tui, theme, keys, ctx, programs, id.slice(PROGRAM_ITEM.length), () => done()),
696
+ (tui, theme, keys, done) => new ProgramViewer(tui, theme, keys, ctx, program.programs, program.record.id, () => done()),
571
697
  { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "bottom-center" } },
572
698
  );
573
699
  return;
@@ -584,6 +710,6 @@ export async function openTaskViewer(ctx: ExtensionContext, self: AgentNode, pro
584
710
  }
585
711
  await ctx.ui.custom<void>(
586
712
  (tui, theme, keys, done) => new AgentViewer(tui, theme, keys, ctx, owner, entry, session, paddingX, () => done()),
587
- { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "bottom-center" } },
713
+ overlay ? { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "bottom-center" } } : undefined,
588
714
  );
589
715
  }