@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/CHANGELOG.md +84 -0
- package/README.md +2 -1
- package/bin/pd.mjs +23 -3
- package/docs/HOSTS.md +2 -0
- package/docs/PROGRAM.md +17 -28
- package/docs/SPEC.md +28 -39
- package/package.json +3 -2
- package/src/agents/agents.ts +126 -82
- package/src/agents/registry.ts +69 -17
- package/src/agents/tool.ts +31 -87
- package/src/host/client.mjs +38 -5
- package/src/index.ts +7 -4
- package/src/main-agent.md +6 -0
- package/src/main-agent.ts +47 -0
- package/src/output.ts +0 -18
- package/src/programs/execute.ts +25 -5
- package/src/programs/loadout.ts +14 -19
- package/src/programs/programs.ts +39 -115
- package/src/programs/scope.ts +1 -1
- package/src/programs/tool.ts +16 -12
- package/src/roots/guard.mjs +5 -0
- package/src/roots/paths.mjs +1 -0
- package/src/ui/message.ts +41 -7
- package/src/ui/panel.ts +25 -40
- package/src/ui/viewer.ts +278 -152
- package/src/workbench.mjs +10 -6
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,
|
|
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
|
|
98
|
-
private live?: { component: AssistantMessageComponent; calls: string };
|
|
99
|
-
/** Tool components without a result
|
|
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
|
|
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
|
-
|
|
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
|
|
157
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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;
|
|
234
|
-
private update(event: AgentSessionEvent):
|
|
235
|
-
if (this.
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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:
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
this.
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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 (
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
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.
|
|
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")] : [],
|
|
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 = (
|
|
518
|
-
for (const
|
|
519
|
-
|
|
520
|
-
|
|
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
|
|
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,
|
|
653
|
+
export async function openTaskViewer(ctx: ExtensionContext, self: AgentNode, paddingX: number): Promise<void> {
|
|
536
654
|
const entries = ownedEntries(self);
|
|
537
|
-
const records =
|
|
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
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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,
|
|
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
|
}
|