@tshu-w/pi-agents 0.1.1 → 0.2.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/src/ui/viewer.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  AssistantMessageComponent,
3
+ BashExecutionComponent,
4
+ BranchSummaryMessageComponent,
3
5
  CompactionSummaryMessageComponent,
4
6
  CustomEditor,
5
7
  CustomMessageComponent,
@@ -8,7 +10,8 @@ import {
8
10
  getMarkdownTheme,
9
11
  getSelectListTheme,
10
12
  keyHint,
11
- keyText,
13
+ parseSkillBlock,
14
+ SkillInvocationMessageComponent,
12
15
  ToolExecutionComponent,
13
16
  UserMessageComponent,
14
17
  type AgentSession,
@@ -22,7 +25,6 @@ import {
22
25
  fuzzyFilter,
23
26
  Input,
24
27
  Loader,
25
- matchesKey,
26
28
  SelectList,
27
29
  Spacer,
28
30
  Text,
@@ -36,12 +38,22 @@ import {
36
38
  } from "@earendil-works/pi-tui";
37
39
  import { MESSAGE_TYPE, nodes, onTreeChange, programLabel, programsOf, type AgentNode, type Entry } from "../agents/registry.ts";
38
40
  import { latestActivity } from "../agents/search.ts";
41
+ import { PROGRAM_TOOL_NAME } from "../programs/execute.ts";
42
+ import type { Programs } from "../programs/programs.ts";
43
+ import { agentSession } from "../roots/rpc.ts";
39
44
  import { execFileSync } from "node:child_process";
40
45
 
41
46
  type Message = AgentSession["messages"][number];
42
47
  type ToolResult = Parameters<ToolExecutionComponent["updateResult"]>[0];
43
48
  type StatusIndicator = Parameters<CustomEditor["setWorkingStatusIndicator"]>[0];
44
49
 
50
+ /** Stops the elapsed-time timer of a tool component the viewer drops; only a final result stops it otherwise. */
51
+ function stopTimer(tool: ToolExecutionComponent): void {
52
+ const state = (tool as unknown as { rendererState: { interval?: ReturnType<typeof setInterval> } }).rendererState;
53
+ clearInterval(state.interval);
54
+ state.interval = undefined;
55
+ }
56
+
45
57
  /** A spinner and message for the editor's top border, like Pi's working status. */
46
58
  class WorkingStatus extends Loader {
47
59
  renderInBorder(width: number): string {
@@ -86,7 +98,7 @@ class AgentViewer implements Component, Focusable {
86
98
  private live?: { component: AssistantMessageComponent; calls: string };
87
99
  /** Tool components without a result from the last rebuild. */
88
100
  private tools = new Map<string, ToolExecutionComponent>();
89
- private running = new Map<string, { name: string; partial?: ToolResult }>();
101
+ private running = new Map<string, { partial?: ToolResult }>();
90
102
  private expanded = false;
91
103
  /** Lines scrolled up from the end. */
92
104
  private scroll = 0;
@@ -101,7 +113,7 @@ class AgentViewer implements Component, Focusable {
101
113
  constructor(
102
114
  private tui: TUI,
103
115
  private theme: Theme,
104
- keys: KeybindingsManager,
116
+ private keys: KeybindingsManager,
105
117
  private ctx: ExtensionContext,
106
118
  private owner: AgentNode,
107
119
  private entry: Entry,
@@ -142,8 +154,9 @@ class AgentViewer implements Component, Focusable {
142
154
 
143
155
  handleInput(data: string): void {
144
156
  const page = Math.max(1, this.tui.terminal.rows - 8);
145
- if (matchesKey(data, "pageUp")) this.scroll += page;
146
- else if (matchesKey(data, "pageDown")) this.scroll = Math.max(0, this.scroll - page);
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);
147
160
  else this.editor.handleInput(data);
148
161
  this.tui.requestRender();
149
162
  }
@@ -179,6 +192,7 @@ class AgentViewer implements Component, Focusable {
179
192
  dispose(): void {
180
193
  this.indicator?.dispose();
181
194
  this.footer.dispose();
195
+ for (const tool of this.tools.values()) stopTimer(tool);
182
196
  for (const dispose of this.disposers.splice(0)) dispose();
183
197
  }
184
198
 
@@ -206,7 +220,7 @@ class AgentViewer implements Component, Focusable {
206
220
  this.disposers.push(session.subscribe((event) => {
207
221
  if (event.type === "message_start" || event.type === "message_update") this.streaming = event.message;
208
222
  else if (event.type === "message_end") this.streaming = undefined;
209
- else if (event.type === "tool_execution_start") this.running.set(event.toolCallId, { name: event.toolName });
223
+ else if (event.type === "tool_execution_start") this.running.set(event.toolCallId, {});
210
224
  else if (event.type === "tool_execution_update") {
211
225
  const tool = this.running.get(event.toolCallId);
212
226
  if (tool) tool.partial = event.partialResult as ToolResult;
@@ -223,7 +237,7 @@ class AgentViewer implements Component, Focusable {
223
237
  if (!this.live || event.message.role !== "assistant") return false;
224
238
  const calls = event.message.content.flatMap((part) => part.type === "toolCall" ? [part] : []);
225
239
  if (calls.map((call) => call.id).join("\n") !== this.live.calls) return false;
226
- this.live.component.updateContent(event.message);
240
+ this.live.component.updateContent(event.message, true);
227
241
  for (const call of calls) this.tools.get(call.id)?.updateArgs(call.arguments);
228
242
  return true;
229
243
  }
@@ -243,20 +257,19 @@ class AgentViewer implements Component, Focusable {
243
257
  this.tui.requestRender();
244
258
  }
245
259
 
246
- /** The working status in the editor's border, and its color from the Agent's thinking level, as in Pi. */
260
+ /**
261
+ * The working status in the editor's border, and its color from the Agent's thinking level, as in Pi: `Working`,
262
+ * spinner and text in the border's color.
263
+ */
247
264
  private syncStatus(conversation: ReturnType<AgentNode["agents"]["conversation"]>): void {
248
265
  this.editor.borderColor = this.theme.getThinkingBorderColor((conversation?.thinkingLevel ?? "off") as never);
249
266
  const state = this.owner.agents.counts(this.entry.id)?.state;
250
- const busy = state === "running" || state === "waiting";
251
- const tool = [...this.running.values()].at(-1);
252
- const abort = `${keyText("app.interrupt")} to abort`;
253
- const text = !busy ? "" : tool ? `Running ${tool.name}... (${abort})` : `Working... (${abort})`;
267
+ const text = state === "running" || state === "waiting" ? "Working" : "";
254
268
  if (text === this.status) return;
255
269
  this.status = text;
256
270
  this.indicator?.dispose();
257
- this.indicator = text
258
- ? new WorkingStatus(this.tui, (part) => this.editor.borderColor(part), (part) => this.theme.fg("muted", part), text)
259
- : undefined;
271
+ const color = (part: string) => this.editor.borderColor(part);
272
+ this.indicator = text ? new WorkingStatus(this.tui, color, color, text) : undefined;
260
273
  this.editor.setWorkingStatusIndicator(this.indicator as unknown as StatusIndicator);
261
274
  }
262
275
 
@@ -264,6 +277,7 @@ class AgentViewer implements Component, Focusable {
264
277
  this.dirty = false;
265
278
  this.transcript.clear();
266
279
  this.live = undefined;
280
+ for (const tool of this.tools.values()) stopTimer(tool);
267
281
  this.tools.clear();
268
282
  if (!conversation) return;
269
283
  const messages = [...conversation.messages];
@@ -273,23 +287,33 @@ class AgentViewer implements Component, Focusable {
273
287
  this.outbox = this.outbox.filter(({ text, after }) => working && !messages.slice(after).some((message) =>
274
288
  message.role === "custom" && message.content === text && (message.details as { user?: boolean } | undefined)?.user));
275
289
  if (this.streaming && !messages.includes(this.streaming)) messages.push(this.streaming);
276
- const markdown = getMarkdownTheme();
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 };
277
298
  for (const message of messages) {
278
299
  if (message.role === "assistant") {
279
- const component = new AssistantMessageComponent(message, false, markdown);
300
+ const component = new AssistantMessageComponent(message, settings?.getHideThinkingBlock() ?? false, markdown, undefined, pad, transformers);
280
301
  if (message === this.streaming) {
302
+ // As Pi does, so Markdown transformers know the message is still streaming.
303
+ component.updateContent(message, true);
281
304
  const calls = message.content.flatMap((part) => part.type === "toolCall" ? [part.id] : []);
282
305
  this.live = { component, calls: calls.join("\n") };
283
306
  }
284
307
  this.transcript.addChild(component);
285
308
  for (const part of message.content) {
286
309
  if (part.type !== "toolCall") continue;
287
- const tool = new ToolExecutionComponent(part.name, part.id, part.arguments, { showImages: false },
310
+ const tool = new ToolExecutionComponent(part.name, part.id, part.arguments, images,
288
311
  conversation.session?.getToolDefinition(part.name), this.tui, conversation.cwd);
289
312
  tool.setExpanded(this.expanded);
290
313
  this.transcript.addChild(tool);
291
314
  if (message.stopReason === "aborted" || message.stopReason === "error") {
292
- tool.updateResult({ content: [{ type: "text", text: message.errorMessage || "Operation aborted" }], isError: true });
315
+ const text = message.stopReason === "aborted" ? "Operation aborted" : message.errorMessage || "Error";
316
+ tool.updateResult({ content: [{ type: "text", text }], isError: true });
293
317
  } else {
294
318
  this.tools.set(part.id, tool);
295
319
  }
@@ -300,23 +324,45 @@ class AgentViewer implements Component, Focusable {
300
324
  } else if (message.role === "user") {
301
325
  const text = typeof message.content === "string"
302
326
  ? message.content
303
- : message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("\n");
327
+ : message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("");
304
328
  if (!text) continue;
305
329
  this.transcript.addChild(new Spacer(1));
306
- this.transcript.addChild(new UserMessageComponent(text, markdown));
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
+ }
307
342
  } else if (message.role === "custom" && message.display) {
308
343
  const user = message.customType === MESSAGE_TYPE && (message.details as { user?: boolean } | undefined)?.user;
309
- this.transcript.addChild(new Spacer(1));
310
344
  if (user && typeof message.content === "string") {
311
- this.transcript.addChild(new UserMessageComponent(message.content, markdown));
345
+ this.transcript.addChild(new Spacer(1));
346
+ this.transcript.addChild(new UserMessageComponent(message.content, markdown, pad, transformers));
312
347
  } else {
313
- const component = new CustomMessageComponent(message, undefined, markdown);
348
+ // The component starts with its own blank line.
349
+ const component = new CustomMessageComponent(message, runner?.getMessageRenderer(message.customType), markdown, pad);
314
350
  component.setExpanded(this.expanded);
315
351
  this.transcript.addChild(component);
316
352
  }
317
- } else if (message.role === "compactionSummary") {
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") {
318
360
  this.transcript.addChild(new Spacer(1));
319
- this.transcript.addChild(new CompactionSummaryMessageComponent(message, markdown));
361
+ const component = message.role === "compactionSummary"
362
+ ? new CompactionSummaryMessageComponent(message, markdown, pad)
363
+ : new BranchSummaryMessageComponent(message, markdown, pad);
364
+ component.setExpanded(this.expanded);
365
+ this.transcript.addChild(component);
320
366
  }
321
367
  }
322
368
  for (const [id, tool] of this.tools) {
@@ -328,6 +374,82 @@ class AgentViewer implements Component, Focusable {
328
374
  }
329
375
  }
330
376
 
377
+ /** The user's view of one background Program, as its tool call shows in the foreground. */
378
+ class ProgramViewer implements Component, Focusable {
379
+ private readonly tool: ToolExecutionComponent;
380
+ private expanded = false;
381
+ /** Lines scrolled up from the end. */
382
+ private scroll = 0;
383
+ private disposers: Array<() => void> = [];
384
+ focused = false;
385
+
386
+ constructor(
387
+ private tui: TUI,
388
+ private theme: Theme,
389
+ private keys: KeybindingsManager,
390
+ private ctx: ExtensionContext,
391
+ private programs: Programs,
392
+ private id: string,
393
+ private done: () => void,
394
+ ) {
395
+ const code = programs.view(id)?.code;
396
+ const settings = agentSession(ctx.sessionManager)?.settingsManager;
397
+ this.tool = new ToolExecutionComponent(PROGRAM_TOOL_NAME, `program-${id}`, { action: "run", background: true, ...(code !== undefined && { code }) }, {
398
+ showImages: settings?.getShowImages() ?? false, imageWidthCells: settings?.getImageWidthCells(), outputPad: settings?.getOutputPad(),
399
+ }, agentSession(ctx.sessionManager)?.getToolDefinition(PROGRAM_TOOL_NAME), tui, ctx.cwd);
400
+ this.tool.markExecutionStarted();
401
+ this.sync();
402
+ this.disposers.push(programs.onView(() => {
403
+ this.sync();
404
+ this.tui.requestRender();
405
+ }));
406
+ }
407
+
408
+ handleInput(data: string): void {
409
+ const page = Math.max(1, this.tui.terminal.rows - 4);
410
+ if (this.keys.matches(data, "app.clear" as never)) return this.done();
411
+ if (this.keys.matches(data, "app.interrupt" as never)) this.stop();
412
+ else if (this.keys.matches(data, "app.tools.expand" as never)) {
413
+ this.expanded = !this.expanded;
414
+ 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);
417
+ this.tui.requestRender();
418
+ }
419
+
420
+ render(width: number): string[] {
421
+ const running = this.programs.view(this.id)?.record.state === "running";
422
+ const hints = [...running ? [keyHint("app.interrupt", "stop")] : [], keyHint("app.clear", "back")].join(" ");
423
+ const footer = truncateToWidth(" ".repeat(Math.max(0, width - visibleWidth(hints))) + hints, width);
424
+ const border = new DynamicBorder().render(width);
425
+ const budget = Math.max(1, this.tui.terminal.rows - border.length - 2);
426
+ const lines = this.tool.render(width);
427
+ this.scroll = Math.min(this.scroll, Math.max(0, lines.length - budget));
428
+ const end = lines.length - this.scroll;
429
+ // As tall as its content, so the Session shows above it.
430
+ return [...border, ...lines.slice(Math.max(0, end - budget), end), " ".repeat(width), footer];
431
+ }
432
+
433
+ invalidate(): void {
434
+ this.tool.invalidate();
435
+ }
436
+
437
+ dispose(): void {
438
+ stopTimer(this.tool);
439
+ for (const dispose of this.disposers.splice(0)) dispose();
440
+ }
441
+
442
+ private sync(): void {
443
+ const view = this.programs.view(this.id);
444
+ if (view?.result) this.tool.updateResult(view.result as ToolResult, view.record.state === "running");
445
+ }
446
+
447
+ private stop(): void {
448
+ this.programs.stop(this.programs.target(this.id)).catch((error: unknown) =>
449
+ this.ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"));
450
+ }
451
+ }
452
+
331
453
  /** A filterable list in place of the editor, like Pi Durable's conversation switcher. */
332
454
  class ListSelector extends Container implements Focusable {
333
455
  private readonly input = new Input();
@@ -404,12 +526,17 @@ export function ownedEntries(self: AgentNode): Entry[] {
404
526
  return entries.sort((a, b) => Number(b.state !== "idle") - Number(a.state !== "idle") || (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
405
527
  }
406
528
 
407
- /** `/agents`: picks an owned Agent and opens it in the viewer. */
408
- /** `paddingX` is Pi's editor padding, so the viewer's editor lines up as Pi's does. */
409
- export async function openAgentViewer(ctx: ExtensionContext, self: AgentNode, paddingX: number): Promise<void> {
529
+ const PROGRAM_ITEM = "program:";
530
+
531
+ /**
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.
534
+ */
535
+ export async function openTaskViewer(ctx: ExtensionContext, self: AgentNode, programs: Programs | undefined, paddingX: number): Promise<void> {
410
536
  const entries = ownedEntries(self);
411
- if (entries.length === 0) {
412
- ctx.ui.notify("No owned Agents.", "info");
537
+ const records = programs?.list() ?? [];
538
+ if (entries.length === 0 && records.length === 0) {
539
+ ctx.ui.notify("No tasks.", "info");
413
540
  return;
414
541
  }
415
542
  const byId = new Map(entries.map((entry) => [entry.id, entry]));
@@ -421,13 +548,30 @@ export async function openAgentViewer(ctx: ExtensionContext, self: AgentNode, pa
421
548
  return program?.program ? ` ‹ ${programLabel(program)}${owners(program.ownerId)}` : "";
422
549
  };
423
550
  const path = (entry: Entry): string => `${entry.name ?? entry.id}${owners(entry.ownerId)}`;
424
- const items: SelectItem[] = entries.map((entry) => ({
425
- value: entry.id,
426
- label: path(entry),
427
- description: [entry.state, latestActivity(entry.sessionFile) ?? ""].filter(Boolean).join(" "),
428
- }));
551
+ const tasks = [
552
+ ...entries.map((entry) => ({ live: entry.state !== "idle", at: entry.createdAt ?? "", item: {
553
+ value: entry.id,
554
+ label: path(entry),
555
+ description: [entry.state, latestActivity(entry.sessionFile) ?? ""].filter(Boolean).join(" "),
556
+ } })),
557
+ ...records.map((record) => ({ live: record.state === "running", at: new Date(record.startedAt).toISOString(), item: {
558
+ value: PROGRAM_ITEM + record.id,
559
+ label: `Program ${programs!.label(record)}`,
560
+ description: record.state,
561
+ } })),
562
+ ];
563
+ const items: SelectItem[] = tasks
564
+ .sort((a, b) => Number(b.live) - Number(a.live) || b.at.localeCompare(a.at))
565
+ .map((task) => task.item);
429
566
  const id = await ctx.ui.custom<string | undefined>((_tui, theme, keys, done) =>
430
- new ListSelector("Open Agent:", items, theme, keys, (value) => done(value), () => done(undefined)));
567
+ new ListSelector("Open task:", items, theme, keys, (value) => done(value), () => done(undefined)));
568
+ if (id?.startsWith(PROGRAM_ITEM) && programs) {
569
+ await ctx.ui.custom<void>(
570
+ (tui, theme, keys, done) => new ProgramViewer(tui, theme, keys, ctx, programs, id.slice(PROGRAM_ITEM.length), () => done()),
571
+ { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "bottom-center" } },
572
+ );
573
+ return;
574
+ }
431
575
  const entry = id === undefined ? undefined : byId.get(id);
432
576
  const owner = entry?.ownerId === undefined ? undefined : nodes.get(entry.ownerId);
433
577
  if (!entry || !owner) return;