@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.
- package/CHANGELOG.md +1104 -0
- package/FORK-CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +416 -2
- package/fork/defaults.ts +77 -0
- package/fork/index.ts +2 -0
- package/fork/install.ts +58 -0
- package/fork/thinking-row.ts +221 -0
- package/package.json +68 -3
- package/src/clean-copy.ts +313 -0
- package/src/clipboard.ts +29 -0
- package/src/config.ts +292 -0
- package/src/draw/frame-draw.ts +307 -0
- package/src/draw/icons.ts +2014 -0
- package/src/draw/paint.ts +57 -0
- package/src/frame.ts +20 -0
- package/src/hover.ts +185 -0
- package/src/index.ts +322 -0
- package/src/live.ts +91 -0
- package/src/messages/bash-mode-frame.ts +112 -0
- package/src/messages/error-lines.ts +331 -0
- package/src/messages/extension-notice.ts +204 -0
- package/src/messages/hex-swatches.ts +245 -0
- package/src/messages/message-frame.ts +515 -0
- package/src/messages/user-message.ts +207 -0
- package/src/theme-access.ts +26 -0
- package/src/tools/background-jobs.ts +617 -0
- package/src/tools/bash-highlight.ts +330 -0
- package/src/tools/codemode-body.ts +602 -0
- package/src/tools/result-preview.ts +56 -0
- package/src/tools/split-diff.ts +606 -0
- package/src/tools/tool-frame.ts +1019 -0
- package/src/tools/tool-group-view.ts +1160 -0
- package/src/tools/tool-groups.ts +706 -0
- package/src/tools/tool-lines.ts +621 -0
- package/src/tools/write-preview.ts +81 -0
- package/src/turns/code-blocks.ts +273 -0
- package/src/turns/turn-lifecycle.ts +43 -0
- package/src/turns/turn-state.ts +257 -0
- 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
|
+
}
|