pi-squire 0.9.4 → 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,17 @@ 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
+
9
20
  ## [0.9.4] - 2026-10-23
10
21
 
11
22
  ### Changed
@@ -364,7 +375,8 @@ author's dotfiles; the changes below are against that copy.
364
375
 
365
376
  - The published file list includes `child.ts`, which `index.ts` imports.
366
377
 
367
- [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.9.4...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
368
380
  [0.9.4]: https://github.com/gvanderclay/pi-squire/compare/v0.9.3...v0.9.4
369
381
  [0.9.3]: https://github.com/gvanderclay/pi-squire/compare/v0.9.2...v0.9.3
370
382
  [0.9.2]: https://github.com/gvanderclay/pi-squire/compare/v0.9.1...v0.9.2
package/README.md CHANGED
@@ -448,17 +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 `~`.
457
- - The `delegate` tool's own row is drawn the same way, so a launch and its
458
- result match: a call line naming the agent and label, then a header with
459
- a ◐ started icon, the delegate's name and the model it started on, a preview of the task and any skipped fallback models;
460
- expanded, the whole task, then the delegation id, window, thinking and
461
- auto-exit. A refused call keeps its plain error text. 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 `~`.
462
465
  - Stopping a delegate's run (Esc) sends no result: the provider holds the
463
466
  task, so the delegation stays running while the user steers it. The next
464
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.4",
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/render.ts CHANGED
@@ -1,17 +1,20 @@
1
- // How delegations look in the parent: the `delegate` tool's row and the
2
- // result or gone message that arrives later share one header and preview
3
- // style. Both are drawn from structured details, while their text stays what
4
- // the 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.
5
8
  import { homedir } from "node:os";
6
9
 
7
10
  import {
8
11
  getMarkdownTheme,
9
- keyText,
12
+ keyHint,
10
13
  type MessageRenderer,
11
14
  type MessageRenderOptions,
12
15
  type Theme,
13
16
  } from "@earendil-works/pi-coding-agent";
14
- 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";
15
18
  import type { Static } from "typebox";
16
19
 
17
20
  import type { DelegateSchema } from "./index.ts";
@@ -22,44 +25,20 @@ export function modelLabel(model: string, finished: string | undefined): string
22
25
  return finished === undefined || finished === model ? model : `${model} → ${finished}`;
23
26
  }
24
27
 
25
- /** Lines of the body the collapsed view previews. */
26
- 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;
27
32
 
28
- /** The icon and theme color for each status; an unknown status gets a neutral icon. */
29
- const STATUS_STYLE: Record<string, { icon: string; color: "accent" | "success" | "error" | "warning" | "muted" }> = {
30
- started: { icon: "◐", color: "accent" },
31
- done: { icon: "✓", color: "success" },
32
- failed: { icon: "✗", color: "error" },
33
- stopped: { icon: "■", color: "warning" },
34
- gone: { icon: "⊘", color: "warning" },
35
- };
36
-
37
- /** The header line: status icon, the delegate's name and the model it ran on. */
38
- function header(details: MessageDetails, theme: Theme): string {
39
- const status = details.kind === "gone" ? "gone" : details.status;
40
- const known = Object.hasOwn(STATUS_STYLE, status);
41
- const style = known ? STATUS_STYLE[status] : { icon: "•", color: "muted" as const };
42
- let trailer = "";
43
- if (details.kind === "gone") trailer = " closed without a result";
44
- else if (!known) trailer = ` ${details.status}`;
45
- return headerLine(theme, style, details.name, modelLabel(details.model, details.finishedModel), trailer);
46
- }
47
-
48
- function headerLine(
49
- theme: Theme,
50
- style: { icon: string; color: "accent" | "success" | "error" | "warning" | "muted" },
51
- name: string,
52
- model: string,
53
- trailer: string,
54
- ): string {
55
- return `${theme.fg(style.color, style.icon)} ${theme.fg("accent", name)} ${theme.fg("muted", model)}${
56
- trailer === "" ? "" : theme.fg(style.color, trailer)
57
- }`;
58
- }
33
+ type Color = "success" | "error" | "warning" | "muted" | "dim";
59
34
 
60
- function expandHint(theme: Theme): Text {
61
- return new Text(`${theme.fg("dim", keyText("app.tools.expand"))}${theme.fg("muted", " to expand")}`, 0, 0);
62
- }
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
+ };
63
42
 
64
43
  /** `path` with the home directory shown as `~`. */
65
44
  function tilde(path: string): string {
@@ -67,26 +46,11 @@ function tilde(path: string): string {
67
46
  return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
68
47
  }
69
48
 
70
- function metadata(details: MessageDetails): string[] {
71
- const lines = [`delegation ${details.id}`, `request ${details.requestId}`];
72
- if (details.kind === "result") lines.push(`envelope ${tilde(details.envelopePath)}`);
73
- else lines.push(`window ${details.windowName}`);
74
- lines.push(
75
- `session ${details.sessionPath === undefined ? `${details.id} (no session file found)` : tilde(details.sessionPath)}`,
76
- );
77
- return lines;
78
- }
79
-
80
- /**
81
- * The first non-blank lines of a body, with heading marks, backticks and bold
82
- * markers stripped. Plain text, not Markdown, because a cut-off fragment can
83
- * break mid-list or mid-code-block.
84
- */
85
- function preview(body: string): string[] {
86
- 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
87
52
  .split("\n")
88
53
  .filter((line) => line.trim() !== "")
89
- .slice(0, PREVIEW_LINES)
90
54
  .map((line) =>
91
55
  line
92
56
  .replace(/^#+\s+/, "")
@@ -95,10 +59,20 @@ function preview(body: string): string[] {
95
59
  );
96
60
  }
97
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
+
98
72
  /**
99
- * A delegate message drawn from its details. Collapsed: a header, a short
100
- * preview of the result body and an expand hint. Expanded: the header, the
101
- * 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
102
76
  * with tool output; the model always gets the full content.
103
77
  */
104
78
  export function renderMessage(
@@ -108,54 +82,90 @@ export function renderMessage(
108
82
  ): Component | undefined {
109
83
  const details = message.details as MessageDetails | undefined;
110
84
  if (details?.kind !== "result" && details?.kind !== "gone") return undefined;
111
- const box = new Box(outputPad, 1, (text) => theme.bg("customMessageBg", text));
112
- if (!expanded) {
113
- box.addChild(new TruncatedText(header(details, theme)));
114
- if (details.kind === "result") {
115
- for (const line of preview(details.body)) box.addChild(new TruncatedText(theme.fg("muted", line)));
116
- }
117
- box.addChild(expandHint(theme));
118
- 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));
119
116
  }
120
- box.addChild(new Text(header(details, theme), 0, 0));
121
- const dim = metadata(details);
122
- if (details.kind === "gone") dim.push("A reply that arrives later is still delivered.");
123
- else if (details.limit !== undefined) dim.push(details.limit);
124
- const metadataText = new Text(dim.map((line) => theme.fg("dim", line)).join("\n"), 0, 0);
125
- if (details.kind === "gone") {
126
- box.addChild(metadataText);
127
- 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));
128
129
  }
129
- const { task } = details;
130
- const quote =
131
- "note" in task
132
- ? theme.fg("muted", task.note)
133
- : `${theme.fg("muted", `Task (${tilde(task.path)}):`)}\n${task.text
134
- .split("\n")
135
- .map((line) => theme.fg("muted", `> ${line}`))
136
- .join("\n")}`;
137
- box.addChild(new Text(quote, 0, 0));
138
- box.addChild(new Spacer(1));
139
- box.addChild(new Text(theme.fg("dim", "Result"), 0, 0));
140
- box.addChild(new Markdown(details.body, 0, 0, getMarkdownTheme()));
141
- box.addChild(new Spacer(1));
142
- box.addChild(metadataText);
143
- 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)`) : ""}`;
144
142
  }
145
143
 
146
- /** The `delegate` tool's call line: the tool, the agent and any label. Arguments may still be streaming. */
147
- export function renderDelegateCall(args: { agent?: unknown; label?: unknown }, theme: Theme): Component {
148
- let line = theme.bold("delegate");
149
- if (typeof args.agent === "string") line += ` ${theme.fg("accent", args.agent)}`;
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))}`;
150
158
  if (typeof args.label === "string" && args.label !== "" && args.label !== "null")
151
- line += ` ${theme.fg("muted", `· ${args.label}`)}`;
152
- return new Text(line, 0, 0);
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);
153
163
  }
154
164
 
155
165
  /**
156
- * The `delegate` tool's result, in the result message's style: a started
157
- * header, then a task preview and the hint, or, expanded, the whole task and
158
- * the ids. A refused or blocked call has no details and keeps its plain text.
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.
159
169
  */
160
170
  export function renderDelegateResult(
161
171
  result: { content: { type: string; text?: string }[]; details?: unknown },
@@ -169,29 +179,23 @@ export function renderDelegateResult(
169
179
  return new Text(context.isError ? theme.fg("error", text) : text, 0, 0);
170
180
  }
171
181
  const started = details as Static<typeof DelegateSchema>;
172
- const task = typeof context.args.task === "string" ? context.args.task : "";
173
- const box = new Box(0, 0);
174
- box.addChild(new TruncatedText(headerLine(theme, STATUS_STYLE.started, started.windowName, started.model, "")));
175
- const skipped = started.skipped.map(({ model, reason }) => theme.fg("warning", `skipped ${model}: ${reason}`));
176
- if (!expanded) {
177
- for (const line of preview(task)) box.addChild(new TruncatedText(theme.fg("muted", line)));
178
- for (const line of skipped) box.addChild(new TruncatedText(line));
179
- box.addChild(expandHint(theme));
180
- return box;
181
- }
182
- const quote = task
183
- .split("\n")
184
- .map((line) => theme.fg("muted", `> ${line}`))
185
- .join("\n");
186
- box.addChild(new Text(quote, 0, 0));
187
- if (skipped.length + started.notices.length > 0)
188
- box.addChild(new Text([...skipped, ...started.notices.map((line) => theme.fg("dim", line))].join("\n"), 0, 0));
189
- box.addChild(new Spacer(1));
190
- const ids = [
191
- `delegation ${started.id}`,
192
- `window ${started.windowName} (${started.windowId})`,
193
- `thinking ${started.thinking}, auto-exit ${started.autoExit ? "on" : "off"}`,
182
+ const lines = [
183
+ `${theme.fg("accent", "●")} ${theme.fg("toolTitle", theme.bold(started.windowName))}${theme.fg(
184
+ "dim",
185
+ ` (${started.model}) — started`,
186
+ )}`,
194
187
  ];
195
- box.addChild(new Text(ids.map((line) => theme.fg("dim", line)).join("\n"), 0, 0));
196
- return box;
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);
197
201
  }