@tshu-w/pi-agents 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,25 +1,17 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { StringEnum } from "@earendil-works/pi-ai";
3
3
  import { Type } from "typebox";
4
- import { boundBlocks, boundText, type Truncation } from "../output.ts";
4
+ import { boundText } from "../output.ts";
5
5
  import { rejectFields } from "../params.ts";
6
- import { renderResultList, renderTextResult, renderToolCall, startDuration, type DurationState } from "../render-call.ts";
6
+ import { renderTextResult, renderToolCall, startDuration, type DurationState } from "../render-call.ts";
7
7
  import { agentSession } from "../roots/rpc.ts";
8
8
  import type { RootRuntime } from "../roots/runtime.ts";
9
- import {
10
- steerQueued,
11
- THINKING_LEVELS,
12
- WAIT_INTERRUPTED,
13
- type Limits,
14
- type WaitOutcome,
15
- type WaitResult,
16
- } from "./agents.ts";
9
+ import { steerQueued, THINKING_LEVELS, waitText, type Limits } from "./agents.ts";
17
10
  import {
18
11
  deliver,
19
12
  label,
20
13
  nodes,
21
14
  rememberRoots,
22
- shortId,
23
15
  resolveIn,
24
16
  treeEntries,
25
17
  type AgentNode,
@@ -27,55 +19,6 @@ import {
27
19
  } from "./registry.ts";
28
20
  import { listLine, searchEntries } from "./search.ts";
29
21
 
30
- function resultBlock({ agent, input, history }: WaitResult, ids: string[]): string {
31
- return `<agent-result name="${agent.name}" id="${shortId(agent.id, ids)}" status="${input.state}"${history ? ' history="true"' : ""}>\n${input.result ?? ""}\n</agent-result>`;
32
- }
33
-
34
- function resultDetails({ agent, input, history }: WaitResult, result?: string) {
35
- return { id: agent.id, name: agent.name, state: input.state, history, ...(result === undefined ? {} : { result }) };
36
- }
37
-
38
- type Named = { id: string; name?: string };
39
-
40
- /** The line naming the results left out to keep within Pi's output limits. */
41
- function omittedLine(omitted: Named[], ids: string[]): string {
42
- return omitted.length > 0 ? `[Results omitted: ${omitted.map((agent) => label(agent, ids)).join(", ")}. Use wait with fewer targets.]` : "";
43
- }
44
-
45
- /** The line naming the Agents still pending, and that a steer ended the wait if one did. */
46
- function pendingLine(pending: Named[], interrupted: boolean | undefined, ids: string[]): string {
47
- return pending.length > 0 ? `Still pending: ${pending.map((agent) => label(agent, ids)).join(", ")}${interrupted ? `\n${WAIT_INTERRUPTED}` : ""}` : "";
48
- }
49
-
50
- /** Renders wait results; returns the text and the results the caller has now read. */
51
- function renderWait(outcome: WaitOutcome, ids: string[]): { text: string; read: WaitResult[]; details: Record<string, unknown> } {
52
- const { results, pending, interrupted } = outcome;
53
- const pendingText = pendingLine(pending, interrupted, ids);
54
- const flag = interrupted ? { interrupted } : {};
55
- const named = pending.map(({ id, name }) => ({ id, name }));
56
- if (results.length === 0) return { text: pendingText || "No results.", read: [], details: { results: [], pending: named, ...flag } };
57
- const only = results[0]!;
58
- if (results.length === 1 && pending.length === 0 && !only.history && only.input.state === "completed") {
59
- const bounded = boundText(only.input.result ?? "", "pi-agents-wait");
60
- return { text: bounded.text, read: results, details: { results: [resultDetails(only, bounded.kept)], pending: [], ...bounded.details } };
61
- }
62
- const blocks = results.map((result) => resultBlock(result, ids));
63
- const bounded = boundBlocks(blocks, "agent-result", "pi-agents-wait");
64
- let offset = 0;
65
- const details = results.map((result, index) => {
66
- const block = blocks[index]!;
67
- const available = Math.max(0, bounded.kept.length - offset - block.indexOf("\n") - 1);
68
- offset += block.length + 2;
69
- return resultDetails(result, index < bounded.shown ? (result.input.result ?? "").slice(0, available) : undefined);
70
- });
71
- let text = bounded.text;
72
- const read = results.slice(0, bounded.shown);
73
- const omittedText = omittedLine(results.slice(bounded.shown).map((result) => result.agent), ids);
74
- if (omittedText) text += `\n\n${omittedText}`;
75
- if (pendingText) text += `\n\n${pendingText}`;
76
- return { text, read, details: { results: details, pending: named, ...flag, ...bounded.details } };
77
- }
78
-
79
22
  /** Registers the `agent` tool. */
80
23
  export function registerAgentTool(
81
24
  pi: ExtensionAPI,
@@ -102,36 +45,25 @@ export function registerAgentTool(
102
45
  startDuration(context.state as DurationState, args.action === "wait", context.executionStarted);
103
46
  return renderToolCall("agent", args, theme, context.lastComponent);
104
47
  },
105
- // `wait` shows how long it has waited, and when collapsed, each result as a line with the first lines of its body.
106
- renderResult: (result, options, theme, context) => renderTextResult(result, options, theme, context, (context.args as { action?: string }).action === "wait"
107
- ? () => {
108
- const { results = [], pending = [], interrupted, ...truncated } = (result.details ?? {}) as { results?: ReturnType<typeof resultDetails>[]; pending?: Named[]; interrupted?: boolean } & Partial<Truncation>;
109
- const only = results[0];
110
- // As `renderWait` returns them, no results, or a single completed result alone, show as they are.
111
- if (!only || (results.length === 1 && pending.length === 0 && !only.history && only.state === "completed")) return undefined;
112
- const ids = [...results.map((entry) => entry.id), ...pending.map((agent) => agent.id)];
113
- const notes = [omittedLine(results.filter((entry) => entry.result === undefined), ids), pendingLine(pending, interrupted, ids)];
114
- return renderResultList(results.flatMap((entry) => entry.result === undefined ? [] : [{ line: `${label(entry, ids)} ${entry.state}`, body: entry.result }]), notes, truncated, theme);
115
- }
116
- : undefined),
117
- description: `Create, message, and coordinate Agents. \`spawn\` creates an owned Agent and sends its first input; \`send\` sends an input or a write to a visible Agent; \`wait\` waits for owned Agents and returns unread results; \`list\` finds visible Agents; \`abort\` stops an owned Agent's current turn and queued inputs while keeping it available. When an owned Agent finishes an input while its owner is not waiting for it, the owner receives a notification naming the Agent and outcome; \`wait\` returns the result. Owned Agents under the same root share \`${maxConcurrent}\` execution slots and a limit of \`${maxOutstanding}\` inputs that have not ended. Inputs that start a new turn queue when all slots are busy; new inputs are rejected at the input limit.`,
48
+ renderResult: (result, options, theme, context) => renderTextResult(result, options, theme, context),
49
+ description: `Create, message, and coordinate Agents. \`spawn\` creates an Agent and sends its first input; \`send\` sends an input or a write to a visible Agent; \`wait\` waits for owned Agents to finish; \`list\` finds visible Agents; \`abort\` stops an owned Agent's current turn and queued inputs while keeping it available. When an owned Agent finishes an input, its owner receives a notification with the result. Owned Agents under the same root share \`${maxConcurrent}\` execution slots and a limit of \`${maxOutstanding}\` inputs that have not ended. Inputs that start a new turn queue when all slots are busy; new inputs are rejected at the input limit.`,
118
50
  promptSnippet: "Create, message, and coordinate Agents",
119
51
  promptGuidelines: [
120
52
  "Use `agent(action='spawn', name=..., message=...)` for concrete, bounded work with a clear expected result. Keep the first message self-contained; use `context='fork'` only when the Agent needs the caller's conversation.",
121
53
  "For independent work, start the Agents before waiting and partition tasks into non-overlapping responsibilities.",
122
- "Use `agent(action='wait', ...)` when the next step depends on Agent results. Prefer one longer wait over repeated short polling.",
54
+ "Use `agent(action='wait', ...)` when your next step or answer depends on Agent results. Prefer one longer wait over repeated short polling.",
123
55
  ],
124
56
  parameters: Type.Object({
125
- action: StringEnum(["spawn", "send", "wait", "list", "abort"] as const, { description: "Operation and applicable parameters: `spawn(name, message, cwd?, context?, model?, thinkingLevel?)`, `send(target, message, deliverAs?)`, `wait(target?, history?, timeout?)`, `list(query?, state?, limit?, offset?)`, or `abort(target)`." }),
57
+ action: StringEnum(["spawn", "send", "wait", "list", "abort"] as const, { description: "Operation and applicable parameters: `spawn(name, message, cwd?, context?, model?, thinkingLevel?, disown?)`, `send(target, message, deliverAs?)`, `wait(target?, timeout?)`, `list(query?, state?, limit?, offset?)`, or `abort(target)`." }),
126
58
  name: Type.Optional(Type.String({ description: "Name of the new Agent for `spawn`; unique among the caller's Agents." })),
127
59
  message: Type.Optional(Type.String({ description: "Non-empty text for `spawn` or `send`." })),
128
60
  cwd: Type.Optional(Type.String({ description: "Working directory for `spawn` (default: the caller's current working directory)." })),
129
61
  context: Type.Optional(StringEnum(["fresh", "fork"] as const, { description: "Context for `spawn` (default: fresh). fresh starts without the caller's conversation; fork snapshots it." })),
130
62
  model: Type.Optional(Type.String({ description: "Model for `spawn` as provider/modelId (default: the caller's model)." })),
131
63
  thinkingLevel: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level for `spawn`: off, minimal, low, medium, high, xhigh, or max (default: the caller's level)." })),
132
- target: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], { description: "Agent ID, name, or unique ID prefix for `send`, `wait`, or `abort`. `wait` and `send` with `deliverAs='write'` also accept an array. When omitted for `wait`, selects all owned Agents with pending or unread results." })),
133
- deliverAs: Type.Optional(StringEnum(["followUp", "steer", "write"] as const, { description: "Delivery for `send` (default: followUp). followUp waits until the recipient's current turn ends; steer delivers after its current tool calls, before the next model call; write delivers like steer but never starts a turn and has no result." })),
134
- history: Type.Optional(Type.Integer({ minimum: 0, description: "Number of most recent read results to return again per selected Agent for `wait` (default: 0)." })),
64
+ disown: Type.Optional(Type.Boolean({ description: "For `spawn`, start a root Agent the caller does not own; it replies with send instead of returning a result (default: false)." })),
65
+ target: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], { description: "Agent ID, name, or unique ID prefix for `send`, `wait`, or `abort`. `wait` and `send` with `deliverAs='write'` also accept an array. When omitted for `wait`, selects all owned Agents with pending inputs." })),
66
+ deliverAs: Type.Optional(StringEnum(["followUp", "steer", "write"] as const, { description: "Delivery for `send` (default: followUp). followUp waits until the recipient's current turn ends; steer delivers after its current tool calls, before the next model call; write delivers like steer but does not wake an idle recipient and has no result." })),
135
67
  timeout: Type.Optional(Type.Number({ minimum: 10, maximum: 3600, description: "Maximum seconds for `wait` (default: 30, min: 10, max: 3600). Timeout does not abort Agents." })),
136
68
  query: Type.Optional(Type.String({ description: "Search query for `list`, matching ID, name, cwd, summaries, and user messages. Case-insensitive; spaces mean AND; `|` means OR." })),
137
69
  state: Type.Optional(StringEnum(["running", "waiting", "queued", "idle"] as const, { description: "State for `list`: running, waiting, queued, or idle." })),
@@ -146,8 +78,18 @@ export function registerAgentTool(
146
78
  const targets = params.target === undefined ? undefined : Array.isArray(params.target) ? params.target : [params.target];
147
79
  switch (params.action) {
148
80
  case "spawn": {
149
- rejectFields(params, "spawn", ["target", "deliverAs", "history", "timeout", "query", "state", "limit", "offset"]);
81
+ rejectFields(params, "spawn", ["target", "deliverAs", "timeout", "query", "state", "limit", "offset"]);
150
82
  if (!params.name?.trim() || !params.message?.trim()) throw new Error("spawn requires name and message");
83
+ if (params.disown) {
84
+ if (params.context === "fork") throw new Error("Only an owned Agent can fork the caller's context.");
85
+ if (self.ownerId !== undefined) throw new Error("Only a root Agent can disown an Agent.");
86
+ const root = agents.disown({ name: params.name, cwd: params.cwd, model: params.model, thinkingLevel: params.thinkingLevel });
87
+ await roots.send({ id: root.id, label: root.name }, "followUp", params.message, signal);
88
+ return {
89
+ content: [{ type: "text", text: `Agent ${label(root, ids())} started.` }],
90
+ details: { id: root.id, name: root.name, queued: false },
91
+ };
92
+ }
151
93
  const { record, queued } = agents.spawn({
152
94
  name: params.name,
153
95
  message: params.message,
@@ -162,7 +104,7 @@ export function registerAgentTool(
162
104
  };
163
105
  }
164
106
  case "send": {
165
- rejectFields(params, "send", ["name", "cwd", "context", "model", "thinkingLevel", "history", "timeout", "query", "state", "limit", "offset"]);
107
+ rejectFields(params, "send", ["name", "cwd", "context", "model", "thinkingLevel", "timeout", "query", "state", "limit", "offset", "disown"]);
166
108
  const delivery = params.deliverAs ?? "followUp";
167
109
  if (!targets?.length || !params.message?.trim()) throw new Error("send requires target and message");
168
110
  if (targets.length > 1 && delivery !== "write") throw new Error("Only a write can go to several Agents.");
@@ -191,18 +133,20 @@ export function registerAgentTool(
191
133
  };
192
134
  }
193
135
  case "wait": {
194
- rejectFields(params, "wait", ["name", "message", "cwd", "context", "model", "thinkingLevel", "deliverAs", "query", "state", "limit", "offset"]);
136
+ rejectFields(params, "wait", ["name", "message", "cwd", "context", "model", "thinkingLevel", "deliverAs", "query", "state", "limit", "offset", "disown"]);
195
137
  const selected = targets?.map((target) => agents.ownedTarget(target));
196
138
  // An empty partial result lets the row show the elapsed time.
197
139
  onUpdate?.({ content: [], details: undefined });
198
140
  const session = agentSession(current.sessionManager);
199
- const outcome = await agents.wait(selected, params.history ?? 0, params.timeout ?? 30, signal, () => steerQueued(session?.agent));
200
- const rendered = renderWait(outcome, ids());
201
- agents.markRead(rendered.read);
202
- return { content: [{ type: "text", text: rendered.text }], details: rendered.details };
141
+ const { end, pending } = await agents.wait(selected, params.timeout ?? 30, signal, () => steerQueued(session?.agent));
142
+ const text = waitText(end, `Still pending: ${pending.map((agent) => label(agent, ids())).join(", ")}`);
143
+ return {
144
+ content: [{ type: "text", text }],
145
+ details: { pending: pending.map(({ id, name }) => ({ id, name })), ...(end === "timedOut" && { timedOut: true }), ...(end === "interrupted" && { interrupted: true }) },
146
+ };
203
147
  }
204
148
  case "list": {
205
- rejectFields(params, "list", ["name", "message", "cwd", "context", "model", "thinkingLevel", "target", "deliverAs", "history", "timeout"]);
149
+ rejectFields(params, "list", ["name", "message", "cwd", "context", "model", "thinkingLevel", "target", "deliverAs", "timeout", "disown"]);
206
150
  const offset = params.offset ?? 0;
207
151
  const limit = params.limit ?? 20;
208
152
  const all = await visible(self, signal);
@@ -218,7 +162,7 @@ export function registerAgentTool(
218
162
  return { content: [{ type: "text", text: text.text }], details: { total: items.length, agents, ...text.details } };
219
163
  }
220
164
  case "abort": {
221
- rejectFields(params, "abort", ["name", "message", "cwd", "context", "model", "thinkingLevel", "deliverAs", "history", "timeout", "query", "state", "limit", "offset"]);
165
+ rejectFields(params, "abort", ["name", "message", "cwd", "context", "model", "thinkingLevel", "deliverAs", "timeout", "query", "state", "limit", "offset", "disown"]);
222
166
  if (targets?.length !== 1) throw new Error("abort requires one target");
223
167
  const agent = agents.ownedTarget(targets[0]!);
224
168
  const aborted = await agents.abort(agent, { silent: true });
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ import { createReporter } from "./roots/report.ts";
41
41
  import { installQueuePatches } from "./roots/queue.ts";
42
42
  import { agentSession, installRpcPatches } from "./roots/rpc.ts";
43
43
  import { createRootRuntime } from "./roots/runtime.ts";
44
+ import { registerMainAgent } from "./main-agent.ts";
44
45
  import { renderAgentMessage } from "./ui/message.ts";
45
46
  import { createPanel } from "./ui/panel.ts";
46
47
  import { showQueue } from "./ui/queue.ts";
@@ -134,6 +135,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
134
135
  };
135
136
 
136
137
  pi.registerFlag(PROGRAM_ONLY_FLAG, { type: "boolean", description: "Present tools as with codemode.mode \"only\": scripts reach them through program" });
138
+ registerMainAgent(pi);
137
139
 
138
140
  let warnedBothScriptTools = false;
139
141
  pi.on("session_start", (_event, current) => {
@@ -155,9 +157,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
155
157
  cwd: () => ctx?.cwd ?? current.cwd,
156
158
  busy: () => !(ctx?.isIdle() ?? true),
157
159
  sessionFile: () => ctx?.sessionManager.getSessionFile(),
158
- receive: (text, delivery, messageId, from) => {
160
+ receive: (text, delivery, messageId, from, notice) => {
159
161
  const as = created.stopped ? "write" : delivery;
160
- pi.sendMessage(customMessage(text, as, { messageId, sender: from }), as === "write"
162
+ pi.sendMessage(customMessage(text, as, { messageId, sender: from, notice }), as === "write"
161
163
  ? { deliverAs: "steer" }
162
164
  : { deliverAs: as, triggerTurn: true });
163
165
  queue.update();
@@ -167,7 +169,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
167
169
  nodes.set(id, node);
168
170
  programs = new Programs(rootId, {
169
171
  appendEntry: (customType, data) => pi.appendEntry(customType, data),
170
- notify: (text) => agents.notify(text),
172
+ notify: (text, notice) => agents.notify(text, notice),
171
173
  changed: () => panel.update(),
172
174
  });
173
175
  created.programs = programs;
package/src/main-agent.md CHANGED
@@ -1,8 +1,6 @@
1
- # Main Agent
1
+ You are the user's main Agent.
2
2
 
3
- You are the user's main Agent. The user talks only to you; you hand work to other Agents and report back.
4
-
5
- - Handle a request yourself only if it takes at most three read-only tool calls; otherwise hand it to an Agent.
6
- - Before spawning, `list` Agents: send work in an area an Agent already handles to that Agent.
7
- - Spawn Agents in the directory the work belongs to, or `~` if none; never in your working directory.
8
- - Relay an Agent's questions to the user, and its results as a short conclusion.
3
+ - Do quick read-only checks yourself; give other work to Agents.
4
+ - Reuse the Agent already on a topic, or spawn one where the work is (`disown` for lasting topics).
5
+ - Don't wait for Agents; end your turn so the user can talk to you.
6
+ - Pass on their questions as asked and their results briefly.
@@ -0,0 +1,53 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import * as path from "node:path";
4
+ import { treeMetadata } from "./agents/registry.ts";
5
+ import { statePaths } from "./roots/paths.mjs";
6
+
7
+ export const MAIN_FLAG = "main";
8
+ const instructions = readFileSync(new URL("./main-agent.md", import.meta.url), "utf8").trim();
9
+
10
+ /** The main Agents' Session files by entry name, as `main.json` maps them; an old `{ sessionFile }` is the entry `main`. */
11
+ function mainEntries(): Record<string, string> {
12
+ try {
13
+ const entries = JSON.parse(readFileSync(statePaths().mainSession, "utf8")) as Record<string, string>;
14
+ return typeof entries.sessionFile === "string" ? { main: entries.sessionFile } : entries;
15
+ } catch {
16
+ return {};
17
+ }
18
+ }
19
+
20
+ /**
21
+ * A main Agent is a Session an entry of `main.json` names: `pd main --name <entry>` starts one with `--main=<entry>`,
22
+ * and `/new` in it starts the next for the same entry. It gets the main Agent's instructions.
23
+ */
24
+ export function registerMainAgent(pi: ExtensionAPI): void {
25
+ pi.registerFlag(MAIN_FLAG, { type: "string", description: "Start the main Agent of this entry (used by pd main)" });
26
+ let main = false;
27
+ pi.on("session_start", (event, ctx) => {
28
+ main = false;
29
+ if (treeMetadata(ctx)) return;
30
+ // Absolute, as `pd main` compares it from other directories; a session directory may be relative.
31
+ const current = ctx.sessionManager.getSessionFile();
32
+ const file = current && path.resolve(current);
33
+ const mains = mainEntries();
34
+ const flag = pi.getFlag(MAIN_FLAG);
35
+ const previous = event.reason === "new" && event.previousSessionFile !== undefined ? path.resolve(event.previousSessionFile) : undefined;
36
+ const names = event.reason === "startup" && typeof flag === "string"
37
+ ? [flag]
38
+ : Object.keys(mains).filter((name) => previous !== undefined && mains[name] === previous);
39
+ if (file && names.length > 0) {
40
+ for (const name of names) mains[name] = file;
41
+ const record = statePaths().mainSession;
42
+ mkdirSync(path.dirname(record), { recursive: true, mode: 0o700 });
43
+ // Replaced at once, so that a reader never sees it half written.
44
+ const temporary = `${record}.${process.pid}`;
45
+ writeFileSync(temporary, JSON.stringify(mains), { mode: 0o600 });
46
+ renameSync(temporary, record);
47
+ }
48
+ main = file !== undefined && Object.values(mains).includes(file);
49
+ });
50
+ pi.on("before_agent_start", (event) => {
51
+ if (main) event.systemPromptOptions.sections["main-agent"] = instructions;
52
+ });
53
+ }
package/src/output.ts CHANGED
@@ -40,21 +40,3 @@ export function boundText(text: string, prefix: string): BoundedText {
40
40
  details: { truncation, fullOutputPath },
41
41
  };
42
42
  }
43
-
44
- /**
45
- * Bounds result blocks joined by blank lines. The block that crosses the limit keeps its closing
46
- * tag; `shown` counts the blocks that appear, and the caller names the omitted ones.
47
- */
48
- export function boundBlocks(blocks: string[], tag: string, prefix: string): BoundedText & { shown: number } {
49
- const bounded = boundText(blocks.join("\n\n"), prefix);
50
- if (!bounded.details) return { ...bounded, shown: blocks.length };
51
- const kept = bounded.kept;
52
- let start = 0;
53
- const shown = blocks.filter((block) => {
54
- const visible = start < kept.length;
55
- start += block.length + 2;
56
- return visible;
57
- }).length;
58
- const open = kept.lastIndexOf(`<${tag} `) > kept.lastIndexOf(`</${tag}>`);
59
- return { ...bounded, text: `${kept}${open ? `\n</${tag}>` : ""}${bounded.text.slice(kept.length)}`, shown };
60
- }
@@ -3,9 +3,7 @@ import { Text } from "@earendil-works/pi-tui";
3
3
  import { CODEMODE_TOOL_NAME, type PiCodemode } from "./codemode.ts";
4
4
  import { PROGRAM_TOOL_NAME } from "./execute.ts";
5
5
  import { shortId } from "../agents/registry.ts";
6
- import { formatDuration, formatToolCall, moreLines, renderResultList, renderTextResult, startDuration, type DurationState } from "../render-call.ts";
7
- import type { Truncation } from "../output.ts";
8
- import { omittedLine, runningLine, type ProgramResultDetails } from "./programs.ts";
6
+ import { formatToolCall, moreLines, renderTextResult, startDuration, type DurationState } from "../render-call.ts";
9
7
 
10
8
  /** The first line of the `codemode` description, which the `program` intro replaces. */
11
9
  const CODEMODE_FIRST_LINE = "Run JavaScript code to orchestrate/compose tool calls\n";
@@ -17,6 +15,11 @@ type Changes = ReturnType<NonNullable<ToolDefinition["prepareLoadout"]>>;
17
15
  type Renderers = Pick<ToolDefinition, "renderCall" | "renderResult">;
18
16
 
19
17
  const CODE_PREVIEW_LINES = 10;
18
+ // The colors Pi highlights JavaScript with; renderers get the same theme object after a theme change, so these tell it apart.
19
+ const SYNTAX_COLORS = ["text", "muted", "syntaxComment", "syntaxKeyword", "syntaxFunction", "syntaxVariable", "syntaxString", "syntaxNumber", "syntaxType", "syntaxOperator", "syntaxPunctuation"] as const;
20
+
21
+ /** A call row's state: its duration, and its script's highlighting, kept across redraws. */
22
+ type CallState = DurationState & { script?: { code: string; colors: string; lines: string[] } };
20
23
 
21
24
  export const PROGRAM_ONLY_FLAG = "program-only";
22
25
  // Owned Agents run in their owner's process without its CLI flags, so the flag holds for the process.
@@ -82,10 +85,14 @@ export function programRenderers(piCodemode: PiCodemode): Renderers {
82
85
  return {
83
86
  renderCall(args, theme, context) {
84
87
  const { code, ...rest } = (args ?? {}) as Record<string, unknown>;
85
- startDuration(context.state as DurationState, rest.action === "wait", context.executionStarted);
88
+ const state = context.state as CallState;
89
+ startDuration(state, rest.action === "wait", context.executionStarted);
86
90
  let text = formatToolCall(PROGRAM_TOOL_NAME, rest, theme);
87
91
  if (typeof code === "string" && code) {
88
- const lines = highlightCode(code.replace(/\r/g, "").replace(/\t/g, " ").trimEnd(), "javascript");
92
+ const source = code.replace(/\r/g, "").replace(/\t/g, " ").trimEnd();
93
+ const colors = SYNTAX_COLORS.map((color) => theme.fg(color, "")).join("");
94
+ if (state.script?.code !== source || state.script.colors !== colors) state.script = { code: source, colors, lines: highlightCode(source, "javascript") };
95
+ const { lines } = state.script;
89
96
  const shown = context.expanded ? lines : lines.slice(0, CODE_PREVIEW_LINES);
90
97
  text += `\n${shown.join("\n")}`;
91
98
  if (shown.length < lines.length) text += `\n${moreLines(lines.length - shown.length, theme)}`;
@@ -94,21 +101,9 @@ export function programRenderers(piCodemode: PiCodemode): Renderers {
94
101
  component.setText(text);
95
102
  return component;
96
103
  },
97
- // `wait` shows how long it has waited, and when collapsed, each result as a line with the first lines of its output;
98
- // the other results render like `codemode`.
104
+ // `wait` shows how long it has waited; the other results render like `codemode`.
99
105
  renderResult: (result, options, theme, context) => (context.args as { action?: string }).action === "wait"
100
- ? renderTextResult(result, options, theme, context, () => {
101
- const { results = [], running = [], interrupted, ...truncated } = (result.details ?? {}) as { results?: ProgramResultDetails[]; running?: string[]; interrupted?: boolean } & Partial<Truncation>;
102
- const only = results[0];
103
- // As `renderProgramWait` returns them, no results, or a single completed result alone, show as they are.
104
- if (!only || (results.length === 1 && running.length === 0 && only.state === "completed")) return undefined;
105
- const ids = [...results.map((entry) => entry.id), ...running];
106
- const notes = [omittedLine(results.flatMap((entry) => entry.output === undefined ? [entry.id] : []), ids), runningLine(running, interrupted, ids)];
107
- return renderResultList(results.flatMap(({ id, state, wallTimeSeconds, output }) => output === undefined ? [] : [{
108
- line: [`Program ${shortId(id, ids)}`, state, ...(wallTimeSeconds === undefined ? [] : [formatDuration(wallTimeSeconds * 1000)])].join(" "),
109
- body: output,
110
- }]), notes, truncated, theme);
111
- })
106
+ ? renderTextResult(result, options, theme, context)
112
107
  : piCodemode.renderResult(result, options, theme, context) as Text,
113
108
  };
114
109
  }