@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.
Files changed (132) hide show
  1. package/README.md +28 -2
  2. package/README.zh-CN.md +27 -2
  3. package/assets/bytefly.schema.json +17 -1
  4. package/assets/chrome.png +0 -0
  5. package/assets/job.png +0 -0
  6. package/assets/plan-review.png +0 -0
  7. package/assets/question.png +0 -0
  8. package/assets/rewind.png +0 -0
  9. package/assets/todo.png +0 -0
  10. package/assets/tool-renderings.png +0 -0
  11. package/assets/tools/ask_user_question.png +0 -0
  12. package/assets/tools/bash.png +0 -0
  13. package/assets/tools/edit.png +0 -0
  14. package/assets/tools/enter_plan_mode.png +0 -0
  15. package/assets/tools/enter_worktree.png +0 -0
  16. package/assets/tools/exit_plan_mode.png +0 -0
  17. package/assets/tools/exit_worktree.png +0 -0
  18. package/assets/tools/find.png +0 -0
  19. package/assets/tools/goal.png +0 -0
  20. package/assets/tools/grep.png +0 -0
  21. package/assets/tools/job.png +0 -0
  22. package/assets/tools/list_worktrees.png +0 -0
  23. package/assets/tools/ls.png +0 -0
  24. package/assets/tools/memory.png +0 -0
  25. package/assets/tools/notice.png +0 -0
  26. package/assets/tools/open_path.png +0 -0
  27. package/assets/tools/prune_worktrees.png +0 -0
  28. package/assets/tools/read.png +0 -0
  29. package/assets/tools/read_doc.png +0 -0
  30. package/assets/tools/run_doctor.png +0 -0
  31. package/assets/tools/send_wechat.png +0 -0
  32. package/assets/tools/set_plan_steps.png +0 -0
  33. package/assets/tools/subagent.png +0 -0
  34. package/assets/tools/todo.png +0 -0
  35. package/assets/tools/turn-rule.png +0 -0
  36. package/assets/tools/user-message.png +0 -0
  37. package/assets/tools/watch.png +0 -0
  38. package/assets/tools/web_fetch.png +0 -0
  39. package/assets/tools/web_search.png +0 -0
  40. package/assets/tools/write.png +0 -0
  41. package/assets/tools/write_plan.png +0 -0
  42. package/extensions/bytefly/README.md +261 -36
  43. package/extensions/bytefly/block/component.ts +282 -0
  44. package/extensions/bytefly/block/config.ts +24 -0
  45. package/extensions/bytefly/block/fallback.ts +120 -0
  46. package/extensions/bytefly/block/register.ts +164 -0
  47. package/extensions/bytefly/chrome.ts +97 -0
  48. package/extensions/bytefly/doctor/tools.ts +5 -2
  49. package/extensions/bytefly/header/component.ts +21 -10
  50. package/extensions/bytefly/header/config.ts +0 -3
  51. package/extensions/bytefly/header/register.ts +6 -1
  52. package/extensions/bytefly/index.ts +32 -12
  53. package/extensions/bytefly/message/config.ts +15 -0
  54. package/extensions/bytefly/message/format.ts +42 -0
  55. package/extensions/bytefly/message/register.ts +15 -0
  56. package/extensions/bytefly/notice/component.ts +107 -0
  57. package/extensions/bytefly/notice/config.ts +41 -0
  58. package/extensions/bytefly/notice/register.ts +27 -0
  59. package/extensions/bytefly/pad.ts +42 -0
  60. package/extensions/bytefly/separator/component.ts +2 -1
  61. package/extensions/bytefly/separator/format.ts +17 -10
  62. package/extensions/bytefly/separator/register.ts +3 -2
  63. package/extensions/bytefly/working/config.ts +20 -0
  64. package/extensions/bytefly/working/format.ts +25 -0
  65. package/extensions/bytefly/working/register.ts +88 -0
  66. package/extensions/document/README.md +11 -4
  67. package/extensions/document/paging.ts +10 -10
  68. package/extensions/document/render.ts +7 -7
  69. package/extensions/file-browse/README.md +3 -1
  70. package/extensions/file-browse/client.ts +9 -2
  71. package/extensions/file-browse/git.ts +4 -7
  72. package/extensions/file-browse/html.ts +27 -17
  73. package/extensions/file-open/README.md +6 -0
  74. package/extensions/goal/README.md +16 -10
  75. package/extensions/goal/format.ts +7 -20
  76. package/extensions/goal/tools.ts +5 -17
  77. package/extensions/goal/tui.ts +12 -40
  78. package/extensions/job/README.md +21 -12
  79. package/extensions/job/format.ts +2 -2
  80. package/extensions/job/index.ts +25 -20
  81. package/extensions/job/tools.ts +5 -3
  82. package/extensions/job/tui.ts +13 -8
  83. package/extensions/memory/README.md +6 -0
  84. package/extensions/memory/format.ts +5 -3
  85. package/extensions/plan/README.md +32 -12
  86. package/extensions/plan/tools.ts +20 -5
  87. package/extensions/plan/web-client.ts +58 -17
  88. package/extensions/plan/web-page.ts +34 -9
  89. package/extensions/plan/web-router.ts +40 -9
  90. package/extensions/plan/web.ts +22 -2
  91. package/extensions/question/README.md +13 -1
  92. package/extensions/question/answers.ts +25 -18
  93. package/extensions/question/tools.ts +11 -6
  94. package/extensions/rewind/README.md +2 -0
  95. package/extensions/rewind/format.ts +6 -1
  96. package/extensions/rewind/index.ts +3 -1
  97. package/extensions/rewind/snapshot-run.ts +1 -1
  98. package/extensions/subagent/README.md +6 -0
  99. package/extensions/subagent/index.ts +1 -1
  100. package/extensions/subagent/render.ts +56 -29
  101. package/extensions/todo/README.md +19 -12
  102. package/extensions/todo/format.ts +17 -25
  103. package/extensions/todo/tools.ts +2 -1
  104. package/extensions/todo/tui.ts +3 -2
  105. package/extensions/watch/README.md +14 -6
  106. package/extensions/watch/format.ts +2 -2
  107. package/extensions/watch/index.ts +28 -23
  108. package/extensions/watch/tools.ts +5 -3
  109. package/extensions/watch/tui.ts +12 -7
  110. package/extensions/web-access/README.md +16 -7
  111. package/extensions/web-access/fetch/tool.ts +1 -1
  112. package/extensions/web-access/search/tool.ts +3 -3
  113. package/extensions/wechat/README.md +6 -0
  114. package/extensions/worktree/README.md +6 -0
  115. package/lib/README.md +2 -1
  116. package/lib/format.ts +17 -0
  117. package/lib/notice.ts +87 -0
  118. package/lib/tui/chrome.ts +8 -4
  119. package/lib/tui/cursor.ts +2 -1
  120. package/lib/tui/glyphs.ts +10 -11
  121. package/lib/tui/indent.ts +166 -0
  122. package/lib/tui/index.ts +3 -1
  123. package/lib/tui/rows.ts +39 -5
  124. package/lib/tui/rules.ts +9 -3
  125. package/lib/tui/tokens.ts +14 -0
  126. package/lib/web/base.ts +2 -1
  127. package/lib/web/components.ts +3 -1
  128. package/lib/web/layout.ts +5 -1
  129. package/package.json +7 -3
  130. package/themes/bytefly-dark.json +7 -8
  131. package/themes/bytefly-light.json +10 -11
  132. 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
+ }