pi-squire 0.9.3 → 0.9.5

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 CHANGED
@@ -6,6 +6,27 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.5] - 2026-10-23
10
+
11
+ ### Changed
12
+
13
+ - A delegation is drawn as one tool call that changes state, in the style of
14
+ pi-interactive-subagents, replacing the 0.9.2 to 0.9.4 look: the `delegate`
15
+ row shows `○` with the agent, label and task while the call runs, and one
16
+ header, `● <name> (<model>) — started`, once the delegate starts; the result arrives as a
17
+ finished tool row on Pi's tool success or error background, `✓ <name> (<model>) — done` or `✗` with the status,
18
+ with a 5-line preview. The renderer tests change with it.
19
+
20
+ ## [0.9.4] - 2026-10-23
21
+
22
+ ### Changed
23
+
24
+ - The `delegate` tool's row is drawn in the same style as the result message:
25
+ a call line naming the agent and label, then a ◐ header with the delegate's
26
+ name and model, a task preview and any skipped fallback models; expanded,
27
+ the whole task and the delegation id, window, thinking and auto-exit. The
28
+ text the model reads is unchanged.
29
+
9
30
  ## [0.9.3] - 2026-10-23
10
31
 
11
32
  ### Changed
@@ -354,7 +375,9 @@ author's dotfiles; the changes below are against that copy.
354
375
 
355
376
  - The published file list includes `child.ts`, which `index.ts` imports.
356
377
 
357
- [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.9.3...HEAD
378
+ [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.9.5...HEAD
379
+ [0.9.5]: https://github.com/gvanderclay/pi-squire/compare/v0.9.4...v0.9.5
380
+ [0.9.4]: https://github.com/gvanderclay/pi-squire/compare/v0.9.3...v0.9.4
358
381
  [0.9.3]: https://github.com/gvanderclay/pi-squire/compare/v0.9.2...v0.9.3
359
382
  [0.9.2]: https://github.com/gvanderclay/pi-squire/compare/v0.9.1...v0.9.2
360
383
  [0.9.1]: https://github.com/gvanderclay/pi-squire/compare/v0.9.0...v0.9.1
package/README.md CHANGED
@@ -448,12 +448,20 @@ Delegate session: <session file or id>
448
448
  or `/model` in its window), it reads `launched → finished`, for example
449
449
  `(anthropic/claude-haiku-5-5 → openai/gpt-6-luna)`. `delegation_status`
450
450
  reports the same.
451
- - That text is what the parent model reads. The user sees it drawn instead:
452
- collapsed, a header with a status icon (✓ done, ✗ failed, ■ stopped), the
453
- delegate's name and the model, then the first 3 non-blank lines of the
454
- result with heading marks, backticks and bold stripped; expanded (Ctrl+O),
455
- the quoted task, the result as Markdown, then the ids and files, with the
456
- home directory shown as `~`. The closed-without-a-result message gets the same header with ⊘.
451
+ - That text is what the parent model reads. The user sees the delegation as
452
+ one tool call that changes state, in the style of
453
+ [pi-interactive-subagents](https://github.com/amosblomqvist/pi-interactive-subagents):
454
+ - The `delegate` row shows `○` with the agent, the label, the task's first
455
+ line and its line count while the call runs. Once the delegate starts,
456
+ that header is replaced by `● <name> (<model>) — started` over the same
457
+ task line, with any skipped fallback model. A refused call keeps its
458
+ `○` header above the plain error text.
459
+ - The result arrives drawn as a finished tool row, on Pi's tool success or
460
+ error background: `✓ <name> (<model>) — done`, or `✗` with `failed`,
461
+ `stopped` or `closed without a result`. Collapsed, it previews the first
462
+ 5 non-blank lines with heading marks, backticks and bold stripped;
463
+ expanded (Ctrl+O), it shows the result as Markdown, the quoted task, then
464
+ the ids and files, with the home directory shown as `~`.
457
465
  - Stopping a delegate's run (Esc) sends no result: the provider holds the
458
466
  task, so the delegation stays running while the user steers it. The next
459
467
  run that completes sends the result, `done`, and its body opens with a note
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squire",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "description": "Starts a delegate Pi session in a background tmux window, sends it the task over message:*, and shows the parent its result",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ import { Type } from "typebox";
49
49
  import { type Agent, isThinking, type Roster, readRoster, THINKING_LEVELS } from "./agents.ts";
50
50
  import { AGENT_ENV, AUTO_EXIT_ENV, FALLBACK_ENV, registerChild } from "./child.ts";
51
51
  import { activeMark, clearMarks, isFree, readMarks } from "./limits.ts";
52
+ import { renderDelegateCall, renderDelegateResult } from "./render.ts";
52
53
  import { createResults, delegateName } from "./results.ts";
53
54
  import { createTmuxClient, type TmuxClient } from "./tmux.ts";
54
55
  import { createTracking, toolResult } from "./tracking.ts";
@@ -596,6 +597,8 @@ export default function delegate(
596
597
  ),
597
598
  }),
598
599
  outputSchema: DelegateSchema,
600
+ renderCall: renderDelegateCall,
601
+ renderResult: renderDelegateResult,
599
602
  // Opens a new window each call and removes nothing. Open world: the delegate calls a remote model and runs its agent's tools unattended.
600
603
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
601
604
  async execute(
package/src/render.ts CHANGED
@@ -1,17 +1,23 @@
1
- // How a delegate message looks in the parent: drawn from the structured
2
- // details `results.ts` attaches, while the message's text stays what the
3
- // parent model reads.
1
+ // How delegations look in the parent. A delegation reads as one tool call that
2
+ // changes state: the `delegate` row shows ○ while its arguments arrive and ●
3
+ // once started, and the result message that arrives later is drawn as a
4
+ // finished tool row (✓ or ✗ on Pi's tool success or error background). The
5
+ // style follows amosblomqvist/pi-interactive-subagents (c3e8b53,
6
+ // pi-extension/subagents/index.ts:1891-1944 and 2353-2460). Everything is
7
+ // drawn from structured details; the text the parent model reads is separate.
4
8
  import { homedir } from "node:os";
5
9
 
6
10
  import {
7
11
  getMarkdownTheme,
8
- keyText,
12
+ keyHint,
9
13
  type MessageRenderer,
10
14
  type MessageRenderOptions,
11
15
  type Theme,
12
16
  } from "@earendil-works/pi-coding-agent";
13
- import { Box, type Component, Markdown, Spacer, Text, TruncatedText } from "@earendil-works/pi-tui";
17
+ import { Box, type Component, Container, Markdown, Spacer, Text, TruncatedText } from "@earendil-works/pi-tui";
18
+ import type { Static } from "typebox";
14
19
 
20
+ import type { DelegateSchema } from "./index.ts";
15
21
  import type { MessageDetails } from "./results.ts";
16
22
 
17
23
  /** The launch model, or `launch → finished` when the delegate finished on another one. */
@@ -19,30 +25,20 @@ export function modelLabel(model: string, finished: string | undefined): string
19
25
  return finished === undefined || finished === model ? model : `${model} → ${finished}`;
20
26
  }
21
27
 
22
- /** Lines of the body the collapsed view previews. */
23
- const PREVIEW_LINES = 3;
28
+ /** Lines of a result the collapsed message previews. */
29
+ const PREVIEW_LINES = 5;
30
+ /** Characters of the task's first line the call row shows. */
31
+ const TASK_PREVIEW_CHARS = 100;
24
32
 
25
- /** The icon and theme color for each status; an unknown status gets a neutral icon. */
26
- const STATUS_STYLE: Record<string, { icon: string; color: "success" | "error" | "warning" | "muted" }> = {
27
- done: { icon: "✓", color: "success" },
28
- failed: { icon: "✗", color: "error" },
29
- stopped: { icon: "■", color: "warning" },
30
- gone: { icon: "⊘", color: "warning" },
31
- };
33
+ type Color = "success" | "error" | "warning" | "muted" | "dim";
32
34
 
33
- /** The header line: status icon, the delegate's name and the model it ran on. */
34
- function header(details: MessageDetails, theme: Theme): string {
35
- const status = details.kind === "gone" ? "gone" : details.status;
36
- const known = Object.hasOwn(STATUS_STYLE, status);
37
- const style = known ? STATUS_STYLE[status] : { icon: "•", color: "muted" as const };
38
- let trailer = "";
39
- if (details.kind === "gone") trailer = " closed without a result";
40
- else if (!known) trailer = ` ${details.status}`;
41
- const model = theme.fg("muted", modelLabel(details.model, details.finishedModel));
42
- return `${theme.fg(style.color, style.icon)} ${theme.fg("accent", details.name)} ${model}${
43
- trailer === "" ? "" : theme.fg(style.color, trailer)
44
- }`;
45
- }
35
+ /** The icon, its color and the status word for each way a delegation ends. */
36
+ const ENDINGS: Record<string, { icon: string; color: Color; word: string; wordColor: Color }> = {
37
+ done: { icon: "✓", color: "success", word: "done", wordColor: "dim" },
38
+ failed: { icon: "✗", color: "error", word: "failed", wordColor: "error" },
39
+ stopped: { icon: "✗", color: "warning", word: "stopped", wordColor: "warning" },
40
+ gone: { icon: "✗", color: "warning", word: "closed without a result", wordColor: "warning" },
41
+ };
46
42
 
47
43
  /** `path` with the home directory shown as `~`. */
48
44
  function tilde(path: string): string {
@@ -50,26 +46,11 @@ function tilde(path: string): string {
50
46
  return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
51
47
  }
52
48
 
53
- function metadata(details: MessageDetails): string[] {
54
- const lines = [`delegation ${details.id}`, `request ${details.requestId}`];
55
- if (details.kind === "result") lines.push(`envelope ${tilde(details.envelopePath)}`);
56
- else lines.push(`window ${details.windowName}`);
57
- lines.push(
58
- `session ${details.sessionPath === undefined ? `${details.id} (no session file found)` : tilde(details.sessionPath)}`,
59
- );
60
- return lines;
61
- }
62
-
63
- /**
64
- * The first non-blank lines of a body, with heading marks, backticks and bold
65
- * markers stripped. Plain text, not Markdown, because a cut-off fragment can
66
- * break mid-list or mid-code-block.
67
- */
68
- function preview(body: string): string[] {
69
- return body
49
+ /** Non-blank lines with heading marks, backticks and bold markers stripped, for a plain-text preview. */
50
+ function plainLines(text: string): string[] {
51
+ return text
70
52
  .split("\n")
71
53
  .filter((line) => line.trim() !== "")
72
- .slice(0, PREVIEW_LINES)
73
54
  .map((line) =>
74
55
  line
75
56
  .replace(/^#+\s+/, "")
@@ -78,10 +59,20 @@ function preview(body: string): string[] {
78
59
  );
79
60
  }
80
61
 
62
+ function idLines(details: MessageDetails): string[] {
63
+ const lines = [`delegation ${details.id}`, `request ${details.requestId}`];
64
+ if (details.kind === "result") lines.push(`envelope ${tilde(details.envelopePath)}`);
65
+ else lines.push(`window ${details.windowName}`);
66
+ lines.push(
67
+ `session ${details.sessionPath === undefined ? `${details.id} (no session file found)` : tilde(details.sessionPath)}`,
68
+ );
69
+ return lines;
70
+ }
71
+
81
72
  /**
82
- * A delegate message drawn from its details. Collapsed: a header, a short
83
- * preview of the result body and an expand hint. Expanded: the header, the
84
- * quoted task, the body as Markdown, then the ids and files. Ctrl+O toggles it
73
+ * A delegate result or gone message, drawn as a finished tool row: a header
74
+ * (icon, name, model, status), then a preview and the expand hint, or,
75
+ * expanded, the result as Markdown, the task and the ids. Ctrl+O toggles it
85
76
  * with tool output; the model always gets the full content.
86
77
  */
87
78
  export function renderMessage(
@@ -91,37 +82,120 @@ export function renderMessage(
91
82
  ): Component | undefined {
92
83
  const details = message.details as MessageDetails | undefined;
93
84
  if (details?.kind !== "result" && details?.kind !== "gone") return undefined;
94
- const box = new Box(outputPad, 1, (text) => theme.bg("customMessageBg", text));
95
- if (!expanded) {
96
- box.addChild(new TruncatedText(header(details, theme)));
97
- if (details.kind === "result") {
98
- for (const line of preview(details.body)) box.addChild(new TruncatedText(theme.fg("muted", line)));
99
- }
100
- box.addChild(new Text(`${theme.fg("dim", keyText("app.tools.expand"))}${theme.fg("muted", " to expand")}`, 0, 0));
101
- return box;
85
+ const status = details.kind === "gone" ? "gone" : details.status;
86
+ const background = status === "done" ? "toolSuccessBg" : "toolErrorBg";
87
+ const box = new Box(outputPad, 1, (text) => theme.bg(background, text));
88
+ const header = headerLine(details, status, theme);
89
+ box.addChild(expanded ? new Text(header, 0, 0) : new TruncatedText(header));
90
+ if (details.kind === "result" && details.limit !== undefined)
91
+ box.addChild(new Text(theme.fg("warning", details.limit), 0, 0));
92
+ for (const child of expanded ? expandedLines(details, theme) : collapsedLines(details, theme)) box.addChild(child);
93
+ return box;
94
+ }
95
+
96
+ /** The icon, the delegate's name, the model and the status word. */
97
+ function headerLine(details: MessageDetails, status: string, theme: Theme): string {
98
+ const ending = Object.hasOwn(ENDINGS, status)
99
+ ? ENDINGS[status]
100
+ : { icon: "•", color: "muted" as const, word: status, wordColor: "muted" as const };
101
+ const model = modelLabel(details.model, details.finishedModel);
102
+ return `${theme.fg(ending.color, ending.icon)} ${theme.fg("toolTitle", theme.bold(details.name))}${theme.fg(
103
+ "dim",
104
+ ` (${model}) —`,
105
+ )} ${theme.fg(ending.wordColor, ending.word)}`;
106
+ }
107
+
108
+ /** A result's first lines, a count of the rest, and the expand hint. */
109
+ function collapsedLines(details: MessageDetails, theme: Theme): Component[] {
110
+ const children: Component[] = [];
111
+ if (details.kind === "result") {
112
+ const lines = plainLines(details.body);
113
+ for (const line of lines.slice(0, PREVIEW_LINES)) children.push(new TruncatedText(theme.fg("dim", line)));
114
+ if (lines.length > PREVIEW_LINES)
115
+ children.push(new Text(theme.fg("muted", `… ${lines.length - PREVIEW_LINES} more lines`), 0, 0));
102
116
  }
103
- box.addChild(new Text(header(details, theme), 0, 0));
104
- const dim = metadata(details);
105
- if (details.kind === "gone") dim.push("A reply that arrives later is still delivered.");
106
- else if (details.limit !== undefined) dim.push(details.limit);
107
- const metadataText = new Text(dim.map((line) => theme.fg("dim", line)).join("\n"), 0, 0);
108
- if (details.kind === "gone") {
109
- box.addChild(metadataText);
110
- return box;
117
+ children.push(new Text(keyHint("app.tools.expand", "to expand"), 0, 0));
118
+ return children;
119
+ }
120
+
121
+ /** The result as Markdown and the quoted task, then the ids and files. */
122
+ function expandedLines(details: MessageDetails, theme: Theme): Component[] {
123
+ const dim = (lines: string[]): Text => new Text(lines.map((line) => theme.fg("dim", line)).join("\n"), 0, 0);
124
+ const children: Component[] = [];
125
+ if (details.kind === "result") {
126
+ const { task } = details;
127
+ const quote = "note" in task ? [task.note] : [`Task (${tilde(task.path)}):`, ...task.text.split("\n")];
128
+ children.push(new Spacer(1), new Markdown(details.body, 0, 0, getMarkdownTheme()), new Spacer(1), dim(quote));
111
129
  }
112
- const { task } = details;
113
- const quote =
114
- "note" in task
115
- ? theme.fg("muted", task.note)
116
- : `${theme.fg("muted", `Task (${tilde(task.path)}):`)}\n${task.text
117
- .split("\n")
118
- .map((line) => theme.fg("muted", `> ${line}`))
119
- .join("\n")}`;
120
- box.addChild(new Text(quote, 0, 0));
121
- box.addChild(new Spacer(1));
122
- box.addChild(new Text(theme.fg("dim", "Result"), 0, 0));
123
- box.addChild(new Markdown(details.body, 0, 0, getMarkdownTheme()));
124
- box.addChild(new Spacer(1));
125
- box.addChild(metadataText);
126
- return box;
130
+ const tail = details.kind === "gone" ? ["A reply that arrives later is still delivered."] : [];
131
+ children.push(new Spacer(1), dim([...idLines(details), ...tail]));
132
+ return children;
133
+ }
134
+
135
+ /** The task's first non-blank line, cut at 100 characters, and its line count when it has more than one. */
136
+ function taskLine(task: unknown, theme: Theme): string {
137
+ if (typeof task !== "string") return "";
138
+ const first = task.split("\n").find((line) => line.trim() !== "") ?? "";
139
+ const shown = first.length > TASK_PREVIEW_CHARS ? `${first.slice(0, TASK_PREVIEW_CHARS)}…` : first;
140
+ const total = task.split("\n").length;
141
+ return `${shown === "" ? "" : theme.fg("toolOutput", shown)}${total > 1 ? theme.fg("muted", ` (${total} lines)`) : ""}`;
142
+ }
143
+
144
+ /**
145
+ * The `delegate` tool's call: ○, the agent, any label and the task line,
146
+ * while arguments stream and the call runs. Pi redraws the call above the
147
+ * result, so once a launch succeeds the call draws nothing and the result's
148
+ * header is the row's only one; a refused call keeps it above the error.
149
+ */
150
+ export function renderDelegateCall(
151
+ args: { agent?: unknown; label?: unknown; task?: unknown },
152
+ theme: Theme,
153
+ context: { isPartial: boolean; isError: boolean },
154
+ ): Component {
155
+ if (!context.isPartial && !context.isError) return new Container();
156
+ const agent = typeof args.agent === "string" && args.agent !== "" ? args.agent : "…";
157
+ let text = `○ ${theme.fg("toolTitle", theme.bold(agent))}`;
158
+ if (typeof args.label === "string" && args.label !== "" && args.label !== "null")
159
+ text += theme.fg("dim", ` [${args.label}]`);
160
+ const task = taskLine(args.task, theme);
161
+ if (task !== "") text += `\n${task}`;
162
+ return new Text(text, 0, 0);
163
+ }
164
+
165
+ /**
166
+ * The `delegate` tool's result: ●, the delegate's name, its model and
167
+ * "started", the task line and any skipped fallback models; expanded, the ids too. A
168
+ * refused or blocked call has no details and keeps its plain text.
169
+ */
170
+ export function renderDelegateResult(
171
+ result: { content: { type: string; text?: string }[]; details?: unknown },
172
+ { expanded }: { expanded: boolean },
173
+ theme: Theme,
174
+ context: { args: { task?: unknown }; isError: boolean },
175
+ ): Component {
176
+ const details = result.details as Partial<Static<typeof DelegateSchema>> | undefined;
177
+ if (context.isError || details?.outcome !== "started") {
178
+ const text = result.content.map((part) => part.text ?? "").join("\n");
179
+ return new Text(context.isError ? theme.fg("error", text) : text, 0, 0);
180
+ }
181
+ const started = details as Static<typeof DelegateSchema>;
182
+ const lines = [
183
+ `${theme.fg("accent", "●")} ${theme.fg("toolTitle", theme.bold(started.windowName))}${theme.fg(
184
+ "dim",
185
+ ` (${started.model}) — started`,
186
+ )}`,
187
+ ];
188
+ const task = taskLine(context.args.task, theme);
189
+ if (task !== "") lines.push(task);
190
+ lines.push(...started.skipped.map(({ model, reason }) => theme.fg("warning", `[Skipped ${model}: ${reason}]`)));
191
+ if (expanded)
192
+ lines.push(
193
+ ...[
194
+ ...started.notices,
195
+ `delegation ${started.id}`,
196
+ `window ${started.windowName} (${started.windowId})`,
197
+ `thinking ${started.thinking}, auto-exit ${started.autoExit ? "on" : "off"}`,
198
+ ].map((line) => theme.fg("dim", line)),
199
+ );
200
+ return new Text(lines.join("\n"), 0, 0);
127
201
  }