@xzzpig/pi-tidy 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +1104 -0
  2. package/FORK-CHANGELOG.md +31 -0
  3. package/LICENSE +21 -0
  4. package/README.md +416 -2
  5. package/fork/defaults.ts +77 -0
  6. package/fork/index.ts +2 -0
  7. package/fork/install.ts +58 -0
  8. package/fork/thinking-row.ts +221 -0
  9. package/package.json +68 -3
  10. package/src/clean-copy.ts +313 -0
  11. package/src/clipboard.ts +29 -0
  12. package/src/config.ts +292 -0
  13. package/src/draw/frame-draw.ts +307 -0
  14. package/src/draw/icons.ts +2014 -0
  15. package/src/draw/paint.ts +57 -0
  16. package/src/frame.ts +20 -0
  17. package/src/hover.ts +185 -0
  18. package/src/index.ts +322 -0
  19. package/src/live.ts +91 -0
  20. package/src/messages/bash-mode-frame.ts +112 -0
  21. package/src/messages/error-lines.ts +331 -0
  22. package/src/messages/extension-notice.ts +204 -0
  23. package/src/messages/hex-swatches.ts +245 -0
  24. package/src/messages/message-frame.ts +515 -0
  25. package/src/messages/user-message.ts +207 -0
  26. package/src/theme-access.ts +26 -0
  27. package/src/tools/background-jobs.ts +617 -0
  28. package/src/tools/bash-highlight.ts +330 -0
  29. package/src/tools/codemode-body.ts +602 -0
  30. package/src/tools/result-preview.ts +56 -0
  31. package/src/tools/split-diff.ts +606 -0
  32. package/src/tools/tool-frame.ts +1019 -0
  33. package/src/tools/tool-group-view.ts +1160 -0
  34. package/src/tools/tool-groups.ts +706 -0
  35. package/src/tools/tool-lines.ts +621 -0
  36. package/src/tools/write-preview.ts +81 -0
  37. package/src/turns/code-blocks.ts +273 -0
  38. package/src/turns/turn-lifecycle.ts +43 -0
  39. package/src/turns/turn-state.ts +257 -0
  40. package/src/turns/turn-view.ts +504 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `!` / `!!` bash runs — the commands the user types, not the agent's — in a
3
+ * rounded frame of their own.
4
+ *
5
+ * pi draws them between two full-width rules in `bashMode`, which reads like
6
+ * the agent's bash box with its frame missing. The frame here is tidy's, but
7
+ * marked as the user's: the user icon instead of the terminal one, the command
8
+ * behind the `!` or `!!` that was typed instead of `$`, and the user
9
+ * messages' color for the border. The agent's bash boxes change color with their
10
+ * state; this one keeps its color, and pi's own rows say how the run went
11
+ * (the spinner, `(exit 1)`, `(cancelled)`). `!!` — output kept out of the
12
+ * model's context — stays dim, as pi draws it.
13
+ *
14
+ * Everything under the command row is pi's own: the 20-row preview, `ctrl+o`,
15
+ * the truncation notice.
16
+ */
17
+
18
+ import { BashExecutionComponent } from "@earendil-works/pi-coding-agent";
19
+ import { highlightBashCommand } from "../tools/bash-highlight.ts";
20
+ import { drawFrame, sameLines, stripBackgroundFills, trimBlankEdges } from "../draw/frame-draw.ts";
21
+ import { MESSAGE_ICONS } from "../draw/icons.ts";
22
+ import { liveSlot, tidyEnabled } from "../live.ts";
23
+ import { paint } from "../draw/paint.ts";
24
+ import { getTheme, type ThemeLike } from "../theme-access.ts";
25
+
26
+ export interface BashModeOptions {
27
+ enabled: boolean;
28
+ icons: boolean;
29
+ /** The user messages' color (frame border or rail): a theme color name or `#rrggbb`. */
30
+ color: string;
31
+ }
32
+
33
+ interface BashExecutionInternals {
34
+ command: string;
35
+ /** `"dim"` for `!!`, `"bashMode"` for `!`. */
36
+ colorKey?: string;
37
+ contentContainer?: { children: Array<{ render(width: number): string[] }> };
38
+ render(width: number): string[];
39
+ __tidyBashModeCache?: { width: number; sample: string; raw: string[]; out: string[] };
40
+ }
41
+
42
+ const bashModeOptions = liveSlot<BashModeOptions | undefined>("bash-mode-options", undefined);
43
+
44
+ /**
45
+ * `theme` with `fg` taking `#rrggbb` too, for `drawFrame`. Forwarded, not
46
+ * spread: pi's theme keeps its methods on the class prototype.
47
+ */
48
+ function withHexColors(theme: ThemeLike): ThemeLike {
49
+ return { fg: (color, text) => paint(theme, color, text), bold: (text) => theme.bold(text) };
50
+ }
51
+
52
+ function commandRow(component: BashExecutionInternals, theme: ThemeLike, color: string, icons: boolean): string {
53
+ const excluded = component.colorKey === "dim";
54
+ const prompt = paint(theme, color, theme.bold(excluded ? "!! " : "! "));
55
+ let command: string;
56
+ try {
57
+ command = highlightBashCommand(component.command, (token, text) => theme.fg(token, text));
58
+ } catch {
59
+ command = component.command;
60
+ }
61
+ const icon = icons ? `${paint(theme, color, MESSAGE_ICONS.user)} ` : "";
62
+ return ` ${icon}${prompt}${command}`;
63
+ }
64
+
65
+ function renderBashMode(
66
+ component: BashExecutionInternals,
67
+ width: number,
68
+ theme: ThemeLike,
69
+ options: BashModeOptions,
70
+ ): string[] | undefined {
71
+ const container = component.contentContainer;
72
+ if (!container || typeof component.command !== "string") return undefined;
73
+ const color = component.colorKey === "dim" ? "dim" : options.color;
74
+
75
+ // Child 0 is pi's `$ command` header; the rows under it are kept as drawn.
76
+ const raw: string[] = [];
77
+ for (const child of container.children.slice(1)) raw.push(...child.render(width - 2));
78
+
79
+ const sample = paint(theme, color, "·");
80
+ const cache = component.__tidyBashModeCache;
81
+ if (cache && cache.width === width && cache.sample === sample && sameLines(cache.raw, raw)) return cache.out;
82
+
83
+ const body = trimBlankEdges(raw.map(stripBackgroundFills));
84
+ const content = [commandRow(component, theme, color, options.icons), ...(body.length > 0 ? ["", ...body] : [])];
85
+ const out = ["", ...drawFrame(content, width, withHexColors(theme), color)];
86
+ component.__tidyBashModeCache = { width, sample, raw, out };
87
+ return out;
88
+ }
89
+
90
+ /**
91
+ * Install the patch once; later calls (a `/reload`) only update the options it
92
+ * reads. `enabled: false`, `tidy.enabled: false`, a missing theme or a render
93
+ * error leaves pi's own drawing.
94
+ */
95
+ export function patchBashExecutions(options: BashModeOptions): void {
96
+ bashModeOptions.set(options);
97
+ const proto = BashExecutionComponent.prototype as unknown as BashExecutionInternals & { __tidyBashMode?: boolean };
98
+ if (proto.__tidyBashMode) return;
99
+ proto.__tidyBashMode = true;
100
+
101
+ const originalRender = proto.render;
102
+ proto.render = function (this: BashExecutionInternals, width: number): string[] {
103
+ const live = bashModeOptions.get();
104
+ const theme = getTheme();
105
+ if (!theme || !live?.enabled || !tidyEnabled.get() || width < 6) return originalRender.call(this, width);
106
+ try {
107
+ return renderBashMode(this, width, theme, live) ?? originalRender.call(this, width);
108
+ } catch {
109
+ return originalRender.call(this, width);
110
+ }
111
+ };
112
+ }
@@ -0,0 +1,331 @@
1
+ /**
2
+ * pi's own error rows — the ones that are not a tool result: a failed request,
3
+ * an extension that threw, a provider that dropped blocks. It builds each as a
4
+ * plain `ThemedText` (`Error: 429: {…}`) and appends it straight to the chat
5
+ * container, once per retry attempt.
6
+ *
7
+ * Two things are wrong with that: the attempt count never changes the text, so
8
+ * a retry storm leaves three identical rows carrying the provider's raw body;
9
+ * and the row has no frame, unlike every other box in the transcript.
10
+ *
11
+ * So exactly one row survives: a repeat of the same text folds into it with a
12
+ * `×N`, and that row wears the frame the rest of tidy's boxes wear. The rows
13
+ * are transport state, not conversation, but they are also the only record
14
+ * that a turn failed — dropping them all would leave the failure invisible.
15
+ *
16
+ * `ThemedText` is not exported and never reaches a class tidy could patch, so
17
+ * the hook is the container: after `InteractiveMode.init`, the chat container
18
+ * gets its own `addChild`. Rows not painted in the error colour (warnings,
19
+ * notes, dim asides) pass through untouched — a billing notice is information,
20
+ * and repeating one is a real event, not a retry.
21
+ */
22
+
23
+ import { AssistantMessageComponent, InteractiveMode } from "@earendil-works/pi-coding-agent";
24
+ import { type Component, Spacer, Text } from "@earendil-works/pi-tui";
25
+ import { drawFrame, labeledTopBorder, stripBackgroundFills, trimBlankEdges, visibleText } from "../draw/frame-draw.ts";
26
+ import { liveSlot, tidyEnabled } from "../live.ts";
27
+ import { getTheme } from "../theme-access.ts";
28
+
29
+ export interface ErrorLineOptions {
30
+ /** Fold a repeated error row into the first one as `×N`, and frame it. */
31
+ enabled: boolean;
32
+ }
33
+
34
+ const errorLineOptions = liveSlot<ErrorLineOptions>("error-line-options", { enabled: true });
35
+
36
+ /** Marks a container already watching, so a second install can't double-wrap. */
37
+ const INSTALLED = Symbol.for("pi-tidy:error-lines");
38
+ const INIT_PATCH = Symbol.for("pi-tidy:error-lines-init");
39
+
40
+ export interface ChatContainerLike {
41
+ children: Component[];
42
+ addChild(child: Component): void;
43
+ }
44
+
45
+ /** Wide enough that a probe render says what the row holds, not how it wraps. */
46
+ const PROBE_WIDTH = 400;
47
+
48
+ /** The escape `theme.fg(color, …)` opens with, for prefix matching. */
49
+ function opener(color: string): string {
50
+ const probe = getTheme()?.fg(color, "\u0000") ?? "";
51
+ return probe.split("\u0000")[0] ?? "";
52
+ }
53
+
54
+ function rowsOf(component: Component): string[] {
55
+ try {
56
+ return component.render(PROBE_WIDTH).map(stripBackgroundFills);
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ /**
63
+ * The colour a row is painted in, when it is one of pi's status rows: pi wraps
64
+ * the whole line in a single `fg`, so the first visible row opens with it.
65
+ */
66
+ function rowColor(rows: string[]): string | undefined {
67
+ const row = rows.find((line) => visibleText(line).trim() !== "");
68
+ if (!row) return undefined;
69
+ const painted = row.trimStart();
70
+ for (const color of ["error", "warning"]) {
71
+ const prefix = opener(color);
72
+ if (prefix && painted.startsWith(prefix)) return color;
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ function blankRows(rows: string[]): boolean {
78
+ return rows.every((line) => visibleText(line).trim() === "");
79
+ }
80
+
81
+ /**
82
+ * Give one row the error frame, labelled `label()` at draw time. Only `render`
83
+ * changes: the row keeps its identity, and nothing is drawn ahead of the frame.
84
+ */
85
+ function frameErrorRow(child: Component, label: () => string, live?: () => boolean): void {
86
+ const unpainted = child.render.bind(child);
87
+ child.render = (width: number): string[] => {
88
+ if (live && !live()) return unpainted(width);
89
+ const theme = getTheme();
90
+ const w = Math.max(4, width);
91
+ const rows = trimBlankEdges(unpainted(Math.max(2, w - 2)).map(stripBackgroundFills));
92
+ if (rows.length === 0) return [];
93
+ // No theme yet: pi's shell hasn't painted anything either, so keep the
94
+ // rows exactly as pi drew them rather than inventing colours.
95
+ if (!theme) return rows;
96
+ const framed = drawFrame(rows, w, theme, "error");
97
+ framed[0] = labeledTopBorder(w, theme, "error", theme.fg("error", label()));
98
+ return framed;
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Watch one chat container. `addChild` is the only seam, and the rows keep
104
+ * their identity: pi holds references to some of them (`removeChild` on the
105
+ * previous status line), so a repeat is simply not added and the survivor gets
106
+ * its own `render`.
107
+ */
108
+ export function installErrorLines(container: ChatContainerLike): void {
109
+ const target = container as ChatContainerLike & { [INSTALLED]?: boolean };
110
+ if (target[INSTALLED]) return;
111
+ target[INSTALLED] = true;
112
+
113
+ const addChild = container.addChild.bind(container);
114
+ let kept: Component | undefined;
115
+ let keptText = "";
116
+ let keptCount = 0;
117
+
118
+ const reset = () => {
119
+ kept = undefined;
120
+ keptText = "";
121
+ keptCount = 0;
122
+ };
123
+
124
+ const adopt = (child: Component, text: string) => {
125
+ kept = child;
126
+ keptText = text;
127
+ keptCount = 1;
128
+ frameErrorRow(child, () => (keptCount > 1 ? `error ×${keptCount}` : "error"));
129
+ };
130
+
131
+ container.addChild = (child: Component) => {
132
+ // Only pi's status rows (a `Text`) and the spacers between them can take
133
+ // part, so nothing else is probed. A probe is a render outside the frame's
134
+ // top-to-bottom pass, and the tool grouping reads that order: a probed tool
135
+ // joined its group before the step that issued it (the step's thinking row
136
+ // then hung above the summary line), and a probed message, not filled in
137
+ // yet, read as a group boundary.
138
+ if (!(child instanceof Text) && !(child instanceof Spacer)) {
139
+ reset();
140
+ const message = child as AssistantLike;
141
+ if (message.contentContainer) chatOf.set(child, container);
142
+ addChild(child);
143
+ // A resumed session builds the message before adding it: it is already
144
+ // filled in, and only now has a place in the chat.
145
+ if (message.contentContainer) noteFailure(message, message.lastMessage);
146
+ return;
147
+ }
148
+ const rows = rowsOf(child);
149
+ // Separators neither repeat nor break a repeat: pi may space two attempts
150
+ // apart, and that should still read as one failure.
151
+ if (rows.length > 0 && blankRows(rows)) {
152
+ addChild(child);
153
+ return;
154
+ }
155
+ const isErrorRow =
156
+ tidyEnabled.get() && errorLineOptions.get().enabled && child instanceof Text && rowColor(rows) === "error";
157
+ if (!isErrorRow) {
158
+ reset();
159
+ addChild(child);
160
+ return;
161
+ }
162
+ const text = rows.map(visibleText).join("\n").trim();
163
+ // pi's "Retry failed after N attempts: <error>" closes a run of failed
164
+ // attempts: it says the same as their box, and says it last.
165
+ const run = failureOf.get(previousMessage(container, container.children.length) as object);
166
+ if (run?.error && text.includes(run.error)) {
167
+ run.closed = true;
168
+ for (const member of run.members) member.contentContainer?.clear?.();
169
+ }
170
+ if (kept && keptText === text && container.children.includes(kept)) {
171
+ keptCount++;
172
+ return;
173
+ }
174
+ adopt(child, text);
175
+ addChild(child);
176
+ };
177
+ }
178
+
179
+ const ASSISTANT_PATCH = Symbol.for("pi-tidy:error-lines-assistant");
180
+
181
+ interface AssistantLike {
182
+ contentContainer?: { children?: Component[]; clear?: () => void };
183
+ lastMessage?: FailedMessageLike;
184
+ }
185
+
186
+ interface FailedMessageLike {
187
+ stopReason?: unknown;
188
+ content?: unknown;
189
+ errorMessage?: unknown;
190
+ }
191
+
192
+ /**
193
+ * pi retries a failed request as a new assistant message per attempt, each
194
+ * drawing the same `Error: 429 …`. Consecutive identical failures are one run:
195
+ * the first message keeps the box and counts `×N`, the others draw nothing.
196
+ * When pi gives up it adds "Retry failed after N attempts", and the run's box
197
+ * gives way to that row.
198
+ */
199
+ interface FailureRun {
200
+ key: string;
201
+ error: string;
202
+ count: number;
203
+ members: AssistantLike[];
204
+ closed: boolean;
205
+ }
206
+
207
+ const failureOf = new WeakMap<object, FailureRun>();
208
+ /** The chat each assistant message was added to: its place in the transcript. */
209
+ const chatOf = new WeakMap<object, ChatContainerLike>();
210
+
211
+ /** The message before position `index`, past spacers. Never rendered. */
212
+ function previousMessage(container: ChatContainerLike, index: number): Component | undefined {
213
+ for (let i = index - 1; i >= 0; i--) {
214
+ const child = container.children[i];
215
+ if (!(child instanceof Spacer)) return child;
216
+ }
217
+ return undefined;
218
+ }
219
+
220
+ function isEmpty(message: FailedMessageLike): boolean {
221
+ return Array.isArray(message.content) && message.content.length === 0;
222
+ }
223
+
224
+ function noteFailure(component: AssistantLike, message: FailedMessageLike | undefined): void {
225
+ if (!message || failureOf.has(component) || !failedWithNothingElse(message)) return;
226
+ if (!tidyEnabled.get() || !errorLineOptions.get().enabled) return;
227
+ const container = chatOf.get(component);
228
+ if (!container) return;
229
+ const error = typeof message.errorMessage === "string" ? message.errorMessage : "";
230
+ const key = `${String(message.stopReason)}\u0000${error}`;
231
+ const index = container.children.indexOf(component as Component);
232
+ const run = index > 0 ? failureOf.get(previousMessage(container, index) as object) : undefined;
233
+ if (run && !run.closed && run.key === key && isEmpty(message)) {
234
+ run.count++;
235
+ run.members.push(component);
236
+ failureOf.set(component, run);
237
+ } else {
238
+ failureOf.set(component, { key, error, count: 1, members: [component], closed: false });
239
+ }
240
+ hideIfAbsorbed(component);
241
+ }
242
+
243
+ /** A repeat, or a run pi has closed, draws nothing. */
244
+ function hideIfAbsorbed(component: AssistantLike): void {
245
+ const run = failureOf.get(component);
246
+ if (run && (run.closed || run.members[0] !== component)) component.contentContainer?.clear?.();
247
+ }
248
+
249
+ /**
250
+ * A request that fails for good never reaches the chat as a row of its own:
251
+ * pi ends the assistant message with `stopReason: "error"` and draws
252
+ * `Error: …` as the last line *inside* that message. A length stop does the
253
+ * same with "Response was truncated". Neither passes `addChild` on the chat, so
254
+ * they stayed bare while a retried failure wore the frame.
255
+ *
256
+ * Only a failure with nothing else to show is framed. After streamed text the
257
+ * line belongs to that text, and the settled reply may wear its own frame: a
258
+ * box inside it would be a frame in a frame. An abort is the user's own Esc,
259
+ * not a failure, and stays as pi drew it.
260
+ *
261
+ * The hook is `updateContent`, which rebuilds the message's rows: the line is
262
+ * found by what pi put there, never by rendering it.
263
+ */
264
+ function patchAssistantErrors(): void {
265
+ const proto = (AssistantMessageComponent as unknown as { prototype?: Record<string | symbol, unknown> }).prototype;
266
+ if (!proto || proto[ASSISTANT_PATCH]) return;
267
+ const original = proto.updateContent;
268
+ if (typeof original !== "function") return;
269
+ proto[ASSISTANT_PATCH] = true;
270
+ proto.updateContent = function (this: AssistantLike, message: FailedMessageLike, ...rest: unknown[]) {
271
+ const result = (original as (...args: unknown[]) => unknown).call(this, message, ...rest);
272
+ if (failedWithNothingElse(message)) {
273
+ const last = this.contentContainer?.children?.at(-1);
274
+ if (last instanceof Text) {
275
+ frameErrorRow(
276
+ last,
277
+ () => {
278
+ const run = failureOf.get(this);
279
+ return run && run.count > 1 ? `error ×${run.count}` : "error";
280
+ },
281
+ () => tidyEnabled.get() && errorLineOptions.get().enabled,
282
+ );
283
+ }
284
+ noteFailure(this, message);
285
+ // pi rebuilds the rows on every update (a theme change, a thinking
286
+ // toggle): a message the run absorbed stays absorbed.
287
+ hideIfAbsorbed(this);
288
+ }
289
+ return result;
290
+ };
291
+ }
292
+
293
+ function failedWithNothingElse(message: FailedMessageLike | undefined): boolean {
294
+ if (!message || !Array.isArray(message.content)) return false;
295
+ if (message.stopReason !== "error" && message.stopReason !== "length") return false;
296
+ // pi leaves a failed tool call's error to the tool row, and draws nothing here.
297
+ if (message.stopReason === "error" && message.content.some((block) => block?.type === "toolCall")) return false;
298
+ return !message.content.some((block) => block?.type === "text" && typeof block.text === "string" && block.text.trim());
299
+ }
300
+
301
+ /**
302
+ * pi's interactive mode owns the chat container and hands it to nobody, so the
303
+ * instance is reached through `init`. A pi that renames that method leaves the
304
+ * hook uninstalled: rows then render as pi draws them, which is the behaviour
305
+ * before this patch and not a crash.
306
+ */
307
+ export function patchErrorLines(options: ErrorLineOptions): void {
308
+ errorLineOptions.set(options);
309
+ patchAssistantErrors();
310
+ const proto = (InteractiveMode as unknown as { prototype?: Record<string | symbol, unknown> }).prototype;
311
+ if (!proto || proto[INIT_PATCH]) return;
312
+ const original = proto.init;
313
+ if (typeof original !== "function") return;
314
+ const init = original as (...args: unknown[]) => unknown;
315
+ proto[INIT_PATCH] = true;
316
+ proto.init = function (this: { chatContainer?: ChatContainerLike }, ...args: unknown[]) {
317
+ const install = () => {
318
+ const chat = this?.chatContainer;
319
+ if (chat && typeof chat.addChild === "function") installErrorLines(chat);
320
+ };
321
+ const result = init.apply(this, args);
322
+ if (result && typeof (result as Promise<unknown>).then === "function") {
323
+ return (result as Promise<unknown>).then((value) => {
324
+ install();
325
+ return value;
326
+ });
327
+ }
328
+ install();
329
+ return result;
330
+ };
331
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * What an extension's notice says, read from the message rather than from its
3
+ * renderer.
4
+ *
5
+ * A custom message whose renderer paints its own chrome gives Tidy nothing to
6
+ * work with: its rows are the extension's, and the one thing a reader needs
7
+ * from them — who it is about and how it went — is buried in styled text. Every
8
+ * notice an extension sends also carries the text the model read (`content`),
9
+ * and most carry structured `details`, so the label and the body can come from
10
+ * there instead and the frame stays Tidy's.
11
+ *
12
+ * pi-subagents names its notices `subagent…`; `subagent-notify`'s
13
+ * `details.runs` is documented (its `docs/extension-api.md`, "Host session
14
+ * lifetime and completion wakes"), and the other types are read field by field
15
+ * with a fallback — a notice that names nobody is still drawn, just with the
16
+ * glyph alone.
17
+ */
18
+
19
+ export type NoticeTone = "success" | "error" | "warning";
20
+
21
+ /** The label a notice wears in its frame. Both halves are optional. */
22
+ export interface NoticeLabel {
23
+ /** Who the notice is about: a subagent's name, when the message carries one. */
24
+ name?: string;
25
+ /** What happened, in the plugin's own words. */
26
+ status?: string;
27
+ /** A theme color for the border; Tidy's `custom` color when nothing says. */
28
+ tone?: NoticeTone;
29
+ }
30
+
31
+ interface NoticeMessage {
32
+ customType?: unknown;
33
+ content?: unknown;
34
+ details?: unknown;
35
+ }
36
+
37
+ /** pi-subagents' own `STATUS_TONES`, word for word: what a status means to a reader. */
38
+ const STATUS_TONES: Record<string, NoticeTone> = {
39
+ completed: "success",
40
+ failed: "error",
41
+ partial: "error",
42
+ blocked: "error",
43
+ unhandled: "error",
44
+ "save failed": "error",
45
+ "reconciliation failed": "error",
46
+ blocker: "error",
47
+ paused: "warning",
48
+ stopped: "warning",
49
+ recovered: "warning",
50
+ unanswered: "warning",
51
+ concern: "warning",
52
+ interrupted: "warning",
53
+ "timed out": "warning",
54
+ "could not be reconciled": "warning",
55
+ "needs attention": "warning",
56
+ "needs a decision": "warning",
57
+ "needs clarification": "warning",
58
+ decision: "warning",
59
+ "interview request": "warning",
60
+ progress_update: "warning",
61
+ };
62
+
63
+ /** Fields a notice may carry the subject of, in the order worth trying. */
64
+ const NAME_FIELDS = ["agent", "childKey", "workflowKey", "runId"];
65
+ /** Fields that say how it went, in the order worth trying. */
66
+ const STATUS_FIELDS = ["status", "outcome", "state", "severity", "reason"];
67
+
68
+ const controlNotice = "subagent_control_notice";
69
+ const completionNotice = "subagent-notify";
70
+
71
+ const record = (value: unknown): Record<string, unknown> | undefined =>
72
+ typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
73
+
74
+ /**
75
+ * A notice Tidy draws itself: pi-subagents' whole namespace. Other extensions
76
+ * keep their renderer — their message body is not known to be text.
77
+ */
78
+ export function isNoticeType(customType: unknown): customType is string {
79
+ return typeof customType === "string" && customType.startsWith("subagent");
80
+ }
81
+
82
+ /**
83
+ * Every run a completion notice reports, or nothing. All or nothing: a list
84
+ * with one unreadable entry says nothing trustworthy about the notice.
85
+ */
86
+ function runs(details: unknown): Array<{ agent: string; status: string }> | undefined {
87
+ const list = record(details)?.runs;
88
+ if (!Array.isArray(list) || list.length === 0) return undefined;
89
+ const parsed = list.flatMap((run) => {
90
+ const entry = record(run);
91
+ return typeof entry?.agent === "string" && typeof entry?.status === "string"
92
+ ? [{ agent: entry.agent, status: entry.status }]
93
+ : [];
94
+ });
95
+ return parsed.length === list.length ? parsed : undefined;
96
+ }
97
+
98
+ /** Worst tone of a group: an error outranks a warning, a warning ranks a success. */
99
+ function worstTone(list: Array<{ status: string }>): NoticeTone | undefined {
100
+ const tones = list.map((run) => STATUS_TONES[run.status]);
101
+ return tones.includes("error") ? "error" : tones.includes("warning") ? "warning" : tones.includes("success") ? "success" : undefined;
102
+ }
103
+
104
+ /** Words in a notice's type name that say what kind of message it is, not what it is about. */
105
+ const KIND_NOISE = new Set(["notice", "warning", "message", "notify", "update"]);
106
+
107
+ /**
108
+ * What kind of notice this is, from its type name: `subagent_watchdog_warning`
109
+ * is a watchdog. Used when the message names nobody, so the row still says
110
+ * something — the glyph alone cannot say whether it is a run or a watchdog.
111
+ */
112
+ function kindOf(customType: string): string | undefined {
113
+ const words = customType
114
+ .replace(/^subagent[-_]?/, "")
115
+ .split(/[-_]/)
116
+ .filter((word) => word && !KIND_NOISE.has(word.toLowerCase()));
117
+ return words.length > 0 ? words.join(" ") : undefined;
118
+ }
119
+
120
+ /** The label for a notice, or undefined when this message is not one. */
121
+ export function noticeLabel(message: NoticeMessage | undefined): NoticeLabel | undefined {
122
+ const type = message?.customType;
123
+ if (!isNoticeType(type)) return undefined;
124
+ const details = record(message?.details);
125
+
126
+ if (type === controlNotice) {
127
+ const event = record(details?.event);
128
+ const name = event?.agent;
129
+ const status = event?.type;
130
+ if (typeof name !== "string" || typeof status !== "string") return {};
131
+ return { name, status: status.replaceAll("_", " "), tone: "warning" };
132
+ }
133
+
134
+ if (type === completionNotice) {
135
+ const list = runs(message?.details);
136
+ if (!list) return {};
137
+ if (list.length === 1) {
138
+ const [run] = list as [{ agent: string; status: string }];
139
+ return { name: run.agent, status: run.status, tone: STATUS_TONES[run.status] };
140
+ }
141
+ return { status: `${list.length} background runs`, tone: worstTone(list) };
142
+ }
143
+
144
+ // The rest of pi-subagents' notices each carry part of it: `agent` and
145
+ // `outcome` for a wait, `childKey` and `outcome` for a workflow child,
146
+ // `severity` for a watchdog warning. Whatever is missing is simply not drawn.
147
+ const name = firstString(details, NAME_FIELDS) ?? kindOf(type);
148
+ const status = statusOf(details);
149
+ return {
150
+ ...(name ? { name } : {}),
151
+ ...(status ? { status: status.text, ...(status.tone ? { tone: status.tone } : {}) } : {}),
152
+ };
153
+ }
154
+
155
+ /** The first non-empty string among these fields. */
156
+ function firstString(source: Record<string, unknown> | undefined, fields: readonly string[]): string | undefined {
157
+ for (const field of fields) {
158
+ const value = source?.[field];
159
+ if (typeof value === "string" && value.trim()) return value;
160
+ }
161
+ return undefined;
162
+ }
163
+
164
+ /** The human words and the tone of whatever status field this notice carries. */
165
+ function statusOf(details: Record<string, unknown> | undefined): { text: string; tone?: NoticeTone } | undefined {
166
+ for (const field of STATUS_FIELDS) {
167
+ const value = details?.[field];
168
+ if (typeof value !== "string" || !value.trim()) continue;
169
+ const text = value.replaceAll("_", " ");
170
+ const tone = STATUS_TONES[text] ?? STATUS_TONES[field];
171
+ return tone ? { text, tone } : { text };
172
+ }
173
+ return undefined;
174
+ }
175
+
176
+ /** The text the model read: a custom message's content, flattened to one string. */
177
+ export function noticeText(message: NoticeMessage | undefined): string {
178
+ const content = message?.content;
179
+ if (typeof content === "string") return content;
180
+ if (!Array.isArray(content)) return "";
181
+ return content
182
+ .flatMap((part) => {
183
+ const block = record(part);
184
+ return block?.type === "text" && typeof block.text === "string" ? [block.text] : [];
185
+ })
186
+ .join("\n");
187
+ }
188
+
189
+ /**
190
+ * The body of a notice: its text, minus the lines the frame's label already
191
+ * says. A completion notice opens with its own title, then with `<agent>:`, the
192
+ * child's answer underneath; both are the label again. Nothing is removed while
193
+ * the label says nothing — the first line is then the only summary there is —
194
+ * and nothing is removed when it would leave no text at all.
195
+ */
196
+ export function noticeBodyText(message: NoticeMessage | undefined, label: NoticeLabel | undefined): string {
197
+ const text = noticeText(message);
198
+ if (!label || (!label.name && !label.status)) return text;
199
+ const [, ...rest] = text.split("\n");
200
+ const body = rest.slice(rest.findIndex((line) => line.trim() !== ""));
201
+ if (label.name && body[0]?.trim() === `${label.name}:`) body.shift();
202
+ while (body.length > 0 && body[0]!.trim() === "") body.shift();
203
+ return body.length > 0 ? body.join("\n") : text;
204
+ }