pi-chassis 0.0.0-stage → 0.7.1

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 (169) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/LICENSE +21 -0
  3. package/README.md +51 -2
  4. package/THIRD_PARTY_NOTICES +30 -0
  5. package/dist/chassis/alert.d.ts +50 -0
  6. package/dist/chassis/alert.js +107 -0
  7. package/dist/chassis/api.d.ts +411 -0
  8. package/dist/chassis/api.js +676 -0
  9. package/dist/chassis/bash-output.d.ts +89 -0
  10. package/dist/chassis/bash-output.js +157 -0
  11. package/dist/chassis/brand.d.ts +99 -0
  12. package/dist/chassis/brand.js +79 -0
  13. package/dist/chassis/client.d.ts +87 -0
  14. package/dist/chassis/client.js +271 -0
  15. package/dist/chassis/commands.d.ts +60 -0
  16. package/dist/chassis/commands.js +211 -0
  17. package/dist/chassis/editor.d.ts +70 -0
  18. package/dist/chassis/editor.js +213 -0
  19. package/dist/chassis/footer.d.ts +14 -0
  20. package/dist/chassis/footer.js +136 -0
  21. package/dist/chassis/header.d.ts +12 -0
  22. package/dist/chassis/header.js +38 -0
  23. package/dist/chassis/home.d.ts +48 -0
  24. package/dist/chassis/home.js +184 -0
  25. package/dist/chassis/icons.d.ts +203 -0
  26. package/dist/chassis/icons.js +190 -0
  27. package/dist/chassis/index.d.ts +21 -0
  28. package/dist/chassis/index.js +582 -0
  29. package/dist/chassis/layout.d.ts +40 -0
  30. package/dist/chassis/layout.js +264 -0
  31. package/dist/chassis/message-modes.d.ts +21 -0
  32. package/dist/chassis/message-modes.js +49 -0
  33. package/dist/chassis/messages.d.ts +75 -0
  34. package/dist/chassis/messages.js +392 -0
  35. package/dist/chassis/modes.d.ts +21 -0
  36. package/dist/chassis/modes.js +99 -0
  37. package/dist/chassis/palette.d.ts +84 -0
  38. package/dist/chassis/palette.js +235 -0
  39. package/dist/chassis/peek.d.ts +61 -0
  40. package/dist/chassis/peek.js +156 -0
  41. package/dist/chassis/prompt-row.d.ts +42 -0
  42. package/dist/chassis/prompt-row.js +68 -0
  43. package/dist/chassis/provider-view.d.ts +34 -0
  44. package/dist/chassis/provider-view.js +107 -0
  45. package/dist/chassis/provider.d.ts +69 -0
  46. package/dist/chassis/provider.js +73 -0
  47. package/dist/chassis/readonly-command.d.ts +19 -0
  48. package/dist/chassis/readonly-command.js +454 -0
  49. package/dist/chassis/sanitize.d.ts +42 -0
  50. package/dist/chassis/sanitize.js +103 -0
  51. package/dist/chassis/session-info.d.ts +39 -0
  52. package/dist/chassis/session-info.js +107 -0
  53. package/dist/chassis/sidebar.d.ts +48 -0
  54. package/dist/chassis/sidebar.js +273 -0
  55. package/dist/chassis/state.d.ts +38 -0
  56. package/dist/chassis/state.js +19 -0
  57. package/dist/chassis/telemetry.d.ts +60 -0
  58. package/dist/chassis/telemetry.js +191 -0
  59. package/dist/chassis/tools.d.ts +112 -0
  60. package/dist/chassis/tools.js +588 -0
  61. package/dist/chassis/version.d.ts +2 -0
  62. package/dist/chassis/version.js +20 -0
  63. package/dist/diff/git-diff.d.ts +71 -0
  64. package/dist/diff/git-diff.js +325 -0
  65. package/dist/diff/host.d.ts +34 -0
  66. package/dist/diff/host.js +50 -0
  67. package/dist/diff/index.d.ts +13 -0
  68. package/dist/diff/index.js +86 -0
  69. package/dist/diff/render.d.ts +67 -0
  70. package/dist/diff/render.js +196 -0
  71. package/dist/diff/types.d.ts +60 -0
  72. package/dist/diff/types.js +4 -0
  73. package/dist/diff/view.d.ts +118 -0
  74. package/dist/diff/view.js +528 -0
  75. package/dist/git/flows.d.ts +92 -0
  76. package/dist/git/flows.js +415 -0
  77. package/dist/git/forge.d.ts +26 -0
  78. package/dist/git/forge.js +41 -0
  79. package/dist/git/index.d.ts +20 -0
  80. package/dist/git/index.js +121 -0
  81. package/dist/git/repo.d.ts +64 -0
  82. package/dist/git/repo.js +186 -0
  83. package/dist/git/run.d.ts +38 -0
  84. package/dist/git/run.js +127 -0
  85. package/dist/git/settings.d.ts +8 -0
  86. package/dist/git/settings.js +38 -0
  87. package/dist/git/text.d.ts +51 -0
  88. package/dist/git/text.js +109 -0
  89. package/dist/widgets/container/detect.d.ts +23 -0
  90. package/dist/widgets/container/detect.js +67 -0
  91. package/dist/widgets/container/index.d.ts +9 -0
  92. package/dist/widgets/container/index.js +26 -0
  93. package/dist/widgets/mcp/index.d.ts +13 -0
  94. package/dist/widgets/mcp/index.js +50 -0
  95. package/dist/widgets/mcp/status.d.ts +50 -0
  96. package/dist/widgets/mcp/status.js +70 -0
  97. package/dist/widgets/session-todos/index.d.ts +12 -0
  98. package/dist/widgets/session-todos/index.js +52 -0
  99. package/dist/widgets/subagents/index.d.ts +16 -0
  100. package/dist/widgets/subagents/index.js +173 -0
  101. package/dist/widgets/subagents/status.d.ts +61 -0
  102. package/dist/widgets/subagents/status.js +198 -0
  103. package/dist/widgets/todo/index.d.ts +13 -0
  104. package/dist/widgets/todo/index.js +65 -0
  105. package/dist/widgets/todo/parse.d.ts +29 -0
  106. package/dist/widgets/todo/parse.js +122 -0
  107. package/docs/branding.md +25 -0
  108. package/docs/development.md +22 -0
  109. package/docs/diff-api.md +18 -0
  110. package/docs/git.md +23 -0
  111. package/docs/internals.md +13 -0
  112. package/docs/provider-status.md +23 -0
  113. package/docs/status-widgets.md +157 -0
  114. package/docs/tool-rows.md +50 -0
  115. package/docs/usage.md +62 -0
  116. package/docs/widgets.md +113 -0
  117. package/extensions/chassis/alert.ts +127 -0
  118. package/extensions/chassis/api.ts +967 -0
  119. package/extensions/chassis/bash-output.ts +186 -0
  120. package/extensions/chassis/brand.ts +151 -0
  121. package/extensions/chassis/client.ts +284 -0
  122. package/extensions/chassis/commands.ts +244 -0
  123. package/extensions/chassis/editor.ts +253 -0
  124. package/extensions/chassis/footer.ts +131 -0
  125. package/extensions/chassis/header.ts +43 -0
  126. package/extensions/chassis/home.ts +186 -0
  127. package/extensions/chassis/icons.ts +220 -0
  128. package/extensions/chassis/index.ts +605 -0
  129. package/extensions/chassis/layout.ts +298 -0
  130. package/extensions/chassis/message-modes.ts +48 -0
  131. package/extensions/chassis/messages.ts +403 -0
  132. package/extensions/chassis/modes.ts +108 -0
  133. package/extensions/chassis/palette.ts +258 -0
  134. package/extensions/chassis/peek.ts +167 -0
  135. package/extensions/chassis/prompt-row.ts +86 -0
  136. package/extensions/chassis/provider-view.ts +118 -0
  137. package/extensions/chassis/provider.ts +115 -0
  138. package/extensions/chassis/readonly-command.ts +440 -0
  139. package/extensions/chassis/sanitize.ts +108 -0
  140. package/extensions/chassis/session-info.ts +127 -0
  141. package/extensions/chassis/sidebar.ts +285 -0
  142. package/extensions/chassis/state.ts +50 -0
  143. package/extensions/chassis/telemetry.ts +222 -0
  144. package/extensions/chassis/tools.ts +618 -0
  145. package/extensions/chassis/version.ts +20 -0
  146. package/extensions/diff/git-diff.ts +365 -0
  147. package/extensions/diff/host.ts +58 -0
  148. package/extensions/diff/index.ts +101 -0
  149. package/extensions/diff/render.ts +217 -0
  150. package/extensions/diff/types.ts +67 -0
  151. package/extensions/diff/view.ts +523 -0
  152. package/extensions/git/flows.ts +485 -0
  153. package/extensions/git/forge.ts +54 -0
  154. package/extensions/git/index.ts +117 -0
  155. package/extensions/git/repo.ts +200 -0
  156. package/extensions/git/run.ts +143 -0
  157. package/extensions/git/settings.ts +38 -0
  158. package/extensions/git/text.ts +133 -0
  159. package/extensions/widgets/container/detect.ts +81 -0
  160. package/extensions/widgets/container/index.ts +29 -0
  161. package/extensions/widgets/mcp/index.ts +53 -0
  162. package/extensions/widgets/mcp/status.ts +94 -0
  163. package/extensions/widgets/session-todos/index.ts +55 -0
  164. package/extensions/widgets/subagents/index.ts +170 -0
  165. package/extensions/widgets/subagents/status.ts +237 -0
  166. package/extensions/widgets/todo/index.ts +66 -0
  167. package/extensions/widgets/todo/parse.ts +119 -0
  168. package/package.json +91 -4
  169. package/scripts/link-pi.mjs +76 -0
@@ -0,0 +1,618 @@
1
+ /**
2
+ * OpenCode-style tool rows: one line per call, details on expand (ctrl+o).
3
+ *
4
+ * 󰧮 Read src/app.ts 120 lines
5
+ * 󰏫 Edit src/app.ts +12 -3
6
+ * 󰆍 pnpm test exit 0 · 2.1s
7
+ * 󰍉 Grep "TODO" in src 14 matches
8
+ *
9
+ * The rows are drawn by a renderer resolver (`pi.registerToolRenderer`), which chooses how calls to
10
+ * a tool are drawn without touching the tool: pi's own definitions for read, bash, edit, write,
11
+ * grep, find and ls, with their settings and the active tool set, stay as they are. Tools that
12
+ * belong to other extensions are drawn the same way when that extension registered a row spec for
13
+ * them (`registerToolRows` in api.ts); chassis knows no tool but pi's built-in ones by name.
14
+ *
15
+ * Everything shown comes from tool arguments and results, which a file, a web page or a model can
16
+ * influence, so each piece of text is cleaned of escape sequences and control characters before it
17
+ * is drawn (sanitize.ts).
18
+ */
19
+
20
+ import { type ExtensionAPI, renderDiff, type Theme, type ToolRenderers } from "@earendil-works/pi-coding-agent";
21
+ import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
22
+ import { relative } from "node:path";
23
+ import { noteToolRowFailure, toolRowBroken, toolRowFor, type ToolRowSpec } from "./api.ts";
24
+ import { icon, type IconLike, type IconName, resolveIcon } from "./icons.ts";
25
+ import { primary, sanitize, sanitizeLines, scrubText, shade, stripEscapes } from "./palette.ts";
26
+ import { type BashOutput, bashPeek, foldLines, formatWallTime, LIVE_TAIL_LINES, liveTail, liveVisible, liveWaitMs, parseBashOutput } from "./bash-output.ts";
27
+ import { diffStats } from "./session-info.ts";
28
+
29
+ /** The icon name per tool (icons.ts has the glyph for each set, one cell wide). */
30
+ export const TOOL_ICONS = {
31
+ read: "toolRead",
32
+ write: "toolWrite",
33
+ edit: "toolEdit",
34
+ bash: "toolBash",
35
+ grep: "toolGrep",
36
+ find: "toolFind",
37
+ ls: "toolLs",
38
+ } as const satisfies Record<string, IconName>;
39
+
40
+ const PREVIEW_LINES = 12;
41
+
42
+ /**
43
+ * How a tool row is drawn:
44
+ * plain one unshaded line, the icon and text with a dim summary
45
+ * block a shaded, padded block holding the command and its output
46
+ * bar a shaded one-liner with unshaded output beneath it
47
+ */
48
+ export type RowShape = "plain" | "block" | "bar";
49
+
50
+ /**
51
+ * The one rule for a row's shape, used for every stage of a call so it never changes shape without
52
+ * a reason: a call is a plain line until it has output to show or has failed, then a block for the
53
+ * rest of its life. Tools without a block look (no peek and no live view) are plain, or a bar once
54
+ * they have a body or an error, as before.
55
+ */
56
+ export function rowShape(opts: { blockTool: boolean; hasBody: boolean; isError: boolean; expanded: boolean; sawBody: boolean }): RowShape {
57
+ const active = opts.hasBody || opts.isError || opts.expanded || opts.sawBody;
58
+ if (!active) return "plain";
59
+ return opts.blockTool ? "block" : "bar";
60
+ }
61
+
62
+ /**
63
+ * A shaded row with OpenCode's left border: a one-column strip in the page background, then the
64
+ * panel shade. OpenCode draws the border in `theme.background`; a terminal has no such token to
65
+ * read, so the strip is left unshaded and shows the terminal's own background.
66
+ */
67
+ function edged(theme: Theme, text: string, width: number): string {
68
+ return ` ${shade(theme, text, Math.max(0, width - 1))}`;
69
+ }
70
+
71
+ /**
72
+ * A tool row: the call line on the panel shade (so calls stand apart from assistant text and
73
+ * output), a right-aligned summary, and unshaded expanded output underneath. Width-safe.
74
+ */
75
+ class Row implements Component {
76
+ hidden = false;
77
+ private readonly theme: Theme;
78
+ private readonly left: string;
79
+ private readonly right: string;
80
+ private readonly body: string[];
81
+ private readonly shape: RowShape;
82
+
83
+ constructor(theme: Theme, left: string, right = "", body: string[] = [], shape: RowShape = "plain") {
84
+ this.theme = theme;
85
+ this.left = left;
86
+ this.right = right;
87
+ this.body = body;
88
+ this.shape = shape;
89
+ }
90
+ invalidate(): void {
91
+ this.cache = undefined;
92
+ }
93
+ /** Last output: the row's text never changes, so only the width (and theme) can. */
94
+ private cache: { width: number; mode: string; lines: string[] } | undefined;
95
+ render(width: number): string[] {
96
+ if (this.hidden) return [];
97
+ const mode = this.theme.getColorMode();
98
+ if (this.cache && this.cache.width === width && this.cache.mode === mode) return this.cache.lines;
99
+ // Blocks and bars lose one more column to the left border strip (see edged).
100
+ const w = Math.max(1, width - (this.shape === "plain" ? 2 : 3));
101
+ const r = this.right;
102
+ const rw = visibleWidth(r);
103
+ let line: string;
104
+ if (rw && rw + 4 < w) {
105
+ const l = truncateToWidth(this.left, w - rw - 2, "…");
106
+ line = `${l}${" ".repeat(Math.max(2, w - visibleWidth(l) - rw))}${r}`;
107
+ } else {
108
+ line = truncateToWidth(this.left, w, "…");
109
+ }
110
+ let lines: string[];
111
+ if (this.shape === "plain") {
112
+ // OpenCode's inline row: no shade, nothing under it.
113
+ lines = [` ${line}`];
114
+ } else if (this.shape === "block") {
115
+ // OpenCode's block: a blank shaded row above and below, and one between the command and
116
+ // its output (paddingTop/paddingBottom 1, gap 1). Every body line is shaded too.
117
+ const pad = edged(this.theme, "", width);
118
+ const bodyLine = (b: string) => edged(this.theme, ` ${truncateToWidth(b, w, "…")} `, width);
119
+ lines = [pad, edged(this.theme, ` ${line} `, width), pad, ...this.body.map(bodyLine), pad];
120
+ } else {
121
+ // "bar": a shaded one-liner with unshaded output under it (tools without a block look).
122
+ const bodyLine = (b: string) => ` ${truncateToWidth(b, w + 1, "…")}`;
123
+ lines = [edged(this.theme, ` ${line} `, width), ...this.body.map(bodyLine)];
124
+ }
125
+ this.cache = { width, mode, lines };
126
+ return lines;
127
+ }
128
+ }
129
+
130
+ function rel(path: unknown, cwd: string): string {
131
+ if (typeof path !== "string" || !path) return "…";
132
+ const r = relative(cwd, path);
133
+ return scrubText(!r || r.startsWith("..") ? path.replace(process.env.HOME ?? "\0", "~") : r);
134
+ }
135
+
136
+ function textOf(result: { content?: { type: string; text?: string }[] }): string {
137
+ return (result.content ?? [])
138
+ .filter((c) => c.type === "text")
139
+ .map((c) => c.text ?? "")
140
+ .join("\n");
141
+ }
142
+
143
+ function preview(text: string, theme: Theme, n = PREVIEW_LINES): string[] {
144
+ const lines = sanitizeLines(text).replace(/\s+$/, "").split("\n");
145
+ const shown = lines.slice(0, n).map((l) => ` ${theme.fg("toolOutput", l.replace(/\t/g, " "))}`);
146
+ if (lines.length > n) shown.push(` ${theme.fg("dim", `… ${lines.length - n} more lines`)}`);
147
+ return shown;
148
+ }
149
+
150
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;:]*m/g, "");
151
+
152
+ /** Lines of a diff or a new file shown under a finished edit or write row, and when opened. */
153
+ const CODE_PEEK_LINES = 20;
154
+ const CODE_OPEN_LINES = 120;
155
+
156
+ /** The lines of a diff as pi draws them (added, removed and context colours), without the trailing blank. */
157
+ function diffLines(diff: string | undefined): string[] {
158
+ if (!diff) return [];
159
+ const lines = renderDiff(sanitizeLines(diff)).split("\n");
160
+ while (lines.length && stripAnsi(lines[lines.length - 1]!).trim() === "") lines.pop();
161
+ return lines;
162
+ }
163
+
164
+ /** Numbered lines of a new file: a dim number in a column as wide as the last one, then the code. */
165
+ function numbered(text: string, theme: Theme): string[] {
166
+ const lines = sanitizeLines(text).replace(/\s+$/, "").split("\n");
167
+ const w = String(lines.length).length;
168
+ return lines.map((l, i) => `${theme.fg("dim", String(i + 1).padStart(w))} ${theme.fg("toolOutput", l.replace(/\t/g, " "))}`);
169
+ }
170
+
171
+ /** Lines in a file's text: a final newline ends the last line, it does not start another. */
172
+ export function countLines(text: unknown): number {
173
+ const t = sanitizeLines(text).replace(/\n$/, "");
174
+ return t === "" ? 0 : t.split("\n").length;
175
+ }
176
+
177
+ /** Indent body lines two columns, under the title text, as bash output is. */
178
+ const indented = (lines: string[]) => lines.map((l) => ` ${l}`);
179
+
180
+ /**
181
+ * The first `n` of `lines`, with a dim note of what is left and how to open it. `total` is the
182
+ * true number of lines when `lines` is only the start of a longer list.
183
+ */
184
+ function clipped(lines: string[], n: number, theme: Theme, hint: boolean, total = lines.length): string[] {
185
+ if (total <= n) return lines;
186
+ const note = `… ${total - n} more lines${hint ? " · ctrl+o or click to expand" : ""}`;
187
+ return [...lines.slice(0, n), theme.fg("dim", note)];
188
+ }
189
+
190
+ /** The dim glyph and a space, or nothing when the current icon set has none for this tool. */
191
+ const callIcon = (theme: Theme, glyph: string) => (glyph ? `${theme.fg("dim", glyph)} ` : "");
192
+ const name = (theme: Theme, s: string) => theme.fg("text", s);
193
+ const arg = (theme: Theme, s: string) => theme.fg("muted", s);
194
+
195
+ /** What a renderer knows beyond the result: its shared state and how long the call ran. */
196
+ interface RenderExtra {
197
+ isError: boolean;
198
+ /** Seconds from the first partial result to the last, when the renderer saw both. */
199
+ seconds?: number;
200
+ }
201
+
202
+ /** How one tool is drawn. Exported for tests. */
203
+ export interface Spec {
204
+ /** An icon name (see TOOL_ICONS); the glyph is looked up when the row is drawn. */
205
+ icon?: IconName;
206
+ /** A glyph supplied by another extension, already cleaned (see `fromRowSpec`). Wins over `icon`. */
207
+ iconLike?: IconLike;
208
+ title: string;
209
+ describe: (args: any, cwd: string, theme: Theme) => string;
210
+ summarize: (result: any, args: any, theme: Theme, isError: boolean, extra?: RenderExtra) => string;
211
+ expand?: (result: any, args: any, theme: Theme, extra?: RenderExtra) => string[];
212
+ /**
213
+ * Body lines while the call is still running, from the partial result so far. Return undefined
214
+ * for nothing yet. `expanded` is whether the row was opened with ctrl+o.
215
+ */
216
+ live?: (partial: any, theme: Theme, expanded: boolean) => string[] | undefined;
217
+ /**
218
+ * Body lines left under a finished row while it is collapsed (the last lines of output, and a
219
+ * hint for opening the rest). Return undefined for none. Rows with a peek draw as a shaded block.
220
+ */
221
+ peek?: (result: any, args: any, theme: Theme, extra?: RenderExtra) => string[] | undefined;
222
+ }
223
+
224
+ /** Lines of bash output shown when a row is expanded, from each end when the output is long. */
225
+ const BASH_HEAD_LINES = 6;
226
+ const BASH_TAIL_LINES = 14;
227
+
228
+ /** The parsed output of a bash result, cached on the result object so summary and body share it. */
229
+ const parsedBash = new WeakMap<object, BashOutput>();
230
+ function bashOutputOf(result: any, isError: boolean): BashOutput {
231
+ const key = result && typeof result === "object" ? result : undefined;
232
+ const hit = key && parsedBash.get(key);
233
+ if (hit) return hit;
234
+ const parsed = parseBashOutput(textOf(result), {
235
+ isError,
236
+ truncation: result?.details?.truncation,
237
+ fullOutputPath: result?.details?.fullOutputPath,
238
+ });
239
+ // cleanLine removes escape sequences and controls; bidi controls are removed here.
240
+ parsed.lines = parsed.lines.map(stripEscapes);
241
+ if (key) parsedBash.set(key, parsed);
242
+ return parsed;
243
+ }
244
+
245
+ /** Output lines for an expanded bash row: the first and last of a long output, with a note between. */
246
+ function bashBody(out: BashOutput, theme: Theme): string[] {
247
+ const dim = (s: string) => ` ${theme.fg("dim", s)}`;
248
+ const line = (l: string) => ` ${theme.fg("toolOutput", l)}`;
249
+ if (!out.lines.length) return [dim("(no output)")];
250
+ const limit = BASH_HEAD_LINES + BASH_TAIL_LINES;
251
+ if (out.lines.length <= limit && !out.truncated) return out.lines.map(line);
252
+ const head = foldLines(out.lines, BASH_HEAD_LINES, "head").shown;
253
+ const tail = foldLines(out.lines, BASH_TAIL_LINES, "tail").shown;
254
+ const hidden = out.totalLines - head.length - tail.length;
255
+ const note =
256
+ out.truncated && out.fullOutputPath
257
+ ? `… ${hidden} more lines, full output in ${scrubText(out.fullOutputPath.replace(process.env.HOME ?? "\0", "~"))}`
258
+ : `… ${hidden} more lines`;
259
+ return [...head.map(line), dim(note), ...tail.map(line)];
260
+ }
261
+
262
+ function errorLine(result: any, theme: Theme): string {
263
+ const first = sanitize(sanitizeLines(textOf(result)).split("\n").find((l) => sanitize(l)) ?? "") || "error";
264
+ if (/operation was aborted|aborted/i.test(first)) return theme.fg("warning", "cancelled");
265
+ return theme.fg("error", first.length > 60 ? `${first.slice(0, 57)}…` : first);
266
+ }
267
+
268
+ /** Exported for tests. */
269
+ export const SPECS: Record<string, Spec> = {
270
+ read: {
271
+ icon: TOOL_ICONS.read,
272
+ title: "Read",
273
+ describe: (a, cwd, t) => {
274
+ const range = a?.offset != null || a?.limit != null ? t.fg("dim", ` :${sanitize(a.offset ?? 1)}${a.limit ? `+${sanitize(a.limit)}` : ""}`) : "";
275
+ return `${arg(t, rel(a?.path, cwd))}${range}`;
276
+ },
277
+ summarize: (r, _a, t, err) => {
278
+ if (err) return errorLine(r, t);
279
+ if (r.content?.some((c: any) => c.type === "image")) return t.fg("dim", "image");
280
+ const n = textOf(r).replace(/\n+$/, "").split("\n").length;
281
+ const trunc = r.details?.truncation?.truncated ? t.fg("warning", " truncated") : "";
282
+ return t.fg("dim", `${n} line${n === 1 ? "" : "s"}`) + trunc;
283
+ },
284
+ expand: (r, _a, t) => preview(textOf(r), t),
285
+ },
286
+ write: {
287
+ icon: TOOL_ICONS.write,
288
+ title: "Write",
289
+ describe: (a, cwd, t) => arg(t, rel(a?.path, cwd)),
290
+ summarize: (r, a, t, err) => (err ? errorLine(r, t) : t.fg("success", `+${countLines(a?.content)}`)),
291
+ peek: (r, a, t, extra) => {
292
+ if (extra?.isError || !a?.content) return undefined;
293
+ return indented(clipped(numbered(String(a.content), t), CODE_PEEK_LINES, t, true));
294
+ },
295
+ expand: (_r, a, t) => indented(clipped(numbered(String(a?.content ?? ""), t), CODE_OPEN_LINES, t, false)),
296
+ },
297
+ edit: {
298
+ icon: TOOL_ICONS.edit,
299
+ title: "Edit",
300
+ describe: (a, cwd, t) => arg(t, rel(a?.path, cwd)),
301
+ summarize: (r, _a, t, err) => {
302
+ if (err) return errorLine(r, t);
303
+ const s = diffStats(r.details?.diff);
304
+ return `${t.fg("success", `+${s.added}`)} ${t.fg("error", `-${s.removed}`)}`;
305
+ },
306
+ peek: (r, _a, t, extra) => {
307
+ if (extra?.isError) return undefined;
308
+ const lines = diffLines(r.details?.diff);
309
+ return lines.length ? indented(clipped(lines, CODE_PEEK_LINES, t, true)) : undefined;
310
+ },
311
+ expand: (r, _a, t) => indented(clipped(diffLines(r.details?.diff), CODE_OPEN_LINES, t, false)),
312
+ },
313
+ bash: {
314
+ icon: TOOL_ICONS.bash,
315
+ title: "",
316
+ describe: (a, _cwd, t) => {
317
+ // A carriage return or line separator counts as a line break here, so a command cannot
318
+ // hide its tail by rewinding the line.
319
+ const command = sanitizeLines(a?.command ?? "…");
320
+ const cmd = scrubText(command.split("\n").find((l) => l.trim()) ?? "");
321
+ const multi = command.includes("\n") ? t.fg("dim", " …") : "";
322
+ return `${t.fg("text", cmd)}${multi}`;
323
+ },
324
+ summarize: (r, _a, t, err, extra) => {
325
+ const out = bashOutputOf(r, err);
326
+ const time = formatWallTime(extra?.seconds);
327
+ const tail = time ? t.fg("dim", ` · ${time}`) : "";
328
+ if (out.status === "aborted") return t.fg("warning", "cancelled");
329
+ if (out.status === "timeout") return t.fg("error", `timed out ${out.timeoutSecs}s`);
330
+ if (out.status === "no-exit") return t.fg("error", "no exit code");
331
+ if (out.status === "failed") return t.fg("error", out.exitCode !== undefined ? `exit ${out.exitCode}` : "failed") + tail;
332
+ const n = out.totalLines;
333
+ return t.fg("dim", n ? `${n} line${n === 1 ? "" : "s"}` : "done") + tail;
334
+ },
335
+ expand: (r, a, t, extra) => {
336
+ const out = bashOutputOf(r, extra?.isError === true);
337
+ const cmd = sanitizeLines(a?.command ?? "");
338
+ return [...(cmd.includes("\n") ? preview(cmd, t, 8) : []), ...bashBody(out, t)];
339
+ },
340
+ peek: (r, _a, t, extra) => {
341
+ const out = bashOutputOf(r, extra?.isError === true);
342
+ if (!out.lines.length) return undefined;
343
+ const { lines, hidden } = bashPeek(out);
344
+ const body = lines.map((l) => ` ${t.fg("toolOutput", l)}`);
345
+ if (hidden > 0) body.unshift(` ${t.fg("dim", `… ${hidden} more lines · ctrl+o or click to expand`)}`);
346
+ return body;
347
+ },
348
+ live: (partial, t, expanded) => {
349
+ const tail = liveTail(textOf(partial), expanded ? BASH_HEAD_LINES + BASH_TAIL_LINES : LIVE_TAIL_LINES);
350
+ const lines = tail.lines.map(stripEscapes);
351
+ const earlier = tail.earlier;
352
+ if (!lines.length) return undefined;
353
+ const body = lines.map((l) => ` ${t.fg("toolOutput", l)}`);
354
+ return earlier ? [` ${t.fg("dim", `… ${earlier} earlier`)}`, ...body] : body;
355
+ },
356
+ },
357
+ grep: {
358
+ icon: TOOL_ICONS.grep,
359
+ title: "Grep",
360
+ describe: (a, cwd, t) => `${t.fg("text", JSON.stringify(scrubText(a?.pattern ?? "")))}${a?.path ? arg(t, ` in ${rel(a.path, cwd)}`) : ""}${a?.glob ? t.fg("dim", ` ${scrubText(a.glob)}`) : ""}`,
361
+ summarize: (r, _a, t, err) => {
362
+ if (err) return errorLine(r, t);
363
+ const out = textOf(r).trim();
364
+ const n = !out || /^no matches/i.test(out) ? 0 : out.split("\n").length;
365
+ return t.fg("dim", `${n} match${n === 1 ? "" : "es"}`);
366
+ },
367
+ expand: (r, _a, t) => preview(textOf(r), t),
368
+ },
369
+ find: {
370
+ icon: TOOL_ICONS.find,
371
+ title: "Find",
372
+ describe: (a, cwd, t) => `${t.fg("text", scrubText(a?.pattern ?? ""))}${a?.path ? arg(t, ` in ${rel(a.path, cwd)}`) : ""}`,
373
+ summarize: (r, _a, t, err) => {
374
+ if (err) return errorLine(r, t);
375
+ const out = textOf(r).trim();
376
+ const n = !out || /^no files/i.test(out) ? 0 : out.split("\n").length;
377
+ return t.fg("dim", `${n} file${n === 1 ? "" : "s"}`);
378
+ },
379
+ expand: (r, _a, t) => preview(textOf(r), t),
380
+ },
381
+ ls: {
382
+ icon: TOOL_ICONS.ls,
383
+ title: "List",
384
+ describe: (a, cwd, t) => arg(t, rel(a?.path ?? ".", cwd)),
385
+ summarize: (r, _a, t, err) => (err ? errorLine(r, t) : t.fg("dim", `${textOf(r).trim().split("\n").filter(Boolean).length} entries`)),
386
+ expand: (r, _a, t) => preview(textOf(r), t),
387
+ },
388
+ };
389
+
390
+ /**
391
+ * The live body of a running row, once it has been running long enough to be worth showing. A
392
+ * command that prints a line and then goes quiet gets no further result from pi, so this asks for
393
+ * one more render when the delay is up.
394
+ */
395
+ function liveBody(
396
+ spec: Spec,
397
+ partial: any,
398
+ theme: Theme,
399
+ expanded: boolean,
400
+ state: any,
401
+ invalidate: (() => void) | undefined,
402
+ ): string[] | undefined {
403
+ if (!spec.live || !state) return undefined;
404
+ const text = textOf(partial);
405
+ const now = Date.now();
406
+ if (!liveVisible(state.firstSeenAt, now, text.trim() !== "")) {
407
+ const wait = text.trim() !== "" ? liveWaitMs(state.firstSeenAt, now) : undefined;
408
+ if (wait !== undefined && invalidate && !state.liveTimer) {
409
+ state.liveTimer = setTimeout(() => {
410
+ state.liveTimer = undefined;
411
+ invalidate();
412
+ }, wait);
413
+ state.liveTimer.unref?.();
414
+ }
415
+ return undefined;
416
+ }
417
+ return spec.live(partial, theme, expanded);
418
+ }
419
+
420
+ /** The glyph a spec draws in the current icon set, or "" for none. A supplied glyph is held to two cells. */
421
+ function glyphOf(spec: Spec): string {
422
+ if (spec.iconLike !== undefined) return truncateToWidth(resolveIcon(spec.iconLike) ?? "", 2);
423
+ return spec.icon ? icon(spec.icon) : "";
424
+ }
425
+
426
+ /** Exported for tests: the renderers for one tool spec. */
427
+ export function makeRenderers(spec: Spec, getCwd: () => string) {
428
+ const describe = (args: any, theme: Theme, cwd: string) =>
429
+ `${spec.title ? `${name(theme, spec.title)} ` : ""}${spec.describe(args, cwd, theme)}`;
430
+ return {
431
+ /**
432
+ * Call row: shown until the first result arrives. pi renders call then result into the same
433
+ * shell on every update, so renderResult hides the call row (shared via context.state).
434
+ */
435
+ renderCall(args: any, theme: Theme, context: any): Component {
436
+ const row = new Row(theme, `${callIcon(theme, glyphOf(spec))}${describe(args, theme, context?.cwd ?? getCwd())}`, theme.fg("dim", "…"), [], "plain");
437
+ if (context?.state) context.state.callRow = row;
438
+ return row;
439
+ },
440
+ renderResult(result: any, options: { expanded: boolean; isPartial: boolean }, theme: Theme, context: any): Component {
441
+ const callRow: Row | undefined = context?.state?.callRow;
442
+ if (callRow) callRow.hidden = true;
443
+ const args = context?.args;
444
+ const isError = context?.isError === true;
445
+ // Time the call from the first result this renderer sees: a partial one while it runs.
446
+ const state = context?.state;
447
+ let seconds: number | undefined;
448
+ if (state) {
449
+ state.firstSeenAt ??= Date.now();
450
+ if (!options.isPartial) {
451
+ state.doneAt ??= Date.now();
452
+ if (state.sawPartial) seconds = (state.doneAt - state.firstSeenAt) / 1000;
453
+ } else {
454
+ state.sawPartial = true;
455
+ }
456
+ }
457
+ if (!options.isPartial && state?.liveTimer) {
458
+ clearTimeout(state.liveTimer);
459
+ state.liveTimer = undefined;
460
+ }
461
+ const extra: RenderExtra = { isError, seconds };
462
+ const glyph = glyphOf(spec);
463
+ const mark = !glyph ? "" : isError ? `${theme.fg("error", glyph)} ` : options.isPartial ? `${primary(theme, glyph)} ` : callIcon(theme, glyph);
464
+ const left = `${mark}${describe(args, theme, context?.cwd ?? getCwd())}`;
465
+ const right = options.isPartial ? theme.fg("dim", "running…") : spec.summarize(result, args, theme, isError, extra);
466
+ let body: string[] = [];
467
+ if (options.isPartial) {
468
+ body = liveBody(spec, result, theme, options.expanded, state, context?.invalidate) ?? [];
469
+ } else if (options.expanded && spec.expand) {
470
+ body = spec.expand(result, args, theme, extra);
471
+ } else if (spec.peek) {
472
+ body = spec.peek(result, args, theme, extra) ?? [];
473
+ }
474
+ // Once a call has shown output it stays a block, even if a later frame has less to show
475
+ // (a quiet stretch while it runs), so it never flips back and forth.
476
+ if (state && body.length > 0) state.sawBody = true;
477
+ const shape = rowShape({
478
+ blockTool: Boolean(spec.peek || spec.live),
479
+ hasBody: body.length > 0,
480
+ isError,
481
+ expanded: options.expanded === true && !options.isPartial && body.length > 0,
482
+ sawBody: state?.sawBody === true,
483
+ });
484
+ return new Row(theme, left, right, body, shape);
485
+ },
486
+ };
487
+ }
488
+
489
+ /** The most lines a registered spec's `expand` may show before chassis cuts the rest. */
490
+ const ROW_EXPAND_LINES = 120;
491
+
492
+ /** Text from another extension: escape sequences, control characters and bidi controls removed, one line, SGR kept. */
493
+ const outside = (value: unknown): string => scrubText(value, true);
494
+
495
+ function outsideIcon(like: IconLike | undefined): IconLike | undefined {
496
+ if (typeof like === "string") return scrubText(like);
497
+ if (like && typeof like === "object") return like.plain === undefined ? { nerd: scrubText(like.nerd) } : { nerd: scrubText(like.nerd), plain: scrubText(like.plain) };
498
+ return undefined;
499
+ }
500
+
501
+ /**
502
+ * An internal spec for a row spec another extension registered. Everything the spec returns is
503
+ * cleaned here, so the row code below never sees raw text from outside. Exported for tests.
504
+ */
505
+ export function fromRowSpec(row: ToolRowSpec): Spec {
506
+ return {
507
+ iconLike: outsideIcon(row.icon),
508
+ title: outside(row.title),
509
+ describe: (a, cwd, t) => outside(row.describe(a, cwd, t)),
510
+ summarize: (r, a, t, err) => outside(row.summarize(r, a, t, err)),
511
+ expand: row.expand
512
+ ? (r, a, t) => {
513
+ const lines = row.expand!(r, a, t);
514
+ if (!Array.isArray(lines)) return [];
515
+ // Clean only what can be shown (the cap plus one, so the cut is seen), not a huge list.
516
+ return indented(clipped(lines.slice(0, ROW_EXPAND_LINES + 1).map(outside), ROW_EXPAND_LINES, t, false, lines.length));
517
+ }
518
+ : undefined,
519
+ };
520
+ }
521
+
522
+ /** Two components drawn one after the other. */
523
+ function stacked(first: Component, second: Component): Component {
524
+ return {
525
+ invalidate() {
526
+ first.invalidate();
527
+ second.invalidate();
528
+ },
529
+ render: (width: number) => [...first.render(width), ...second.render(width)],
530
+ };
531
+ }
532
+
533
+ /**
534
+ * Renderers for a tool that belongs to another extension, drawn from the row spec it registered.
535
+ *
536
+ * If the spec throws, that frame is drawn by the tool's own renderers (`next()`). A throw while the
537
+ * call's arguments or output are still arriving (a partial frame) is not held against the spec:
538
+ * the next frame tries it again, since half-received arguments are often what it choked on. A
539
+ * throw on a finished call latches: the rest of that call is drawn by the tool's own renderers, and
540
+ * the failure is counted against the spec (see `toolRowBroken`; after a few, new calls skip it).
541
+ * When the tool has no renderers of its own, the error goes to pi, which draws its plain fallback.
542
+ *
543
+ * One limit: pi reads `renderShell` once, when it creates the row, and cannot change it later. A
544
+ * call that falls back after it started is therefore still inside chassis's own shell (`"self"`);
545
+ * only a call that is resolved when the spec is already known to be broken gets the tool's shell.
546
+ */
547
+ function rowRenderers(row: ToolRowSpec, next: () => ToolRenderers | undefined): ToolRenderers | undefined {
548
+ let inner: ReturnType<typeof makeRenderers>;
549
+ try {
550
+ inner = makeRenderers(fromRowSpec(row), () => process.cwd());
551
+ } catch {
552
+ return next(); // not usable: the tool's own renderers, with its own shell
553
+ }
554
+ // Per call: kept in the call's renderer state when pi gives one, else for this resolution.
555
+ let latchedHere = false;
556
+ const isLatched = (context: any): boolean => latchedHere || context?.state?.rowSpecFailed === true;
557
+ /** Hold a failure against the spec when the call is finished; the styled call row, if any, is dropped. */
558
+ const fail = (context: any, partial: boolean): void => {
559
+ if (partial) return;
560
+ latchedHere = true;
561
+ noteToolRowFailure(row);
562
+ if (context?.state) {
563
+ context.state.rowSpecFailed = true;
564
+ context.state.callRow = undefined;
565
+ }
566
+ };
567
+ return {
568
+ renderShell: "self",
569
+ renderCall(args, theme, context) {
570
+ if (!isLatched(context)) {
571
+ try {
572
+ return inner.renderCall(args, theme, context);
573
+ } catch {
574
+ // Partial while the arguments are still streaming (pi sets argsComplete when they are whole).
575
+ fail(context, context?.isPartial === true && context?.argsComplete !== true);
576
+ }
577
+ }
578
+ const render = next()?.renderCall;
579
+ if (!render) throw new Error("tool row spec failed and the tool has no renderer of its own");
580
+ return render(args, theme, context);
581
+ },
582
+ renderResult(result, options, theme, context) {
583
+ // Set while the styled call row is on screen (and hidden by a result that began to draw).
584
+ const styledCall = context?.state?.callRow !== undefined;
585
+ if (!isLatched(context)) {
586
+ try {
587
+ return inner.renderResult(result, options, theme, context);
588
+ } catch {
589
+ fail(context, options?.isPartial === true);
590
+ }
591
+ }
592
+ const own = next();
593
+ if (!own?.renderResult) throw new Error("tool row spec failed and the tool has no renderer of its own");
594
+ const body = own.renderResult(result, options, theme, context);
595
+ // pi drew the styled call row first; it is hidden now, so the tool's own call row takes its place.
596
+ return styledCall && own.renderCall ? stacked(own.renderCall(context?.args, theme, context), body) : body;
597
+ },
598
+ };
599
+ }
600
+
601
+ /**
602
+ * Draw the built-in tools with chassis's rows, and any other tool whose extension registered a row
603
+ * spec (`registerToolRows`). A resolver answers for these
604
+ * names and passes every other tool to `next()`, so pi keeps the tool definitions, their settings
605
+ * (shell prefix, shell path, image resizing) and which tools are active; only the drawing changes.
606
+ * The resolver wins over a tool's own renderers. Chassis's own specs win over a registered spec of
607
+ * the same name. The registered specs are looked up when a call first appears, so load order does
608
+ * not matter. A spec that has failed on several finished calls is skipped and the tool is drawn by
609
+ * its own renderers, shell included.
610
+ */
611
+ export function installToolRenderers(pi: ExtensionAPI): void {
612
+ const drawn = Object.fromEntries(Object.keys(SPECS).map((n) => [n, { ...makeRenderers(SPECS[n]!, () => process.cwd()), renderShell: "self" as const }]));
613
+ pi.registerToolRenderer((toolName, next) => {
614
+ if (Object.hasOwn(drawn, toolName)) return drawn[toolName];
615
+ const row = toolRowFor(toolName);
616
+ return row && !toolRowBroken(row) ? rowRenderers(row, next) : next();
617
+ });
618
+ }
@@ -0,0 +1,20 @@
1
+ /** This package's own version, read once from its package.json (undefined if it can't be read). */
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ let cached: { value: string | undefined } | undefined;
8
+
9
+ export function chassisVersion(): string | undefined {
10
+ if (cached) return cached.value;
11
+ let value: string | undefined;
12
+ try {
13
+ const v = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"), "utf8"))?.version;
14
+ if (typeof v === "string" && v) value = v;
15
+ } catch {
16
+ // vendored without its package.json: show the pi version only
17
+ }
18
+ cached = { value };
19
+ return value;
20
+ }