@xynogen/pix-pretty 1.2.0 → 1.3.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.
@@ -0,0 +1,177 @@
1
+ import type {
2
+ AgentToolUpdateCallback,
3
+ ExtensionContext,
4
+ ReadToolInput,
5
+ } from "@earendil-works/pi-coding-agent";
6
+
7
+ import { FG_DIM, RST } from "../ansi.js";
8
+ import { MAX_PREVIEW_LINES } from "../config.js";
9
+ import { fileIcon } from "../icons.js";
10
+ import { renderFileContent } from "../renderers.js";
11
+ import type {
12
+ PiPrettyApi,
13
+ ReadParams,
14
+ RenderContextLike,
15
+ ThemeLike,
16
+ ToolFactory,
17
+ ToolResultLike,
18
+ } from "../types.js";
19
+ import {
20
+ fillToolBackground,
21
+ getTextContent,
22
+ humanSize,
23
+ isImageContent,
24
+ isTextContent,
25
+ normalizeLineEndings,
26
+ renderToolError,
27
+ setResultDetails,
28
+ } from "../utils.js";
29
+ import type { ToolContext } from "./context.js";
30
+
31
+ export function registerReadTool(
32
+ pi: PiPrettyApi,
33
+ createReadTool: ToolFactory<ReadToolInput>,
34
+ ctx: ToolContext,
35
+ ): void {
36
+ const { cwd, sp, TextComponent } = ctx;
37
+ const origRead = createReadTool(cwd);
38
+
39
+ pi.registerTool({
40
+ ...origRead,
41
+ name: "read",
42
+
43
+ async execute(
44
+ tid: string,
45
+ params: ReadParams,
46
+ sig: AbortSignal | undefined,
47
+ upd: AgentToolUpdateCallback<unknown> | undefined,
48
+ toolCtx: ExtensionContext,
49
+ ) {
50
+ const result = (await origRead.execute(
51
+ tid,
52
+ params,
53
+ sig,
54
+ upd,
55
+ toolCtx,
56
+ )) as ToolResultLike;
57
+
58
+ const fp = params.path ?? "";
59
+ const offset = params.offset ?? 1;
60
+
61
+ const imageBlock = result.content?.find(isImageContent);
62
+ if (imageBlock) {
63
+ setResultDetails(result, {
64
+ _type: "readImage",
65
+ filePath: fp,
66
+ data: imageBlock.data,
67
+ mimeType: imageBlock.mimeType ?? "image/png",
68
+ });
69
+ return result;
70
+ }
71
+
72
+ const textContent = getTextContent(result);
73
+ if (textContent && fp) {
74
+ const normalizedContent = normalizeLineEndings(textContent);
75
+ const lineCount = normalizedContent.split("\n").length;
76
+ setResultDetails(result, {
77
+ _type: "readFile",
78
+ filePath: fp,
79
+ content: normalizedContent,
80
+ offset,
81
+ lineCount,
82
+ });
83
+ }
84
+
85
+ return result;
86
+ },
87
+
88
+ renderCall(
89
+ args: ReadParams,
90
+ theme: ThemeLike,
91
+ renderCtx: RenderContextLike,
92
+ ) {
93
+ const fp = args.path ?? "";
94
+ const text = renderCtx.lastComponent ?? new TextComponent("", 0, 0);
95
+ const offset = args.offset
96
+ ? ` ${theme.fg("muted", `from line ${args.offset}`)}`
97
+ : "";
98
+ const limit = args.limit
99
+ ? ` ${theme.fg("muted", `(${args.limit} lines)`)}`
100
+ : "";
101
+ text.setText(
102
+ fillToolBackground(
103
+ `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", sp(fp))}${offset}${limit}`,
104
+ ),
105
+ );
106
+ return text;
107
+ },
108
+
109
+ renderResult(
110
+ result: ToolResultLike,
111
+ _opt: unknown,
112
+ theme: ThemeLike,
113
+ renderCtx: RenderContextLike,
114
+ ) {
115
+ const text = renderCtx.lastComponent ?? new TextComponent("", 0, 0);
116
+
117
+ if (renderCtx.isError) {
118
+ text.setText(renderToolError(getTextContent(result) || "Error", theme));
119
+ return text;
120
+ }
121
+
122
+ const d = result.details as Record<string, unknown> | undefined;
123
+
124
+ if (d?._type === "readImage") {
125
+ const byteSize = Math.ceil(((d.data as string).length * 3) / 4);
126
+ text.setText(
127
+ fillToolBackground(
128
+ ` ${fileIcon(d.filePath as string)}${FG_DIM}${d.mimeType ?? "image"} · ${humanSize(byteSize)}${RST}`,
129
+ ),
130
+ );
131
+ return text;
132
+ }
133
+
134
+ if (d?._type === "readFile" && d.content) {
135
+ const key = `read:${d.filePath}:${d.offset}:${d.lineCount}:${process.stdout.columns ?? 80}`;
136
+ if (renderCtx.state._rk !== key) {
137
+ renderCtx.state._rk = key;
138
+ const info = `${FG_DIM}${d.lineCount} lines${RST}`;
139
+ renderCtx.state._rt = fillToolBackground(` ${info}`);
140
+
141
+ const maxShow = renderCtx.expanded
142
+ ? (d.lineCount as number)
143
+ : MAX_PREVIEW_LINES;
144
+ renderFileContent(
145
+ d.content as string,
146
+ d.filePath as string,
147
+ d.offset as number,
148
+ maxShow,
149
+ )
150
+ .then((rendered: string) => {
151
+ if (renderCtx.state._rk !== key) return;
152
+ renderCtx.state._rt = fillToolBackground(
153
+ ` ${info}\n${rendered}`,
154
+ );
155
+ renderCtx.invalidate();
156
+ })
157
+ .catch(() => {});
158
+ }
159
+ text.setText(
160
+ renderCtx.state._rt ??
161
+ fillToolBackground(` ${FG_DIM}${d.lineCount} lines${RST}`),
162
+ );
163
+ return text;
164
+ }
165
+
166
+ const fallback = result.content?.[0];
167
+ const fallbackText =
168
+ fallback && isTextContent(fallback) ? fallback.text : "read";
169
+ text.setText(
170
+ fillToolBackground(
171
+ ` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
172
+ ),
173
+ );
174
+ return text;
175
+ },
176
+ });
177
+ }
@@ -0,0 +1,231 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import type {
3
+ AgentToolUpdateCallback,
4
+ ExtensionContext,
5
+ ToolRenderResultOptions,
6
+ WriteToolInput,
7
+ } from "@earendil-works/pi-coding-agent";
8
+
9
+ import { MAX_RENDER_LINES } from "../config.js";
10
+ import { parseDiff } from "../diff.js";
11
+ import {
12
+ diffThemeCacheKey,
13
+ renderSplit,
14
+ resolveDiffColors,
15
+ summarize,
16
+ } from "../diff-render.js";
17
+ import { hlBlock } from "../highlight.js";
18
+ import { lang } from "../lang.js";
19
+ import type {
20
+ PiPrettyApi,
21
+ RenderContextLike,
22
+ ThemeLike,
23
+ ToolFactory,
24
+ ToolResultLike,
25
+ WriteParams,
26
+ WriteRenderState,
27
+ } from "../types.js";
28
+ import {
29
+ fillToolBackground,
30
+ getTextContent,
31
+ isTextContent,
32
+ renderToolError,
33
+ setResultDetails,
34
+ termW,
35
+ } from "../utils.js";
36
+ import type { ToolContext } from "./context.js";
37
+
38
+ export function registerWriteTool(
39
+ pi: PiPrettyApi,
40
+ createWriteTool: ToolFactory<WriteToolInput>,
41
+ ctx: ToolContext,
42
+ trackInvalidator: (id: string, inv: () => void) => void,
43
+ ): void {
44
+ const { cwd, sp, TextComponent } = ctx;
45
+ const origWrite = createWriteTool(cwd);
46
+
47
+ pi.registerTool({
48
+ ...origWrite,
49
+ name: "write",
50
+
51
+ async execute(
52
+ tid: string,
53
+ params: WriteParams,
54
+ sig: AbortSignal | undefined,
55
+ upd: AgentToolUpdateCallback<unknown> | undefined,
56
+ toolCtx: ExtensionContext,
57
+ ) {
58
+ const fp = params.path ?? params.file_path ?? "";
59
+ let old: string | null = null;
60
+ try {
61
+ if (fp && existsSync(fp)) old = readFileSync(fp, "utf-8");
62
+ } catch {
63
+ old = null;
64
+ }
65
+
66
+ const result = (await origWrite.execute(
67
+ tid,
68
+ params as unknown as Parameters<typeof origWrite.execute>[1],
69
+ sig,
70
+ upd,
71
+ toolCtx,
72
+ )) as ToolResultLike;
73
+ const content = params.content ?? "";
74
+
75
+ if (old !== null && old !== content) {
76
+ const diff = parseDiff(old, content);
77
+ setResultDetails(result, {
78
+ _type: "diff",
79
+ summary: summarize(diff.added, diff.removed),
80
+ oldContent: old,
81
+ newContent: content,
82
+ language: lang(fp),
83
+ });
84
+ } else if (old === null) {
85
+ setResultDetails(result, {
86
+ _type: "new",
87
+ lines: content ? content.split("\n").length : 0,
88
+ content,
89
+ filePath: fp,
90
+ });
91
+ } else {
92
+ setResultDetails(result, { _type: "noChange" });
93
+ }
94
+ return result;
95
+ },
96
+
97
+ renderCall(
98
+ args: WriteParams,
99
+ theme: ThemeLike,
100
+ renderCtx: RenderContextLike<WriteRenderState>,
101
+ ) {
102
+ const fp = args?.path ?? args?.file_path ?? "";
103
+ const isNew = !fp || !existsSync(fp);
104
+ const label = isNew ? "create" : "write";
105
+ const text = renderCtx.lastComponent ?? new TextComponent("", 0, 0);
106
+ const hdr = `${theme.fg("toolTitle", theme.bold(label))} ${theme.fg("accent", sp(fp))}`;
107
+
108
+ if (args?.content && isNew) {
109
+ const previewKey = `create:${diffThemeCacheKey(theme)}:${fp}:${String(args.content).length}`;
110
+ if (renderCtx.state._previewKey !== previewKey) {
111
+ renderCtx.state._previewKey = previewKey;
112
+ renderCtx.state._previewText = hdr;
113
+ const lg = lang(fp);
114
+ hlBlock(String(args.content), lg)
115
+ .then((lines) => {
116
+ if (renderCtx.state._previewKey !== previewKey) return;
117
+ const maxShow = renderCtx.expanded ? lines.length : 16;
118
+ const preview = lines.slice(0, maxShow).join("\n");
119
+ const rem = lines.length - maxShow;
120
+ let out = `${hdr}\n\n${preview}`;
121
+ if (rem > 0)
122
+ out += `\n${theme.fg("muted", `… (${rem} more lines, ${lines.length} total)`)}`;
123
+ renderCtx.state._previewText = out;
124
+ renderCtx.invalidate();
125
+ })
126
+ .catch(() => {});
127
+ }
128
+ text.setText(renderCtx.state._previewText ?? hdr);
129
+ return text;
130
+ }
131
+
132
+ text.setText(fillToolBackground(hdr));
133
+ return text;
134
+ },
135
+
136
+ renderResult(
137
+ result: ToolResultLike,
138
+ _opt: ToolRenderResultOptions,
139
+ theme: ThemeLike,
140
+ renderCtx: RenderContextLike<WriteRenderState>,
141
+ ) {
142
+ const text = renderCtx.lastComponent ?? new TextComponent("", 0, 0);
143
+ if (renderCtx.isError) {
144
+ text.setText(renderToolError(getTextContent(result) || "Error", theme));
145
+ return text;
146
+ }
147
+ const d = result.details as Record<string, unknown> | undefined;
148
+
149
+ if (d?._type === "diff") {
150
+ const key = `wd:${diffThemeCacheKey(theme)}:${termW()}:${d.summary}:${(d.newContent as string).length}:${d.language ?? ""}`;
151
+ if (renderCtx.toolCallId)
152
+ trackInvalidator(renderCtx.toolCallId, renderCtx.invalidate);
153
+ if (renderCtx.state._wdk !== key) {
154
+ renderCtx.state._wdk = key;
155
+ renderCtx.state._wdt = ` ${d.summary}\n${theme.fg("muted", " rendering diff…")}`;
156
+ const dc = resolveDiffColors(theme);
157
+ const diff = parseDiff(
158
+ d.oldContent as string,
159
+ d.newContent as string,
160
+ );
161
+ renderSplit(
162
+ diff,
163
+ d.language as string | undefined,
164
+ MAX_RENDER_LINES,
165
+ dc,
166
+ )
167
+ .then((rendered) => {
168
+ if (renderCtx.state._wdk !== key) return;
169
+ renderCtx.state._wdt = ` ${d.summary}\n${rendered}`;
170
+ renderCtx.invalidate();
171
+ })
172
+ .catch(() => {
173
+ if (renderCtx.state._wdk !== key) return;
174
+ renderCtx.state._wdt = ` ${d.summary}`;
175
+ renderCtx.invalidate();
176
+ });
177
+ }
178
+ text.setText(renderCtx.state._wdt ?? ` ${d.summary}`);
179
+ return text;
180
+ }
181
+
182
+ if (d?._type === "noChange") {
183
+ text.setText(
184
+ fillToolBackground(` ${theme.fg("muted", "✓ no changes")}`),
185
+ );
186
+ return text;
187
+ }
188
+
189
+ if (d?._type === "new") {
190
+ const {
191
+ lines: lineCount,
192
+ content: rawContent,
193
+ filePath: fp,
194
+ } = d as { lines: number; content: string; filePath: string };
195
+ const base = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`;
196
+ const pk = `nf:${diffThemeCacheKey(theme)}:${fp}:${lineCount}`;
197
+ if (renderCtx.state._nfk !== pk) {
198
+ renderCtx.state._nfk = pk;
199
+ renderCtx.state._nft = base;
200
+ if (rawContent) {
201
+ hlBlock(rawContent, lang(fp))
202
+ .then((hlLines) => {
203
+ if (renderCtx.state._nfk !== pk) return;
204
+ const maxShow = renderCtx.expanded ? hlLines.length : 12;
205
+ const preview = hlLines.slice(0, maxShow).join("\n");
206
+ const rem = hlLines.length - maxShow;
207
+ let out = `${base}\n${preview}`;
208
+ if (rem > 0)
209
+ out += `\n${theme.fg("muted", ` … ${rem} more lines`)}`;
210
+ renderCtx.state._nft = out;
211
+ renderCtx.invalidate();
212
+ })
213
+ .catch(() => {});
214
+ }
215
+ }
216
+ text.setText(renderCtx.state._nft ?? base);
217
+ return text;
218
+ }
219
+
220
+ const fallback = result.content?.[0];
221
+ const fallbackText =
222
+ fallback && isTextContent(fallback) ? fallback.text : "written";
223
+ text.setText(
224
+ fillToolBackground(
225
+ ` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
226
+ ),
227
+ );
228
+ return text;
229
+ },
230
+ });
231
+ }
package/src/tsconfig.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "esModuleInterop": true,
8
8
  "skipLibCheck": true,
9
9
  "noEmit": true,
10
- "types": ["node"],
10
+ "types": ["node", "bun"],
11
11
  "allowImportingTsExtensions": true
12
12
  },
13
13
  "include": ["*.ts", "*.d.ts", "../_types/**/*.d.ts"]
package/src/types.ts CHANGED
@@ -98,6 +98,8 @@ export type RenderContextLike<
98
98
  expanded: boolean;
99
99
  isError: boolean;
100
100
  invalidate: () => void;
101
+ /** Stable id for this tool execution — used to key resize invalidators. */
102
+ toolCallId?: string;
101
103
  };
102
104
 
103
105
  type SessionContextLike = ExtensionContext;
@@ -211,7 +213,12 @@ export type MultiGrepParams = {
211
213
 
212
214
  export type GrepRenderState = { _gk?: string; _gt?: string };
213
215
 
214
- export type EditRenderState = { _pk?: string; _pt?: string };
216
+ export type EditRenderState = {
217
+ _pk?: string;
218
+ _pt?: string;
219
+ _edk?: string;
220
+ _edt?: string;
221
+ };
215
222
 
216
223
  export type WriteRenderState = {
217
224
  _previewKey?: string;
@@ -258,6 +265,7 @@ export type RenderDetails =
258
265
  | GrepResultDetails
259
266
  | EditInfoDetails
260
267
  | MultiEditInfoDetails
268
+ | EditDiffDetails
261
269
  | WriteDiffDetails
262
270
  | WriteNewDetails
263
271
  | WriteNoChangeDetails;
@@ -267,6 +275,11 @@ export type EditInfoDetails = {
267
275
  _type: "editInfo";
268
276
  summary: string;
269
277
  editLine: number;
278
+ /** Full diff payload for split-view rendering */
279
+ oldContent: string;
280
+ newContent: string;
281
+ language: string | undefined;
282
+ filePath: string;
270
283
  };
271
284
 
272
285
  export type MultiEditInfoDetails = {
@@ -274,6 +287,13 @@ export type MultiEditInfoDetails = {
274
287
  summary: string;
275
288
  editCount: number;
276
289
  diffLineCount: number;
290
+ /** Per-operation diffs for split-view rendering */
291
+ ops: Array<{
292
+ oldContent: string;
293
+ newContent: string;
294
+ language: string | undefined;
295
+ filePath: string;
296
+ }>;
277
297
  };
278
298
 
279
299
  export type WriteDiffDetails = {
@@ -293,6 +313,15 @@ export type WriteNewDetails = {
293
313
 
294
314
  export type WriteNoChangeDetails = { _type: "noChange" };
295
315
 
316
+ export type EditDiffDetails = {
317
+ _type: "editDiff";
318
+ summary: string;
319
+ oldContent: string;
320
+ newContent: string;
321
+ language: string | undefined;
322
+ filePath: string;
323
+ };
324
+
296
325
  export interface PiPrettyDeps {
297
326
  sdk: PiPrettySdk;
298
327
  TextComponent: TextComponentCtor;
package/src/utils.ts CHANGED
@@ -48,7 +48,14 @@ export function fillToolBackground(text: string, bg = BG_BASE): string {
48
48
  .join("\n");
49
49
  }
50
50
 
51
+ let _cachedTermW: number | undefined;
52
+
53
+ /** Read terminal width — checks all available sources in priority order.
54
+ * Falls back to querying the controlling tty via fd 1/2/stdin ioctl.
55
+ * Result is cached and invalidated on SIGWINCH / stdout resize. */
51
56
  export function termW(): number {
57
+ if (_cachedTermW !== undefined) return _cachedTermW;
58
+
52
59
  const stderrWithColumns = process.stderr as NodeJS.WriteStream & {
53
60
  columns?: number;
54
61
  };
@@ -56,8 +63,44 @@ export function termW(): number {
56
63
  process.stdout.columns ||
57
64
  stderrWithColumns.columns ||
58
65
  Number.parseInt(process.env.COLUMNS ?? "", 10) ||
59
- 200;
60
- return Math.max(1, Math.min(raw - 4, 210));
66
+ _readTtyColumns() ||
67
+ 120;
68
+ _cachedTermW = Math.max(1, Math.min(raw, 210));
69
+
70
+ // Invalidate on resize so next call re-reads
71
+ process.stdout.once("resize", () => {
72
+ _cachedTermW = undefined;
73
+ });
74
+ process.stdin.once("resize", () => {
75
+ _cachedTermW = undefined;
76
+ });
77
+
78
+ return _cachedTermW;
79
+ }
80
+
81
+ /** Synchronously query the tty size via Node's built-in ioctl binding.
82
+ * Works even when stdout/stderr are piped, as long as stdin is a tty. */
83
+ function _readTtyColumns(): number | undefined {
84
+ try {
85
+ // Node exposes getWindowSize() on tty.ReadStream / tty.WriteStream
86
+ const { getWindowSize } = require("node:tty") as {
87
+ getWindowSize?: (fd: number) => [number, number];
88
+ };
89
+ if (getWindowSize) {
90
+ // Try fd 1 (stdout), 2 (stderr), 0 (stdin) in order
91
+ for (const fd of [1, 2, 0]) {
92
+ try {
93
+ const [cols] = getWindowSize(fd);
94
+ if (cols && cols > 0) return cols;
95
+ } catch {
96
+ /* fd not a tty */
97
+ }
98
+ }
99
+ }
100
+ } catch {
101
+ /* tty module unavailable */
102
+ }
103
+ return undefined;
61
104
  }
62
105
 
63
106
  export function shortPath(cwd: string, home: string, p: string): string {