@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.
- package/bin/pd.mjs +32 -35
- package/docs/HOSTS.md +50 -118
- package/docs/PROGRAM.md +32 -104
- package/docs/SPEC.md +91 -180
- package/package.json +1 -1
- package/src/agents/agents.ts +27 -26
- package/src/agents/registry.ts +33 -8
- package/src/agents/tool.ts +34 -11
- package/src/host/client.mjs +6 -1
- package/src/host/rpc-client.mjs +37 -17
- package/src/index.ts +9 -3
- package/src/output.ts +5 -1
- package/src/programs/codemode.ts +14 -0
- package/src/programs/execute.ts +49 -2
- package/src/programs/loadout.ts +18 -3
- package/src/programs/programs.ts +52 -11
- package/src/programs/scope.ts +5 -4
- package/src/programs/tool.ts +1 -1
- package/src/render-call.ts +113 -15
- package/src/roots/paths.mjs +4 -4
- package/src/ui/message.ts +24 -0
- package/src/ui/queue.ts +7 -6
- package/src/ui/viewer.ts +67 -31
- package/src/workbench.mjs +45 -33
package/src/render-call.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import { keyHint } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
)
|
|
76
|
-
|
|
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
|
|
80
|
-
|
|
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
|
|
88
|
-
component.
|
|
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
|
}
|
package/src/roots/paths.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
57
|
-
return join(
|
|
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 {
|
|
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:
|
|
10
|
+
function summary({ content, details }: Queued): string {
|
|
11
11
|
const text = typeof content === "string" ? content : "";
|
|
12
|
-
const
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
156
|
-
|
|
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, {
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
|
345
|
+
this.transcript.addChild(new Spacer(1));
|
|
346
|
+
this.transcript.addChild(new UserMessageComponent(message.content, markdown, pad, transformers));
|
|
324
347
|
} else {
|
|
325
|
-
|
|
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 === "
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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 (
|
|
380
|
-
else if (
|
|
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 =
|
|
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
|
|
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
|
-
|
|
50
|
+
byId.set(entry.id, entry);
|
|
50
51
|
leaf = entry;
|
|
51
52
|
}
|
|
52
|
-
const
|
|
53
|
-
for (let entry = leaf; entry && entry.type !== 'compaction'; entry =
|
|
54
|
-
|
|
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}
|
|
207
|
+
hints.rawKeyHint(`${detach}/${hints.keyText('tui.select.cancel')}`, 'close'),
|
|
210
208
|
].join(theme.fg('muted', ' · ')) : [
|
|
211
|
-
hints.
|
|
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.
|
|
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 (
|
|
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(
|
|
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 &&
|
|
301
|
-
if (empty &&
|
|
302
|
-
if (empty &&
|
|
303
|
-
if (empty &&
|
|
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 &&
|
|
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 &&
|
|
488
|
+
if (!empty && keybindings.matches(data, 'tui.select.cancel')) {
|
|
491
489
|
editor.setText(pane.mention);
|
|
492
490
|
return true;
|
|
493
491
|
}
|
|
494
|
-
if (
|
|
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 &&
|
|
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
|
-
/**
|
|
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
|
-
|
|
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();
|