@gavin-hu/my-pi-agent 0.9.0 → 0.10.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/README.md +28 -2
- package/README.zh-CN.md +27 -2
- package/assets/bytefly.schema.json +17 -1
- package/assets/chrome.png +0 -0
- package/assets/job.png +0 -0
- package/assets/plan-review.png +0 -0
- package/assets/question.png +0 -0
- package/assets/rewind.png +0 -0
- package/assets/todo.png +0 -0
- package/assets/tool-renderings.png +0 -0
- package/assets/tools/ask_user_question.png +0 -0
- package/assets/tools/bash.png +0 -0
- package/assets/tools/edit.png +0 -0
- package/assets/tools/enter_plan_mode.png +0 -0
- package/assets/tools/enter_worktree.png +0 -0
- package/assets/tools/exit_plan_mode.png +0 -0
- package/assets/tools/exit_worktree.png +0 -0
- package/assets/tools/find.png +0 -0
- package/assets/tools/goal.png +0 -0
- package/assets/tools/grep.png +0 -0
- package/assets/tools/job.png +0 -0
- package/assets/tools/list_worktrees.png +0 -0
- package/assets/tools/ls.png +0 -0
- package/assets/tools/memory.png +0 -0
- package/assets/tools/notice.png +0 -0
- package/assets/tools/open_path.png +0 -0
- package/assets/tools/prune_worktrees.png +0 -0
- package/assets/tools/read.png +0 -0
- package/assets/tools/read_doc.png +0 -0
- package/assets/tools/run_doctor.png +0 -0
- package/assets/tools/send_wechat.png +0 -0
- package/assets/tools/set_plan_steps.png +0 -0
- package/assets/tools/subagent.png +0 -0
- package/assets/tools/todo.png +0 -0
- package/assets/tools/turn-rule.png +0 -0
- package/assets/tools/user-message.png +0 -0
- package/assets/tools/watch.png +0 -0
- package/assets/tools/web_fetch.png +0 -0
- package/assets/tools/web_search.png +0 -0
- package/assets/tools/write.png +0 -0
- package/assets/tools/write_plan.png +0 -0
- package/extensions/bytefly/README.md +261 -36
- package/extensions/bytefly/block/component.ts +282 -0
- package/extensions/bytefly/block/config.ts +24 -0
- package/extensions/bytefly/block/fallback.ts +120 -0
- package/extensions/bytefly/block/register.ts +164 -0
- package/extensions/bytefly/chrome.ts +97 -0
- package/extensions/bytefly/doctor/tools.ts +5 -2
- package/extensions/bytefly/header/component.ts +21 -10
- package/extensions/bytefly/header/config.ts +0 -3
- package/extensions/bytefly/header/register.ts +6 -1
- package/extensions/bytefly/index.ts +32 -12
- package/extensions/bytefly/message/config.ts +15 -0
- package/extensions/bytefly/message/format.ts +42 -0
- package/extensions/bytefly/message/register.ts +15 -0
- package/extensions/bytefly/notice/component.ts +107 -0
- package/extensions/bytefly/notice/config.ts +41 -0
- package/extensions/bytefly/notice/register.ts +27 -0
- package/extensions/bytefly/pad.ts +42 -0
- package/extensions/bytefly/separator/component.ts +2 -1
- package/extensions/bytefly/separator/format.ts +17 -10
- package/extensions/bytefly/separator/register.ts +3 -2
- package/extensions/bytefly/working/config.ts +20 -0
- package/extensions/bytefly/working/format.ts +25 -0
- package/extensions/bytefly/working/register.ts +88 -0
- package/extensions/document/README.md +11 -4
- package/extensions/document/paging.ts +10 -10
- package/extensions/document/render.ts +7 -7
- package/extensions/file-browse/README.md +3 -1
- package/extensions/file-browse/client.ts +9 -2
- package/extensions/file-browse/git.ts +4 -7
- package/extensions/file-browse/html.ts +27 -17
- package/extensions/file-open/README.md +6 -0
- package/extensions/goal/README.md +16 -10
- package/extensions/goal/format.ts +7 -20
- package/extensions/goal/tools.ts +5 -17
- package/extensions/goal/tui.ts +12 -40
- package/extensions/job/README.md +21 -12
- package/extensions/job/format.ts +2 -2
- package/extensions/job/index.ts +25 -20
- package/extensions/job/tools.ts +5 -3
- package/extensions/job/tui.ts +13 -8
- package/extensions/memory/README.md +6 -0
- package/extensions/memory/format.ts +5 -3
- package/extensions/plan/README.md +32 -12
- package/extensions/plan/tools.ts +20 -5
- package/extensions/plan/web-client.ts +58 -17
- package/extensions/plan/web-page.ts +34 -9
- package/extensions/plan/web-router.ts +40 -9
- package/extensions/plan/web.ts +22 -2
- package/extensions/question/README.md +13 -1
- package/extensions/question/answers.ts +25 -18
- package/extensions/question/tools.ts +11 -6
- package/extensions/rewind/README.md +2 -0
- package/extensions/rewind/format.ts +6 -1
- package/extensions/rewind/index.ts +3 -1
- package/extensions/rewind/snapshot-run.ts +1 -1
- package/extensions/subagent/README.md +6 -0
- package/extensions/subagent/index.ts +1 -1
- package/extensions/subagent/render.ts +56 -29
- package/extensions/todo/README.md +19 -12
- package/extensions/todo/format.ts +17 -25
- package/extensions/todo/tools.ts +2 -1
- package/extensions/todo/tui.ts +3 -2
- package/extensions/watch/README.md +14 -6
- package/extensions/watch/format.ts +2 -2
- package/extensions/watch/index.ts +28 -23
- package/extensions/watch/tools.ts +5 -3
- package/extensions/watch/tui.ts +12 -7
- package/extensions/web-access/README.md +16 -7
- package/extensions/web-access/fetch/tool.ts +1 -1
- package/extensions/web-access/search/tool.ts +3 -3
- package/extensions/wechat/README.md +6 -0
- package/extensions/worktree/README.md +6 -0
- package/lib/README.md +2 -1
- package/lib/format.ts +17 -0
- package/lib/notice.ts +87 -0
- package/lib/tui/chrome.ts +8 -4
- package/lib/tui/cursor.ts +2 -1
- package/lib/tui/glyphs.ts +10 -11
- package/lib/tui/indent.ts +166 -0
- package/lib/tui/index.ts +3 -1
- package/lib/tui/rows.ts +39 -5
- package/lib/tui/rules.ts +9 -3
- package/lib/tui/tokens.ts +14 -0
- package/lib/web/base.ts +2 -1
- package/lib/web/components.ts +3 -1
- package/lib/web/layout.ts +5 -1
- package/package.json +7 -3
- package/themes/bytefly-dark.json +7 -8
- package/themes/bytefly-light.json +10 -11
- package/assets/bytefly-chrome.png +0 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tool-block frame: a tool's own call line as the headline, then its body
|
|
3
|
+
* indented under it.
|
|
4
|
+
*
|
|
5
|
+
* The resolver sets `renderShell: "self"`, so Pi draws no background box and no
|
|
6
|
+
* padding; this frame supplies the indent and the width math. The inner
|
|
7
|
+
* component is the tool's existing renderer (or a generic fallback), so syntax
|
|
8
|
+
* highlighting, diffs, previews, and truncation notices are preserved — only the
|
|
9
|
+
* frame changes.
|
|
10
|
+
*
|
|
11
|
+
* The call block leads with its first non-blank line — the tool's own call line,
|
|
12
|
+
* which every built-in and package renderer already opens with the tool name —
|
|
13
|
+
* and the body follows at `BODY_INDENT` columns, with no separator line, so the
|
|
14
|
+
* block is compact. A body may carry a `rail`: a pre-painted glyph in the first
|
|
15
|
+
* indent column, occupying the two columns the plain indent uses. The headline is
|
|
16
|
+
* never railed. The result frame carries it for every tool; the call frame carries
|
|
17
|
+
* it too when the call body *is* the result (`edit`'s diff, `write`'s content),
|
|
18
|
+
* except for the lines of a call line that wrapped — those are the call, so the
|
|
19
|
+
* rail starts at the result.
|
|
20
|
+
*
|
|
21
|
+
* The tool name is the block's only accent. The shipped themes point
|
|
22
|
+
* `toolTitle` at the accent, so a primary subject — a path, URL, query, pattern
|
|
23
|
+
* — would otherwise spend the same colour; `recolorCallHeadline` demotes it to
|
|
24
|
+
* the body role on the call's first line, and a subject that wrapped onto the
|
|
25
|
+
* body (`edit <path>`) is demoted there too.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import { type Component, stripTerminalSequences, truncateToWidth } from "@earendil-works/pi-tui";
|
|
30
|
+
import { sanitizeLine } from "../../../lib/format.ts";
|
|
31
|
+
import { BODY_INDENT, bodyLines, indentBlockLines } from "../../../lib/tui/indent.ts";
|
|
32
|
+
import { paintPrefix } from "../../../lib/tui/tokens.ts";
|
|
33
|
+
|
|
34
|
+
/** How one tool-block frame is drawn. */
|
|
35
|
+
export interface ToolBlockOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Lead with the component's first non-blank line as the block's headline. Set
|
|
38
|
+
* on the call, never on the result.
|
|
39
|
+
*/
|
|
40
|
+
headline?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Drop the component's own leading blank lines, so a self-shell `Box` (`edit`)
|
|
43
|
+
* or a result's spacer never opens the block with a gap.
|
|
44
|
+
*/
|
|
45
|
+
trimLeading?: boolean;
|
|
46
|
+
/** The tool name, so a shell prompt in the headline becomes the tool name. */
|
|
47
|
+
toolName?: string;
|
|
48
|
+
/**
|
|
49
|
+
* A pre-painted rail glyph for the body's first indent column, from
|
|
50
|
+
* `../chrome.ts`. Set on every result frame, and on the call frame only for a
|
|
51
|
+
* tool whose call body carries the result (`edit`, `write`); the headline is
|
|
52
|
+
* never railed, and neither is a call line that wrapped onto the body.
|
|
53
|
+
*/
|
|
54
|
+
rail?: string;
|
|
55
|
+
/**
|
|
56
|
+
* The active theme. A shell call's command and a call's primary subject are
|
|
57
|
+
* repainted in the `label` role, so only the tool name spends the accent;
|
|
58
|
+
* without it the line is left as Pi drew it.
|
|
59
|
+
*/
|
|
60
|
+
theme?: Theme;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Pi draws a shell call as `$ <command>` (or `> <command>` in PowerShell) rather
|
|
65
|
+
* than `bash <command>`. The tool name is the block's headline, so rewrite that
|
|
66
|
+
* leading prompt in place. The name keeps Pi's `toolTitle` styling (the accent
|
|
67
|
+
* in the shipped themes); the command is reopened in `label` and the bold run
|
|
68
|
+
* ended, so the name is the block's only accent.
|
|
69
|
+
*/
|
|
70
|
+
const SHELL_PROMPT = /^\s*[$>] /;
|
|
71
|
+
/** The prompt and the space after it, so a themed rewrite can replace both. */
|
|
72
|
+
const SHELL_PROMPT_SPAN = /[$>] /;
|
|
73
|
+
|
|
74
|
+
/** Replace a shell call's leading prompt with the tool name. */
|
|
75
|
+
export function headlineWithToolName(line: string, toolName: string, theme?: Theme): string {
|
|
76
|
+
if (!SHELL_PROMPT.test(stripTerminalSequences(line))) return line;
|
|
77
|
+
const name = sanitizeLine(toolName);
|
|
78
|
+
if (!theme) return line.replace(/[$>]/, name);
|
|
79
|
+
// `\u001b[22m` ends the name's bold run; the role's opening colours what
|
|
80
|
+
// follows, which Pi's own trailing reset then closes.
|
|
81
|
+
return line.replace(SHELL_PROMPT_SPAN, `${name} \u001b[22m${paintPrefix(theme, "label")}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Recolour a shell call's prompt on its whole (unwrapped) string.
|
|
86
|
+
*
|
|
87
|
+
* Pi renders `$ <command>` as one `toolTitle` run, which `Text` then wraps; a
|
|
88
|
+
* rewrite applied to the first *rendered* line would leave every wrapped
|
|
89
|
+
* fragment of the command in the accent. Applying it before the wrap keeps the
|
|
90
|
+
* name's accent and the command's `label` on every fragment, because the wrap
|
|
91
|
+
* re-emits whichever run it split. Returns `text` unchanged when its first line
|
|
92
|
+
* is not a shell prompt.
|
|
93
|
+
*/
|
|
94
|
+
export function recolorShellCall(text: string, toolName: string, theme: Theme): string {
|
|
95
|
+
const end = text.indexOf("\n");
|
|
96
|
+
const head = end === -1 ? text : text.slice(0, end);
|
|
97
|
+
if (!SHELL_PROMPT.test(stripTerminalSequences(head))) return text;
|
|
98
|
+
const rewritten = headlineWithToolName(head, toolName, theme);
|
|
99
|
+
return end === -1 ? rewritten : rewritten + text.slice(end);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Repaint every accent opening at or after `from` in the body role. A theme that
|
|
104
|
+
* paints no colour, or whose two roles coincide, is left alone.
|
|
105
|
+
*/
|
|
106
|
+
function demoteFrom(text: string, from: number, theme: Theme): string {
|
|
107
|
+
const accent = paintPrefix(theme, "accent");
|
|
108
|
+
const label = paintPrefix(theme, "label");
|
|
109
|
+
if (!accent || accent === label) return text;
|
|
110
|
+
|
|
111
|
+
let out = "";
|
|
112
|
+
let i = 0;
|
|
113
|
+
for (;;) {
|
|
114
|
+
const hit = text.indexOf(accent, i);
|
|
115
|
+
if (hit === -1) {
|
|
116
|
+
out += text.slice(i);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
out += text.slice(i, hit);
|
|
120
|
+
out += hit >= from ? label : accent;
|
|
121
|
+
i = hit + accent.length;
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Demote every accent opening on a line to the body colour. Applied to a call's
|
|
128
|
+
* continuation lines — the wrapped tail of a subject, which is still the call.
|
|
129
|
+
*/
|
|
130
|
+
function demoteAccent(text: string, theme: Theme): string {
|
|
131
|
+
return demoteFrom(text, 0, theme);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Demote a call headline's primary subject from the accent to the body colour,
|
|
136
|
+
* so the tool name is the block's only accent.
|
|
137
|
+
*
|
|
138
|
+
* The name and the subject share the accent sequence in the shipped themes, so
|
|
139
|
+
* the name is told apart by position: the first accent opening at or after the
|
|
140
|
+
* tool name stays, and every later one on the first line is the subject. Only
|
|
141
|
+
* line one is touched — a multi-line call's continuation lines keep their own
|
|
142
|
+
* roles, except the wrapped tail of a bare headline (see `toolBlockLines`).
|
|
143
|
+
*/
|
|
144
|
+
export function recolorCallHeadline(text: string, toolName: string, theme: Theme): string {
|
|
145
|
+
const accent = paintPrefix(theme, "accent");
|
|
146
|
+
if (!accent || accent === paintPrefix(theme, "label")) return text;
|
|
147
|
+
const end = text.indexOf("\n");
|
|
148
|
+
const head = end === -1 ? text : text.slice(0, end);
|
|
149
|
+
if (!head.includes(accent)) return text;
|
|
150
|
+
|
|
151
|
+
// Everything after the tool name is its subject; without a name to anchor on
|
|
152
|
+
// (a renderer that does not open with it) demote the whole line.
|
|
153
|
+
const name = sanitizeLine(toolName);
|
|
154
|
+
const at = head.indexOf(name);
|
|
155
|
+
const from = at === -1 ? 0 : at + name.length;
|
|
156
|
+
|
|
157
|
+
const out = demoteFrom(head, from, theme);
|
|
158
|
+
return end === -1 ? out : out + text.slice(end);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A renderer component that carries a mutable string: Pi's own `Text`. */
|
|
162
|
+
interface Rewritable {
|
|
163
|
+
text: string;
|
|
164
|
+
setText(text: string): void;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Whether a component exposes the `Text` shape. Pi's shell renderers return a
|
|
169
|
+
* `Text`, whose `text` field is private to the type but present at runtime.
|
|
170
|
+
*/
|
|
171
|
+
export function isRewritable(component: Component): component is Component & Rewritable {
|
|
172
|
+
const candidate = component as Partial<Rewritable>;
|
|
173
|
+
return typeof candidate.text === "string" && typeof candidate.setText === "function";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Whether a line has no visible text. Tested after stripping terminal sequences,
|
|
178
|
+
* so a line carrying only a background or the indent counts as blank.
|
|
179
|
+
*/
|
|
180
|
+
function isBlankLine(line: string): boolean {
|
|
181
|
+
return stripTerminalSequences(line).trim().length === 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Whether a call line degenerated to the tool name alone, so the rest of it —
|
|
186
|
+
* the path, the command — moved onto the body lines under it.
|
|
187
|
+
*
|
|
188
|
+
* Pi wraps the call line before the frame sees it, and a `edit <path>` pair is
|
|
189
|
+
* one long unbreakable token: once the path no longer fits, the wrapper drops the
|
|
190
|
+
* name onto its own line and hard-breaks the path below it. The bare name is the
|
|
191
|
+
* only reliable marker of that, and it matters because those lines are the call,
|
|
192
|
+
* not the result — a railed call body (`edit`, `write`) would otherwise draw the
|
|
193
|
+
* path as diff content.
|
|
194
|
+
*/
|
|
195
|
+
function isBareHeadline(body: readonly string[], toolName: string | undefined): boolean {
|
|
196
|
+
if (!toolName) return false;
|
|
197
|
+
const headline = body.find((line) => !isBlankLine(line));
|
|
198
|
+
return headline !== undefined && stripTerminalSequences(headline).trim() === sanitizeLine(toolName);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* How many body lines after a bare headline are still the call: the leading run
|
|
203
|
+
* of non-blank lines, which ends where the result begins. Zero for a call whose
|
|
204
|
+
* headline carries its own subject.
|
|
205
|
+
*/
|
|
206
|
+
function callContinuationCount(body: readonly string[], toolName: string | undefined): number {
|
|
207
|
+
if (!isBareHeadline(body, toolName)) return 0;
|
|
208
|
+
let index = body.findIndex((line) => !isBlankLine(line)) + 1;
|
|
209
|
+
let count = 0;
|
|
210
|
+
while (index < body.length && !isBlankLine(body[index] ?? "")) {
|
|
211
|
+
count++;
|
|
212
|
+
index++;
|
|
213
|
+
}
|
|
214
|
+
return count;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Indent `inner` under the block's headline: the body at `outputPad + BODY_INDENT`,
|
|
219
|
+
* the headline (when asked for) at `outputPad`. `width` clips the headline again
|
|
220
|
+
* after its prompt is rewritten, so a longer tool name cannot overflow.
|
|
221
|
+
*/
|
|
222
|
+
export function toolBlockLines(
|
|
223
|
+
inner: Component | undefined,
|
|
224
|
+
width: number,
|
|
225
|
+
outputPad: number,
|
|
226
|
+
options: ToolBlockOptions = {},
|
|
227
|
+
): string[] {
|
|
228
|
+
const w = Math.max(1, width);
|
|
229
|
+
const indent = (outputPad ?? 0) + BODY_INDENT;
|
|
230
|
+
const body = bodyLines(inner, w, indent);
|
|
231
|
+
const lines = indentBlockLines(body, w, {
|
|
232
|
+
indent,
|
|
233
|
+
rail: options.rail,
|
|
234
|
+
headline: options.headline,
|
|
235
|
+
trimLeading: options.trimLeading,
|
|
236
|
+
// A railed call body starts at the result, never at the tail of a wrapped
|
|
237
|
+
// call line.
|
|
238
|
+
railAfterCall: options.rail !== undefined && isBareHeadline(body, options.toolName),
|
|
239
|
+
});
|
|
240
|
+
if (options.headline && lines.length > 0) {
|
|
241
|
+
// The base renders at the reduced (indented) width, so drop the padding it
|
|
242
|
+
// added before the prompt rewrite can push the headline over the clip.
|
|
243
|
+
const headline = (lines[0] ?? "").trimEnd();
|
|
244
|
+
const named = options.toolName ? headlineWithToolName(headline, options.toolName, options.theme) : headline;
|
|
245
|
+
// A renderer that rebuilds its component after `renderCall` (`edit` moves its
|
|
246
|
+
// diff onto the box) drops the rewrite the resolver applied there, so the
|
|
247
|
+
// headline is demoted again here. It is a no-op for a call the resolver
|
|
248
|
+
// already recoloured before the wrap.
|
|
249
|
+
const recolored =
|
|
250
|
+
options.toolName && options.theme ? recolorCallHeadline(named, options.toolName, options.theme) : named;
|
|
251
|
+
lines[0] = truncateToWidth(recolored, w, "…");
|
|
252
|
+
// A call whose subject is one unbreakable token (`edit <path>`) wraps by
|
|
253
|
+
// dropping the name onto the headline and breaking the subject below it.
|
|
254
|
+
// Those continuation lines are still the call, so they lose the accent too
|
|
255
|
+
// — otherwise the path reads as a heading and the name stops being the
|
|
256
|
+
// block's only accent.
|
|
257
|
+
if (options.theme) {
|
|
258
|
+
const continuation = callContinuationCount(body, options.toolName);
|
|
259
|
+
for (let index = 1; index <= continuation && index < lines.length; index++) {
|
|
260
|
+
lines[index] = demoteAccent(lines[index] ?? "", options.theme);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return lines;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The frame Pi mounts in place of its background box for a tool row. */
|
|
268
|
+
export class ToolBlock implements Component {
|
|
269
|
+
constructor(
|
|
270
|
+
private readonly inner: Component | undefined,
|
|
271
|
+
private readonly outputPad: number,
|
|
272
|
+
private readonly options: ToolBlockOptions = {},
|
|
273
|
+
) {}
|
|
274
|
+
|
|
275
|
+
invalidate(): void {
|
|
276
|
+
this.inner?.invalidate();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
render(width: number): string[] {
|
|
280
|
+
return toolBlockLines(this.inner, Math.max(1, width), this.outputPad, this.options);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tuning for the tool-block result rail.
|
|
3
|
+
*
|
|
4
|
+
* A tool result hangs from the transcript rail (`../chrome.ts`), painted by
|
|
5
|
+
* status. A finished or merely informational result keeps the neutral `faint`
|
|
6
|
+
* rail, so only a running or failed one spends colour. The two file-mutating
|
|
7
|
+
* tools (`edit`, `write`) carry their result on the call body, so that body is
|
|
8
|
+
* railed the same way.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { TuiRole } from "../../../lib/tui/tokens.ts";
|
|
12
|
+
import { RAIL_ROLES, type RailStatus } from "../chrome.ts";
|
|
13
|
+
|
|
14
|
+
/** How a tool result reads at a glance; `warn` is the notice's rail. */
|
|
15
|
+
export type BlockStatus = Exclude<RailStatus, "warn">;
|
|
16
|
+
|
|
17
|
+
export const BLOCK_CONFIG = {
|
|
18
|
+
/**
|
|
19
|
+
* A running result is active, so its rail takes the accent — the one colour a
|
|
20
|
+
* tool result spends. A finished result stays neutral and an error keeps the
|
|
21
|
+
* shared `bad` role, so the rail family still cannot drift.
|
|
22
|
+
*/
|
|
23
|
+
roles: { info: "accent", ok: RAIL_ROLES.ok, bad: RAIL_ROLES.bad } as Record<BlockStatus, TuiRole>,
|
|
24
|
+
} as const;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic call and result fallbacks for a tool with no renderer of its own.
|
|
3
|
+
*
|
|
4
|
+
* Pi's own fallback is only used when nothing resolves a renderer; because the
|
|
5
|
+
* resolver returns one for every tool, a tool like an MCP call with no renderer
|
|
6
|
+
* would otherwise be framed around nothing. These mirror how the built-ins read
|
|
7
|
+
* — a bare subject, then any options — so the block reads like the rest of the
|
|
8
|
+
* transcript instead of a raw argument dump, and every value is sanitized at the
|
|
9
|
+
* boundary.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { sanitizeLine, stripControlChars } from "../../../lib/format.ts";
|
|
14
|
+
import { paint } from "../../../lib/tui/tokens.ts";
|
|
15
|
+
|
|
16
|
+
/** Collapsed argument preview length, matching Pi's generic call header. */
|
|
17
|
+
const COLLAPSED_ARGS_CHARS = 100;
|
|
18
|
+
|
|
19
|
+
/** Result lines shown before the rest collapse into a note. */
|
|
20
|
+
const FALLBACK_PREVIEW_LINES = 10;
|
|
21
|
+
|
|
22
|
+
/** Split top-level arguments into key/value pairs, in insertion order. */
|
|
23
|
+
function argumentEntries(args: unknown): Array<[string, unknown]> {
|
|
24
|
+
if (typeof args === "object" && args !== null && !Array.isArray(args)) {
|
|
25
|
+
return Object.entries(args as Record<string, unknown>);
|
|
26
|
+
}
|
|
27
|
+
return [["args", args]];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The argument shown bare after the tool name, the way a built-in names its
|
|
32
|
+
* subject (`read src/cli.ts:1-8`). Only a non-empty string qualifies, so an
|
|
33
|
+
* option never loses its key; one with spaces is quoted so it stays one value.
|
|
34
|
+
*/
|
|
35
|
+
function subjectValue(value: unknown): string | undefined {
|
|
36
|
+
if (typeof value !== "string") return undefined;
|
|
37
|
+
const text = sanitizeLine(value);
|
|
38
|
+
if (!text) return undefined;
|
|
39
|
+
return /\s/.test(text) ? JSON.stringify(text) : text;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One option fragment: `key value`, a bare `key` for a true flag, or undefined
|
|
44
|
+
* when the value carries nothing (a false flag, null, or an empty string).
|
|
45
|
+
*/
|
|
46
|
+
function optionFragment(key: string, value: unknown): string | undefined {
|
|
47
|
+
if (value === undefined || value === null || value === false || value === "") return undefined;
|
|
48
|
+
const name = sanitizeLine(key);
|
|
49
|
+
if (value === true) return name;
|
|
50
|
+
if (typeof value === "string") return `${name} ${subjectValue(value) ?? ""}`.trimEnd();
|
|
51
|
+
if (typeof value === "number" || typeof value === "bigint") return `${name} ${value}`;
|
|
52
|
+
return `${name} ${sanitizeLine(JSON.stringify(value) ?? String(value))}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A call line for a tool with no renderer: the bold title, then its leading
|
|
57
|
+
* string argument (when there is one) and the remaining options in parentheses,
|
|
58
|
+
* matching the built-ins' `read <path>` / `ls <path> (limit 500)` grammar. The
|
|
59
|
+
* subject reads in the body colour — the tool name is the call's only accent —
|
|
60
|
+
* and the options stay faint.
|
|
61
|
+
*/
|
|
62
|
+
export function callFallback(toolName: string, args: unknown, theme: Theme, expanded: boolean): string {
|
|
63
|
+
const header = theme.fg("toolTitle", theme.bold(toolName));
|
|
64
|
+
if (args == null) return header;
|
|
65
|
+
const entries = argumentEntries(args);
|
|
66
|
+
if (entries.length === 0) return header;
|
|
67
|
+
if (expanded) {
|
|
68
|
+
const lines = entries.map(([key, value]) => {
|
|
69
|
+
const text = typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? String(value));
|
|
70
|
+
return ` ${sanitizeLine(key)}: ${stripControlChars(text).split("\n").join("\n ")}`;
|
|
71
|
+
});
|
|
72
|
+
return `${header}\n${paint(theme, "meta", lines.join("\n"))}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let subject: string | undefined;
|
|
76
|
+
const options: string[] = [];
|
|
77
|
+
for (const [key, value] of entries) {
|
|
78
|
+
if (subject === undefined) {
|
|
79
|
+
const candidate = subjectValue(value);
|
|
80
|
+
if (candidate !== undefined) {
|
|
81
|
+
subject = candidate;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const option = optionFragment(key, value);
|
|
86
|
+
if (option !== undefined) options.push(option);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// With a subject the options read as a trailing aside; without one (a flag or
|
|
90
|
+
// no arguments at all) they stand on their own.
|
|
91
|
+
const body =
|
|
92
|
+
subject !== undefined ? `${subject}${options.length > 0 ? ` (${options.join(", ")})` : ""}` : options.join(" ");
|
|
93
|
+
if (!body) return header;
|
|
94
|
+
const preview = body.length > COLLAPSED_ARGS_CHARS ? `${body.slice(0, COLLAPSED_ARGS_CHARS - 3)}...` : body;
|
|
95
|
+
if (subject === undefined) return `${header} ${paint(theme, "faint", preview)}`;
|
|
96
|
+
// Colour the subject and the options apart, after the preview clip, so a
|
|
97
|
+
// clipped subject never strands an unterminated escape.
|
|
98
|
+
const subjectPart = preview.slice(0, Math.min(subject.length, preview.length));
|
|
99
|
+
const optionsPart = preview.slice(subjectPart.length);
|
|
100
|
+
return `${header} ${paint(theme, "label", subjectPart)}${optionsPart ? paint(theme, "faint", optionsPart) : ""}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A result preview for a tool with no renderer: its text, capped when collapsed. */
|
|
104
|
+
export function resultFallback(
|
|
105
|
+
content: readonly { type: string; text?: string }[],
|
|
106
|
+
theme: Theme,
|
|
107
|
+
expanded: boolean,
|
|
108
|
+
): string {
|
|
109
|
+
const text = content
|
|
110
|
+
.filter((block) => block.type === "text")
|
|
111
|
+
.map((block) => stripControlChars(block.text ?? ""))
|
|
112
|
+
.join("\n");
|
|
113
|
+
if (!text) return "";
|
|
114
|
+
const lines = text.split("\n");
|
|
115
|
+
const shown = expanded ? lines : lines.slice(0, FALLBACK_PREVIEW_LINES);
|
|
116
|
+
let output = theme.fg("toolOutput", shown.join("\n"));
|
|
117
|
+
const remaining = lines.length - shown.length;
|
|
118
|
+
if (remaining > 0) output += `\n${paint(theme, "meta", `... (${remaining} more lines)`)}`;
|
|
119
|
+
return output;
|
|
120
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers bytefly's tool-block resolver.
|
|
3
|
+
*
|
|
4
|
+
* The resolver runs for every tool, built-ins included. It switches the row to
|
|
5
|
+
* `renderShell: "self"` — dropping Pi's filled status background — and frames
|
|
6
|
+
* the tool's own `renderCall` / `renderResult`, so the content is unchanged and
|
|
7
|
+
* only the framing is bytefly's. The call leads with its own first line as the
|
|
8
|
+
* headline; the body is indented directly under it. The body carries the
|
|
9
|
+
* transcript rail, painted by status. The result frame rails every tool; the
|
|
10
|
+
* call frame rails only the two file-mutating tools (`edit`, `write`), whose
|
|
11
|
+
* call body *is* the result (a diff, a written file). A tool with no renderer
|
|
12
|
+
* gets the same treatment around a generic fallback.
|
|
13
|
+
*
|
|
14
|
+
* The frame is added even for a tool that sets `renderShell: "self"` (`edit`):
|
|
15
|
+
* that only tells Pi not to draw its filled box, so its diff would otherwise sit
|
|
16
|
+
* at the wrong indent. The indent is bytefly's, not Pi's box, so nothing nests.
|
|
17
|
+
*
|
|
18
|
+
* Pi hands a renderer the component it returned last time as
|
|
19
|
+
* `context.lastComponent`, and every built-in renderer reuses it in place — it
|
|
20
|
+
* assumes that component is its own `Text` and calls `setText` on it. We return
|
|
21
|
+
* a `ToolBlock`, so passing it back would break the base renderer (it throws
|
|
22
|
+
* `text.setText is not a function`, and the row falls back to Pi's generic call
|
|
23
|
+
* line). The resolver therefore keeps each base renderer's own component in the
|
|
24
|
+
* row's shared `state` and hands *that* back, so the base updates in place while
|
|
25
|
+
* we wrap the result in a fresh frame.
|
|
26
|
+
*
|
|
27
|
+
* Because the frame takes over the row's `outputPad`, the base renderer is
|
|
28
|
+
* handed `outputPad: 0`. Pi's `edit` is a natively self-shell renderer and pads
|
|
29
|
+
* its own `Box` by `context.outputPad`; leaving the host value in place would
|
|
30
|
+
* add the pad twice and set the whole edit block a column deeper than every
|
|
31
|
+
* other block. The frame is the only place that applies the pad.
|
|
32
|
+
*
|
|
33
|
+
* The two tools that mutate a file (`edit`, `write`) carry their result on the
|
|
34
|
+
* call, so the call body is railed like a result. Pi's `edit` paints its own
|
|
35
|
+
* `Box` from a tool background role, so the frame hands it a theme whose `bg` is
|
|
36
|
+
* inert; otherwise the base would paint a band the rest of the transcript does
|
|
37
|
+
* not have. (`write` is a plain `Text` and paints nothing; the facade is a no-op
|
|
38
|
+
* there but shared.)
|
|
39
|
+
*
|
|
40
|
+
* Before framing, a shell call's `$ <command>` string is rewritten so the tool
|
|
41
|
+
* name keeps the accent and the command reads as the body (`recolorShellCall`),
|
|
42
|
+
* and a call's primary subject is demoted to the body colour
|
|
43
|
+
* (`recolorCallHeadline`), so the tool name is the block's only accent. Doing
|
|
44
|
+
* both on the string rather than the wrapped lines keeps a long command or
|
|
45
|
+
* subject in one role when it wraps; the frame repeats the subject rewrite for a
|
|
46
|
+
* component that is rebuilt after this point (`edit`).
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import type { ExtensionAPI, Theme, ThemeBg } from "@earendil-works/pi-coding-agent";
|
|
50
|
+
import { type Component, Text } from "@earendil-works/pi-tui";
|
|
51
|
+
import { createRailSource, railGlyph, type RailSource } from "../chrome.ts";
|
|
52
|
+
import { createPadStore, type PadSource } from "../pad.ts";
|
|
53
|
+
import { isRewritable, recolorCallHeadline, recolorShellCall, ToolBlock } from "./component.ts";
|
|
54
|
+
import { BLOCK_CONFIG, type BlockStatus } from "./config.ts";
|
|
55
|
+
import { callFallback, resultFallback } from "./fallback.ts";
|
|
56
|
+
|
|
57
|
+
/** Per-row memory of the base renderer's own components, kept off Pi's state. */
|
|
58
|
+
interface BlockMemory {
|
|
59
|
+
byteflyBaseCall?: Component;
|
|
60
|
+
byteflyBaseResult?: Component;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The tools whose call body carries the result: the two that mutate a file. */
|
|
64
|
+
const RESULT_ON_CALL = new Set(["edit", "write"]);
|
|
65
|
+
|
|
66
|
+
/** The painted rail glyph for a row's state: running spends the accent, a failure red. */
|
|
67
|
+
function statusRail(theme: Theme, context: { isPartial: boolean; isError: boolean }, glyph: string): string {
|
|
68
|
+
const status: BlockStatus = context.isPartial ? "info" : context.isError ? "bad" : "ok";
|
|
69
|
+
return railGlyph(theme, BLOCK_CONFIG.roles[status], glyph);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A theme whose `bg` is inert. Pi's `edit` paints its own `Box` from a tool
|
|
74
|
+
* background role; the transcript has no bands, so the base must not paint one.
|
|
75
|
+
* Every other method is inherited unchanged.
|
|
76
|
+
*/
|
|
77
|
+
function withoutBackground(theme: Theme): Theme {
|
|
78
|
+
const suppressed = Object.create(theme) as Theme;
|
|
79
|
+
suppressed.bg = (_color: ThemeBg, text: string) => text;
|
|
80
|
+
return suppressed;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Call the base renderer, falling back to the frame's own line when it throws.
|
|
85
|
+
*
|
|
86
|
+
* A base can throw on a partial (still-streaming) argument. Catching it here
|
|
87
|
+
* keeps the frame in charge of the pad; letting it reach Pi would swap in Pi's
|
|
88
|
+
* generic call line, a column to the left, and the row would jump.
|
|
89
|
+
*/
|
|
90
|
+
function frameBase(render: () => Component | undefined, fallback: () => Component): Component {
|
|
91
|
+
try {
|
|
92
|
+
return render() ?? fallback();
|
|
93
|
+
} catch {
|
|
94
|
+
return fallback();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Register the resolver that frames every tool block with the bytefly indent. */
|
|
99
|
+
export function registerToolBlocks(
|
|
100
|
+
pi: ExtensionAPI,
|
|
101
|
+
pad: PadSource = createPadStore(),
|
|
102
|
+
rail: RailSource = createRailSource(),
|
|
103
|
+
): void {
|
|
104
|
+
pi.registerToolRenderer((toolName, next) => {
|
|
105
|
+
const base = next();
|
|
106
|
+
return {
|
|
107
|
+
renderShell: "self",
|
|
108
|
+
renderCall: (args, theme, context) => {
|
|
109
|
+
const memory = context.state as BlockMemory;
|
|
110
|
+
const resultOnCall = RESULT_ON_CALL.has(toolName);
|
|
111
|
+
const inner = frameBase(
|
|
112
|
+
() =>
|
|
113
|
+
base?.renderCall?.(args, resultOnCall ? withoutBackground(theme) : theme, {
|
|
114
|
+
...context,
|
|
115
|
+
outputPad: 0,
|
|
116
|
+
lastComponent: memory.byteflyBaseCall,
|
|
117
|
+
}),
|
|
118
|
+
() => new Text(callFallback(toolName, args, theme, context.expanded), 0, 0),
|
|
119
|
+
);
|
|
120
|
+
memory.byteflyBaseCall = inner;
|
|
121
|
+
// Split a shell call's roles before the frame wraps it: Pi renders
|
|
122
|
+
// `$ <cmd>` as one `toolTitle` run, so a rewrite after wrapping would
|
|
123
|
+
// only reach the first line and leave the wrapped command in the
|
|
124
|
+
// accent. `recolorShellCall` is a no-op for a call that is not a shell
|
|
125
|
+
// prompt. Then demote the subject (a path, URL, query, pattern) to the
|
|
126
|
+
// body colour, so the tool name is the call's only accent. Both run
|
|
127
|
+
// before the wrap for the same reason; the frame repeats the subject
|
|
128
|
+
// rewrite for a component that is rebuilt after this point (`edit`).
|
|
129
|
+
if (isRewritable(inner)) {
|
|
130
|
+
inner.setText(recolorCallHeadline(recolorShellCall(inner.text, toolName, theme), toolName, theme));
|
|
131
|
+
}
|
|
132
|
+
// The call's own first line is the block's headline; the frame drops the
|
|
133
|
+
// blank edges a self-shell `Box` (`edit`) pads itself with. A tool whose
|
|
134
|
+
// call body is its result (`edit`, `write`) rails that body.
|
|
135
|
+
return new ToolBlock(inner, pad.resolve(context.outputPad), {
|
|
136
|
+
headline: true,
|
|
137
|
+
trimLeading: true,
|
|
138
|
+
toolName,
|
|
139
|
+
theme,
|
|
140
|
+
rail: resultOnCall ? statusRail(theme, context, rail.resolve()) : undefined,
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
renderResult: (result, options, theme, context) => {
|
|
144
|
+
const memory = context.state as BlockMemory;
|
|
145
|
+
const resultOnCall = RESULT_ON_CALL.has(toolName);
|
|
146
|
+
const inner = frameBase(
|
|
147
|
+
() =>
|
|
148
|
+
base?.renderResult?.(result, options, resultOnCall ? withoutBackground(theme) : theme, {
|
|
149
|
+
...context,
|
|
150
|
+
outputPad: 0,
|
|
151
|
+
lastComponent: memory.byteflyBaseResult,
|
|
152
|
+
}),
|
|
153
|
+
() => new Text(resultFallback(result.content, theme, options.expanded), 0, 0),
|
|
154
|
+
);
|
|
155
|
+
memory.byteflyBaseResult = inner;
|
|
156
|
+
// A tool's own leading spacer would open the body with a gap, so drop it.
|
|
157
|
+
return new ToolBlock(inner, pad.resolve(context.outputPad), {
|
|
158
|
+
trimLeading: true,
|
|
159
|
+
rail: statusRail(theme, context, rail.resolve()),
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
}
|