@tshu-w/pi-agents 0.2.0 → 0.2.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.
@@ -1,5 +1,15 @@
1
- import { keyHint } from "@earendil-works/pi-coding-agent";
2
- import { Text } from "@earendil-works/pi-tui";
1
+ import { DEFAULT_MAX_BYTES, formatSize, keyHint, truncateToVisualLines } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Component,
4
+ Container,
5
+ getCapabilities,
6
+ getImageDimensions,
7
+ imageFallback,
8
+ Spacer,
9
+ Text,
10
+ truncateToWidth,
11
+ } from "@earendil-works/pi-tui";
12
+ import type { Truncation } from "./output.ts";
3
13
 
4
14
  type ToolCallTheme = {
5
15
  bold(text: string): string;
@@ -37,7 +47,8 @@ export function startDuration(state: DurationState, enabled: boolean, executionS
37
47
  if (enabled && executionStarted && state.startedAt === undefined) state.startedAt = Date.now();
38
48
  }
39
49
 
40
- function formatDuration(ms: number): string {
50
+ /** Durations as Pi shows them: `4.2s` below a minute, then `1m 30s` and `2h 3m 4s`. */
51
+ export function formatDuration(ms: number): string {
41
52
  const seconds = ms / 1000;
42
53
  if (seconds < 60) return `${seconds.toFixed(1)}s`;
43
54
  const totalSeconds = Math.floor(seconds);
@@ -60,31 +71,118 @@ function updateDuration(state: DurationState, isPartial: boolean, durationMs: nu
60
71
  }
61
72
 
62
73
  interface ResultTheme {
63
- fg(color: "toolOutput" | "muted", text: string): string;
74
+ fg(color: "toolOutput" | "muted" | "warning", text: string): string;
75
+ bold(text: string): string;
76
+ }
77
+
78
+ // As in Pi's `codemode` output.
79
+ const BLOCK_PREVIEW_LINES = 5;
80
+
81
+ // Pi's `stripAnsi` pattern, derived from ansi-regex (MIT), which Pi does not export: OSC sequences ended by ST, and CSI sequences.
82
+ const ANSI = /\x1b\][\s\S]*?(?:\x07|\x1b\\|\x9c)|[\x1b\x9b][[\]()#;?]*(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]/g;
83
+
84
+ /**
85
+ * `text` as Pi shows tool output: without ANSI sequences, carriage returns, or the control characters Pi's
86
+ * `sanitizeBinaryOutput` removes, which it does not export; and with tabs as 3 spaces.
87
+ */
88
+ function displayText(text: string): string {
89
+ return text.replace(ANSI, "").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\uFFF9-\uFFFB]/g, "").replace(/\r/g, "").replace(/\t/g, " ");
90
+ }
91
+
92
+ /** Placeholders for a result's images where Pi shows them as text: when the terminal cannot show images or they are hidden. */
93
+ function imageText(content: Array<{ type: string; data?: string; mimeType?: string }>, showImages: boolean | undefined): string {
94
+ if (getCapabilities().images && showImages) return "";
95
+ return content.flatMap((part) => part.type !== "image" ? [] : [imageFallback(
96
+ part.mimeType ?? "image/unknown",
97
+ part.data && part.mimeType ? getImageDimensions(part.data, part.mimeType) ?? undefined : undefined,
98
+ )]).join("\n");
99
+ }
100
+
101
+ /** Styled non-blank lines of `text`. */
102
+ function outputText(text: string, theme: ResultTheme): string {
103
+ return displayText(text).split("\n").filter((line) => line.trim()).map((line) => theme.fg("toolOutput", line)).join("\n");
104
+ }
105
+
106
+ /** The warning Pi's bash renderer shows for truncated output. */
107
+ function truncationWarning({ truncation, fullOutputPath }: Partial<Truncation>, theme: ResultTheme): string | undefined {
108
+ const warnings: string[] = [];
109
+ if (fullOutputPath) warnings.push(`Full output: ${fullOutputPath}`);
110
+ if (truncation?.truncated) {
111
+ warnings.push(truncation.truncatedBy === "lines"
112
+ ? `Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`
113
+ : `Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`);
114
+ }
115
+ return warnings.length > 0 ? theme.fg("warning", `[${warnings.join(". ")}]`) : undefined;
116
+ }
117
+
118
+ /**
119
+ * The collapsed view of `wait` results: each result as its line in bold, then the first visual lines of its body without
120
+ * blank lines, indented, and the number of lines after them; then the notes, without blank lines, and the truncation
121
+ * warning as Pi's bash renderer shows it. Like Pi's `codemode` output preview, it counts wrapped lines and caches its
122
+ * lines per width.
123
+ */
124
+ export function renderResultList(results: { line: string; body: string }[], notes: string[], truncated: Partial<Truncation>, theme: ResultTheme): Component {
125
+ let cached: { width: number; lines: string[] } | undefined;
126
+ return {
127
+ render(width) {
128
+ if (cached?.width !== width) {
129
+ const lines: string[] = [];
130
+ for (const { line, body } of results) {
131
+ lines.push(...new Text(theme.fg("toolOutput", theme.bold(displayText(line))), 0, 0).render(width));
132
+ const preview = truncateToVisualLines(outputText(body, theme), BLOCK_PREVIEW_LINES, width - 2, 0, "start");
133
+ lines.push(...preview.visualLines.map((visual) => ` ${visual}`));
134
+ if (preview.skippedCount > 0) lines.push(truncateToWidth(` ${moreLines(preview.skippedCount, theme)}`, width, "..."));
135
+ }
136
+ lines.push(...new Text(outputText(notes.join("\n"), theme), 0, 0).render(width));
137
+ const warning = truncationWarning(truncated, theme);
138
+ if (warning) lines.push(...new Text(warning, 0, 0).render(width));
139
+ cached = { width, lines };
140
+ }
141
+ return cached.lines;
142
+ },
143
+ invalidate() {
144
+ cached = undefined;
145
+ },
146
+ };
64
147
  }
65
148
 
66
149
  /**
67
150
  * The text output like Pi's default result, with the duration of a timed call below it.
68
- * Like Pi's `bash` result, a blank line precedes the output and the duration.
151
+ * Like Pi's `bash` result, a blank line precedes the output and the duration. When collapsed, `tidy` may show its view
152
+ * instead, followed by the placeholders of the result's images.
69
153
  */
70
154
  export function renderTextResult(
71
- result: { content: Array<{ type: string; text?: string }> },
155
+ result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> },
72
156
  options: { expanded: boolean; isPartial: boolean },
73
157
  theme: ResultTheme,
74
- context: { state: unknown; invalidate: () => void; lastComponent?: unknown; durationMs?: number },
75
- ): Text {
76
- const output = result.content.flatMap((part) => part.type === "text" && part.text ? [part.text] : []).join("\n").trim();
158
+ context: { state: unknown; invalidate: () => void; lastComponent?: unknown; durationMs?: number; showImages?: boolean },
159
+ tidy?: () => Component | undefined,
160
+ ): Component {
161
+ const images = imageText(result.content, context.showImages);
162
+ // As Pi's text output of a result: its text items, then its image placeholders.
163
+ const text = result.content.flatMap((part) => part.type === "text" && part.text ? [displayText(part.text)] : []).join("\n");
164
+ const output = [text, images].filter(Boolean).join("\n").trim();
77
165
  const lines = output ? output.split("\n") : [];
78
166
  const shown = options.expanded ? lines : lines.slice(0, RESULT_PREVIEW_LINES);
79
- const sections: string[] = [];
80
- if (shown.length > 0) {
167
+ const tidied = options.expanded ? undefined : tidy?.();
168
+ const sections: Component[] = [];
169
+ if (tidied) {
170
+ const section = new Container();
171
+ section.addChild(tidied);
172
+ if (images) section.addChild(new Text(theme.fg("toolOutput", images), 0, 0));
173
+ sections.push(section);
174
+ } else if (shown.length > 0) {
81
175
  let text = shown.map((line) => theme.fg("toolOutput", line)).join("\n");
82
176
  if (shown.length < lines.length) text += `\n${moreLines(lines.length - shown.length, theme)}`;
83
- sections.push(text);
177
+ sections.push(new Text(text, 0, 0));
84
178
  }
85
179
  const duration = updateDuration(context.state as DurationState, options.isPartial, context.durationMs, context.invalidate);
86
- if (duration) sections.push(theme.fg("muted", duration));
87
- const component = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
88
- component.setText(sections.map((section) => `\n${section}`).join("\n"));
180
+ if (duration) sections.push(new Text(theme.fg("muted", duration), 0, 0));
181
+ const component = context.lastComponent instanceof Container ? context.lastComponent : new Container();
182
+ component.clear();
183
+ for (const section of sections) {
184
+ component.addChild(new Spacer(1));
185
+ component.addChild(section);
186
+ }
89
187
  return component;
90
188
  }
@@ -39,7 +39,7 @@ export function agentDir(env = process.env) {
39
39
  }
40
40
 
41
41
  /** The session directory configured without `--session-dir`: PI_CODING_AGENT_SESSION_DIR, then the global `sessionDir` setting. */
42
- export function configuredSessionDir(env = process.env) {
42
+ function configuredSessionDir(env = process.env) {
43
43
  if (env.PI_CODING_AGENT_SESSION_DIR) return resolvePath(env.PI_CODING_AGENT_SESSION_DIR);
44
44
  let setting;
45
45
  try { setting = JSON.parse(readFileSync(join(agentDir(env), 'settings.json'), 'utf8').replace(/^\uFEFF/, '')).sessionDir; }
@@ -52,9 +52,9 @@ export function sessionDir(env = process.env) {
52
52
  return configuredSessionDir(env) ?? join(agentDir(env), 'sessions');
53
53
  }
54
54
 
55
- /** The directory Pi keeps a cwd's Sessions in when none is configured. */
56
- export function defaultSessionDir(cwd, env = process.env) {
57
- return join(agentDir(env), 'sessions', `--${resolve(cwd).replace(/^\//, '').replace(/[/\\:]/g, '-')}--`);
55
+ /** The directory Pi keeps a cwd's Sessions in when none is configured, as Pi's unexported `getDefaultSessionDirPath`. */
56
+ export function defaultSessionDir(cwd, agentDirectory) {
57
+ return join(resolve(agentDirectory), 'sessions', `--${resolve(cwd).replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`);
58
58
  }
59
59
 
60
60
  export async function prepareDirectory(directory) {
@@ -0,0 +1,24 @@
1
+ import { getMarkdownTheme, UserMessageComponent, type MessageRenderer } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
3
+ import { messageBody, type Sender } from "../agents/registry.ts";
4
+
5
+ /**
6
+ * Collapsed, a message from another Agent shows its sender after the label, as Pi shows a collapsed skill, and the
7
+ * user's input from the task viewer shows as a user message. Expanded, or for any other message, Pi shows the text the
8
+ * model received.
9
+ */
10
+ export const renderAgentMessage: MessageRenderer = (message, { expanded, outputPad }, theme) => {
11
+ if (expanded || typeof message.content !== "string") return undefined;
12
+ const details = message.details as { user?: boolean; sender?: Sender } | undefined;
13
+ if (details?.user) return new UserMessageComponent(message.content, getMarkdownTheme(), outputPad);
14
+ const body = details?.sender && messageBody(message.content, details.sender);
15
+ if (body === undefined) return undefined;
16
+ const { id, name } = details!.sender!;
17
+ const box = new Box(outputPad, 1, (text) => theme.bg("customMessageBg", text));
18
+ const label = theme.fg("customMessageLabel", `\x1b[1m[${message.customType}]\x1b[22m`);
19
+ const sender = name === undefined ? theme.fg("customMessageText", ` from ${id}`) : theme.fg("customMessageText", ` from ${name}`) + theme.fg("dim", ` (${id})`);
20
+ box.addChild(new Text(label + sender, 0, 0));
21
+ box.addChild(new Spacer(1));
22
+ box.addChild(new Markdown(body, 0, 0, getMarkdownTheme(), { color: (text) => theme.fg("customMessageText", text) }));
23
+ return box;
24
+ };
package/src/ui/queue.ts CHANGED
@@ -1,16 +1,17 @@
1
1
  import type { AgentSession, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { TruncatedText, type TUI } from "@earendil-works/pi-tui";
3
- import { MESSAGE_TAG, MESSAGE_TYPE } from "../agents/registry.ts";
3
+ import { messageBody, MESSAGE_TYPE, type Sender } from "../agents/registry.ts";
4
4
 
5
5
  const WIDGET_KEY = "pi-agents-queue";
6
6
 
7
- type Queued = { role: string; customType?: string; content?: unknown; details?: { delivery?: string } };
7
+ type Queued = { role: string; customType?: string; content?: unknown; details?: { delivery?: string; sender?: Sender } };
8
8
 
9
9
  /** One line for an Agent message: its sender and body. */
10
- function summary(content: unknown): string {
10
+ function summary({ content, details }: Queued): string {
11
11
  const text = typeof content === "string" ? content : "";
12
- const match = new RegExp(`^<${MESSAGE_TAG} from="([^"]*)"[^>]*>\\n([\\s\\S]*)\\n</${MESSAGE_TAG}>$`).exec(text);
13
- return (match ? `[${match[1]}] ${match[2]}` : text).replace(/\s+/g, " ").trim();
12
+ const sender = details?.sender;
13
+ const body = sender && messageBody(text, sender);
14
+ return (body === undefined ? text : `[${sender!.name ?? sender!.id}] ${body}`).replace(/\s+/g, " ").trim();
14
15
  }
15
16
 
16
17
  /** Agent messages waiting in the Session's queues, which Pi does not list as it lists the user's. */
@@ -19,7 +20,7 @@ export function queuedLines(agent: AgentSession["agent"]): string[] {
19
20
  const lines = (label: string, messages: Queued[]) => messages
20
21
  .filter((message) => message.role === "custom" && message.customType === MESSAGE_TYPE)
21
22
  // A write waits in the steering queue but asks for nothing, so it is labeled apart from a steer.
22
- .map((message) => `${message.details?.delivery === "write" ? "Write" : label}: ${summary(message.content)}`);
23
+ .map((message) => `${message.details?.delivery === "write" ? "Write" : label}: ${summary(message)}`);
23
24
  return [...lines("Steering", queues.steeringQueue.messages), ...lines("Follow-up", queues.followUpQueue.messages)];
24
25
  }
25
26
 
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,
@@ -96,7 +98,7 @@ class AgentViewer implements Component, Focusable {
96
98
  private live?: { component: AssistantMessageComponent; calls: string };
97
99
  /** Tool components without a result from the last rebuild. */
98
100
  private tools = new Map<string, ToolExecutionComponent>();
99
- private running = new Map<string, { name: string; partial?: ToolResult }>();
101
+ private running = new Map<string, { partial?: ToolResult }>();
100
102
  private expanded = false;
101
103
  /** Lines scrolled up from the end. */
102
104
  private scroll = 0;
@@ -111,7 +113,7 @@ class AgentViewer implements Component, Focusable {
111
113
  constructor(
112
114
  private tui: TUI,
113
115
  private theme: Theme,
114
- keys: KeybindingsManager,
116
+ private keys: KeybindingsManager,
115
117
  private ctx: ExtensionContext,
116
118
  private owner: AgentNode,
117
119
  private entry: Entry,
@@ -152,8 +154,9 @@ class AgentViewer implements Component, Focusable {
152
154
 
153
155
  handleInput(data: string): void {
154
156
  const page = Math.max(1, this.tui.terminal.rows - 8);
155
- if (matchesKey(data, "pageUp")) this.scroll += page;
156
- 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);
157
160
  else this.editor.handleInput(data);
158
161
  this.tui.requestRender();
159
162
  }
@@ -217,7 +220,7 @@ class AgentViewer implements Component, Focusable {
217
220
  this.disposers.push(session.subscribe((event) => {
218
221
  if (event.type === "message_start" || event.type === "message_update") this.streaming = event.message;
219
222
  else if (event.type === "message_end") this.streaming = undefined;
220
- 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, {});
221
224
  else if (event.type === "tool_execution_update") {
222
225
  const tool = this.running.get(event.toolCallId);
223
226
  if (tool) tool.partial = event.partialResult as ToolResult;
@@ -234,7 +237,7 @@ class AgentViewer implements Component, Focusable {
234
237
  if (!this.live || event.message.role !== "assistant") return false;
235
238
  const calls = event.message.content.flatMap((part) => part.type === "toolCall" ? [part] : []);
236
239
  if (calls.map((call) => call.id).join("\n") !== this.live.calls) return false;
237
- this.live.component.updateContent(event.message);
240
+ this.live.component.updateContent(event.message, true);
238
241
  for (const call of calls) this.tools.get(call.id)?.updateArgs(call.arguments);
239
242
  return true;
240
243
  }
@@ -254,20 +257,19 @@ class AgentViewer implements Component, Focusable {
254
257
  this.tui.requestRender();
255
258
  }
256
259
 
257
- /** 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
+ */
258
264
  private syncStatus(conversation: ReturnType<AgentNode["agents"]["conversation"]>): void {
259
265
  this.editor.borderColor = this.theme.getThinkingBorderColor((conversation?.thinkingLevel ?? "off") as never);
260
266
  const state = this.owner.agents.counts(this.entry.id)?.state;
261
- const busy = state === "running" || state === "waiting";
262
- const tool = [...this.running.values()].at(-1);
263
- const abort = `${keyText("app.interrupt")} to abort`;
264
- const text = !busy ? "" : tool ? `Running ${tool.name}... (${abort})` : `Working... (${abort})`;
267
+ const text = state === "running" || state === "waiting" ? "Working" : "";
265
268
  if (text === this.status) return;
266
269
  this.status = text;
267
270
  this.indicator?.dispose();
268
- this.indicator = text
269
- ? new WorkingStatus(this.tui, (part) => this.editor.borderColor(part), (part) => this.theme.fg("muted", part), text)
270
- : undefined;
271
+ const color = (part: string) => this.editor.borderColor(part);
272
+ this.indicator = text ? new WorkingStatus(this.tui, color, color, text) : undefined;
271
273
  this.editor.setWorkingStatusIndicator(this.indicator as unknown as StatusIndicator);
272
274
  }
273
275
 
@@ -285,23 +287,33 @@ class AgentViewer implements Component, Focusable {
285
287
  this.outbox = this.outbox.filter(({ text, after }) => working && !messages.slice(after).some((message) =>
286
288
  message.role === "custom" && message.content === text && (message.details as { user?: boolean } | undefined)?.user));
287
289
  if (this.streaming && !messages.includes(this.streaming)) messages.push(this.streaming);
288
- 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 };
289
298
  for (const message of messages) {
290
299
  if (message.role === "assistant") {
291
- const component = new AssistantMessageComponent(message, false, markdown);
300
+ const component = new AssistantMessageComponent(message, settings?.getHideThinkingBlock() ?? false, markdown, undefined, pad, transformers);
292
301
  if (message === this.streaming) {
302
+ // As Pi does, so Markdown transformers know the message is still streaming.
303
+ component.updateContent(message, true);
293
304
  const calls = message.content.flatMap((part) => part.type === "toolCall" ? [part.id] : []);
294
305
  this.live = { component, calls: calls.join("\n") };
295
306
  }
296
307
  this.transcript.addChild(component);
297
308
  for (const part of message.content) {
298
309
  if (part.type !== "toolCall") continue;
299
- const tool = new ToolExecutionComponent(part.name, part.id, part.arguments, { showImages: false },
310
+ const tool = new ToolExecutionComponent(part.name, part.id, part.arguments, images,
300
311
  conversation.session?.getToolDefinition(part.name), this.tui, conversation.cwd);
301
312
  tool.setExpanded(this.expanded);
302
313
  this.transcript.addChild(tool);
303
314
  if (message.stopReason === "aborted" || message.stopReason === "error") {
304
- 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 });
305
317
  } else {
306
318
  this.tools.set(part.id, tool);
307
319
  }
@@ -312,23 +324,45 @@ class AgentViewer implements Component, Focusable {
312
324
  } else if (message.role === "user") {
313
325
  const text = typeof message.content === "string"
314
326
  ? message.content
315
- : message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("\n");
327
+ : message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("");
316
328
  if (!text) continue;
317
329
  this.transcript.addChild(new Spacer(1));
318
- 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
+ }
319
342
  } else if (message.role === "custom" && message.display) {
320
343
  const user = message.customType === MESSAGE_TYPE && (message.details as { user?: boolean } | undefined)?.user;
321
- this.transcript.addChild(new Spacer(1));
322
344
  if (user && typeof message.content === "string") {
323
- 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));
324
347
  } else {
325
- 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);
326
350
  component.setExpanded(this.expanded);
327
351
  this.transcript.addChild(component);
328
352
  }
329
- } 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") {
330
360
  this.transcript.addChild(new Spacer(1));
331
- 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);
332
366
  }
333
367
  }
334
368
  for (const [id, tool] of this.tools) {
@@ -359,8 +393,10 @@ class ProgramViewer implements Component, Focusable {
359
393
  private done: () => void,
360
394
  ) {
361
395
  const code = programs.view(id)?.code;
362
- this.tool = new ToolExecutionComponent(PROGRAM_TOOL_NAME, `program-${id}`, { action: "run", background: true, ...(code !== undefined && { code }) }, { showImages: false },
363
- agentSession(ctx.sessionManager)?.getToolDefinition(PROGRAM_TOOL_NAME), tui, ctx.cwd);
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);
364
400
  this.tool.markExecutionStarted();
365
401
  this.sync();
366
402
  this.disposers.push(programs.onView(() => {
@@ -376,8 +412,8 @@ class ProgramViewer implements Component, Focusable {
376
412
  else if (this.keys.matches(data, "app.tools.expand" as never)) {
377
413
  this.expanded = !this.expanded;
378
414
  this.tool.setExpanded(this.expanded);
379
- } else if (matchesKey(data, "pageUp")) this.scroll += page;
380
- else if (matchesKey(data, "pageDown")) this.scroll = Math.max(0, this.scroll - page);
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);
381
417
  this.tui.requestRender();
382
418
  }
383
419
 
package/src/workbench.mjs CHANGED
@@ -38,23 +38,21 @@ function textOf(content) {
38
38
  return Array.isArray(content) ? content.filter(block => block?.type === 'text').map(block => block.text).join('\n') : '';
39
39
  }
40
40
 
41
- /** The messages on the Session's active branch since its last compaction, oldest first. */
42
- async function branchMessages(file) {
43
- const entries = new Map();
41
+ /** The messages on the Session's active branch since its last compaction, oldest first, read as Pi reads the Session. */
42
+ async function branchMessages(pi, file) {
43
+ const entries = pi.parseSessionEntries(await readFile(file, 'utf8'));
44
+ // Older Sessions take Pi's current form.
45
+ pi.migrateSessionEntries(entries);
46
+ const byId = new Map();
44
47
  let leaf;
45
- for (const line of (await readFile(file, 'utf8')).split('\n')) {
46
- let entry;
47
- try { entry = JSON.parse(line); } catch { continue; }
48
+ for (const entry of entries) {
48
49
  if (typeof entry?.id !== 'string' || entry.type === 'session') continue;
49
- entries.set(entry.id, entry);
50
+ byId.set(entry.id, entry);
50
51
  leaf = entry;
51
52
  }
52
- const messages = [];
53
- for (let entry = leaf; entry && entry.type !== 'compaction'; entry = entries.get(entry.parentId)) {
54
- if (entry.type === 'message' && entry.message) messages.push(entry.message);
55
- else if (entry.type === 'custom_message') messages.push({ role: 'custom', ...entry });
56
- }
57
- return messages.reverse();
53
+ const branch = [];
54
+ for (let entry = leaf; entry && entry.type !== 'compaction'; entry = byId.get(entry.parentId)) branch.push(entry);
55
+ return branch.reverse().flatMap(entry => pi.sessionEntryToContextMessages(entry));
58
56
  }
59
57
 
60
58
  function age(ms) {
@@ -206,13 +204,13 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
206
204
  hints.keyHint('app.tools.expand', 'attach'),
207
205
  hints.rawKeyHint(['lineUp', 'halfPageUp', 'pageUp', 'lineDown', 'halfPageDown', 'pageDown']
208
206
  .map(name => hints.keyText(`tui.altScreen.${name}`)).filter(Boolean).join('/'), 'scroll'),
209
- hints.rawKeyHint(`${detach}/esc`, 'close'),
207
+ hints.rawKeyHint(`${detach}/${hints.keyText('tui.select.cancel')}`, 'close'),
210
208
  ].join(theme.fg('muted', ' · ')) : [
211
- hints.rawKeyHint('enter', 'attach'),
209
+ hints.keyHint('tui.select.confirm', 'attach'),
212
210
  hints.rawKeyHint(detach, 'quit'),
213
211
  hints.rawKeyHint('space', 'preview'),
214
212
  hints.rawKeyHint('ctrl+x', 'stop'),
215
- hints.rawKeyHint('ctrl+d', 'remove'),
213
+ hints.keyHint('app.session.delete', 'remove'),
216
214
  hints.rawKeyHint('@', 'message'),
217
215
  hints.rawKeyHint('/', 'commands'),
218
216
  ].join(theme.fg('muted', ' · '));
@@ -282,11 +280,11 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
282
280
  // As in a Session, the detach key leaves and everything keeps running; in the preview, it returns to the list.
283
281
  if (isDetach(data)) return void (pane ? pane.close() : quit());
284
282
  if (pane?.handleInput(data, empty)) return;
285
- if (T.matchesKey(data, 'ctrl+c')) {
283
+ if (keybindings.matches(data, 'app.clear')) {
286
284
  if (!empty) return editor.setText(pane?.mention ?? '');
287
285
  if (Date.now() - lastCtrlC < 1000) return void quit();
288
286
  lastCtrlC = Date.now();
289
- return say('Press Ctrl+C again to quit');
287
+ return say(`Press ${hints.keyText('app.clear')} again to quit`);
290
288
  }
291
289
  if (T.matchesKey(data, 'ctrl+x')) return interrupt();
292
290
  if (empty && keybindings.matches(data, 'app.session.delete')) return void remove(selected()?.session);
@@ -297,10 +295,10 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
297
295
  if (pane && !editor.getText().startsWith(pane.mention)) editor.setText(before);
298
296
  return;
299
297
  }
300
- if (empty && T.matchesKey(data, 'escape')) return void quit();
301
- if (empty && T.matchesKey(data, 'up')) return move(-1);
302
- if (empty && T.matchesKey(data, 'down')) return move(1);
303
- if (empty && T.matchesKey(data, 'enter')) return void open(selected());
298
+ if (empty && keybindings.matches(data, 'tui.select.cancel')) return void quit();
299
+ if (empty && keybindings.matches(data, 'tui.select.up')) return move(-1);
300
+ if (empty && keybindings.matches(data, 'tui.select.down')) return move(1);
301
+ if (empty && keybindings.matches(data, 'tui.select.confirm')) return void open(selected());
304
302
  if (empty && T.matchesKey(data, 'space')) return void preview(selected()?.session);
305
303
  if (empty && data === '@') return pick(session => {
306
304
  picked = { session, mention: `@${mention(session)} ` };
@@ -425,7 +423,7 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
425
423
  async function preview(session) {
426
424
  if (!session?.sessionFile) return;
427
425
  let messages;
428
- try { messages = await branchMessages(session.sessionFile); }
426
+ try { messages = await branchMessages(pi, session.sessionFile); }
429
427
  catch (error) { return say(error.message, 'error'); }
430
428
  let body = conversation(messages);
431
429
  const border = themes.getEditorTheme().borderColor;
@@ -437,7 +435,7 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
437
435
  const watcher = watch(session.sessionFile, () => {
438
436
  clearTimeout(timer);
439
437
  timer = setTimeout(async () => {
440
- try { body = conversation(await branchMessages(session.sessionFile)); }
438
+ try { body = conversation(await branchMessages(pi, session.sessionFile)); }
441
439
  catch (error) { return say(error.message, 'error'); }
442
440
  see();
443
441
  ui.requestRender();
@@ -482,16 +480,16 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
482
480
  },
483
481
  /** Enter, `↑`/`↓`, and Space act on the preview only while no message is typed. */
484
482
  handleInput(data, empty) {
485
- if (keybindings.matches(data, 'app.tools.expand') || (empty && T.matchesKey(data, 'enter'))) {
483
+ if (keybindings.matches(data, 'app.tools.expand') || (empty && keybindings.matches(data, 'tui.select.confirm'))) {
486
484
  close();
487
485
  void openSession(session);
488
486
  return true;
489
487
  }
490
- if (!empty && T.matchesKey(data, 'escape')) {
488
+ if (!empty && keybindings.matches(data, 'tui.select.cancel')) {
491
489
  editor.setText(pane.mention);
492
490
  return true;
493
491
  }
494
- if (T.matchesKey(data, 'escape') || (empty && (T.matchesKey(data, 'space') || T.matchesKey(data, 'ctrl+c')))) {
492
+ if (keybindings.matches(data, 'tui.select.cancel') || (empty && (T.matchesKey(data, 'space') || keybindings.matches(data, 'app.clear')))) {
495
493
  close();
496
494
  return true;
497
495
  }
@@ -499,7 +497,7 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
499
497
  const half = Math.max(1, Math.floor(page / 2));
500
498
  const steps = { lineUp: 1, lineDown: -1, halfPageUp: half, halfPageDown: -half, pageUp: page - 1, pageDown: 1 - page, top: Infinity, bottom: -Infinity };
501
499
  const action = Object.keys(steps).find(name => keybindings.matches(data, `tui.altScreen.${name}`));
502
- const step = empty && T.matchesKey(data, 'up') ? 1 : empty && T.matchesKey(data, 'down') ? -1 : action ? steps[action] : 0;
500
+ const step = empty && keybindings.matches(data, 'tui.select.up') ? 1 : empty && keybindings.matches(data, 'tui.select.down') ? -1 : action ? steps[action] : 0;
503
501
  if (!step) return false;
504
502
  scroll = Math.max(0, scroll + step);
505
503
  // As in Pi's `auto` mode, the scrollbar shows while scrolling and for a second after.
@@ -514,20 +512,27 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
514
512
  ui.requestRender(true);
515
513
  }
516
514
 
517
- /** Messages and replies as Pi shows them; thinking is left out and each run of tool calls folds to one line. */
515
+ /**
516
+ * Messages and replies as Pi shows them; thinking is left out, and each run of tool calls, branch summary, and custom
517
+ * message other than the user's input folds to one line.
518
+ */
518
519
  function conversation(messages) {
519
520
  const markdown = themes.getMarkdownTheme();
520
521
  const view = new T.Container();
521
522
  let tools = [];
523
+ // Pi's hint for folded output: "... (36 more lines, …)".
524
+ const summarize = summary => {
525
+ view.addChild(new T.Spacer(1));
526
+ view.addChild(new T.Text(theme.fg('muted', `... (${summary})`), 1, 0));
527
+ };
522
528
  const fold = () => {
523
529
  if (!tools.length) return;
524
- // Pi's hint for folded output: "... (36 more lines, …)".
525
- const names = [...new Set(tools)].join(', ');
526
- view.addChild(new T.Spacer(1));
527
- view.addChild(new T.Text(theme.fg('muted', `... (${tools.length} tool call${tools.length > 1 ? 's' : ''}: ${names})`), 1, 0));
530
+ summarize(`${tools.length} tool call${tools.length > 1 ? 's' : ''}: ${[...new Set(tools)].join(', ')}`);
528
531
  tools = [];
529
532
  };
530
533
  for (const message of messages) {
534
+ // As in Pi's chat, a custom message not for display is skipped.
535
+ if (message.role === 'custom' && !message.display) continue;
531
536
  const user = message.role === 'user' || (message.role === 'custom' && message.customType === 'pi-agents' && message.details?.user);
532
537
  if (user) {
533
538
  const text = textOf(message.content).trim();
@@ -542,6 +547,13 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
542
547
  view.addChild(new pi.AssistantMessageComponent({ ...message, content }, true, markdown));
543
548
  }
544
549
  tools.push(...message.content.flatMap(part => part.type === 'toolCall' ? [part.name] : []));
550
+ } else if (message.role === 'branchSummary') {
551
+ fold();
552
+ summarize('branch summary');
553
+ } else if (message.role === 'custom') {
554
+ const from = message.customType === 'pi-agents' ? message.details?.sender?.name ?? message.details?.sender?.id : undefined;
555
+ fold();
556
+ summarize(from ? `message from ${from}` : `${message.customType} message`);
545
557
  }
546
558
  }
547
559
  fold();