@xynogen/pix-pretty 1.0.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/src/lang.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { basename, extname } from "node:path";
2
+
3
+ import type { BundledLanguage } from "./types.js";
4
+
5
+ export const EXT_LANG: Record<string, BundledLanguage> = {
6
+ ts: "typescript",
7
+ tsx: "tsx",
8
+ js: "javascript",
9
+ jsx: "jsx",
10
+ mjs: "javascript",
11
+ cjs: "javascript",
12
+ py: "python",
13
+ rb: "ruby",
14
+ rs: "rust",
15
+ go: "go",
16
+ java: "java",
17
+ c: "c",
18
+ cpp: "cpp",
19
+ h: "c",
20
+ hpp: "cpp",
21
+ cs: "csharp",
22
+ swift: "swift",
23
+ kt: "kotlin",
24
+ html: "html",
25
+ css: "css",
26
+ scss: "scss",
27
+ less: "css",
28
+ json: "json",
29
+ jsonc: "jsonc",
30
+ yaml: "yaml",
31
+ yml: "yaml",
32
+ toml: "toml",
33
+ md: "markdown",
34
+ mdx: "mdx",
35
+ sql: "sql",
36
+ sh: "bash",
37
+ bash: "bash",
38
+ zsh: "bash",
39
+ lua: "lua",
40
+ php: "php",
41
+ dart: "dart",
42
+ xml: "xml",
43
+ graphql: "graphql",
44
+ svelte: "svelte",
45
+ vue: "vue",
46
+ dockerfile: "dockerfile",
47
+ makefile: "make",
48
+ zig: "zig",
49
+ nim: "nim",
50
+ elixir: "elixir",
51
+ ex: "elixir",
52
+ erb: "erb",
53
+ hbs: "handlebars",
54
+ };
55
+
56
+ export function lang(fp: string): BundledLanguage | undefined {
57
+ const base = basename(fp).toLowerCase();
58
+ if (base === "dockerfile") return "dockerfile";
59
+ if (base === "makefile" || base === "gnumakefile") return "make";
60
+ if (base === ".envrc" || base === ".env") return "bash";
61
+ return EXT_LANG[extname(fp).slice(1).toLowerCase()];
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Terminal image rendering (iTerm2 / Kitty / Ghostty inline image protocols)
66
+ // Handles tmux passthrough for image protocols.
67
+ // ---------------------------------------------------------------------------
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Tests for paste-chips marker restyling and width safety.
3
+ *
4
+ * Regression: restyling markers can widen lines (e.g. "#1" → "text"),
5
+ * which caused a TUI crash when the restyled line exceeded terminal width.
6
+ * See: pi-crash.log — "Rendered line 38 exceeds terminal width (285 > 283)"
7
+ */
8
+
9
+ import { describe, it, expect } from "bun:test";
10
+ import { visibleWidth } from "@earendil-works/pi-tui";
11
+ import { restyleMarkers } from "./paste-chips";
12
+
13
+ // ─── restyleMarkers ──────────────────────────────────────────────────────────
14
+
15
+ describe("paste-chips restyleMarkers", () => {
16
+ describe("text markers", () => {
17
+ it("restyles chars marker to text label", () => {
18
+ const result = restyleMarkers("[paste #1 2232 chars]", new Set());
19
+ expect(result).toBe("[paste text 2232 chars]");
20
+ });
21
+
22
+ it("restyles lines marker to text label", () => {
23
+ const result = restyleMarkers("[paste #2 +42 lines]", new Set());
24
+ expect(result).toBe("[paste text +42]");
25
+ });
26
+
27
+ it("restyles bare marker (no size info) to text label", () => {
28
+ const result = restyleMarkers("[paste #3]", new Set());
29
+ expect(result).toBe("[paste text #3]");
30
+ });
31
+ });
32
+
33
+ describe("image markers", () => {
34
+ it("restyles marker to image label when ID is in imageIds", () => {
35
+ const imageIds = new Set([1]);
36
+ const result = restyleMarkers("[paste #1 58 chars]", imageIds);
37
+ expect(result).toBe("[paste image #1]");
38
+ });
39
+
40
+ it("does not restyle non-image ID as image", () => {
41
+ const imageIds = new Set([1]);
42
+ const result = restyleMarkers("[paste #2 100 chars]", imageIds);
43
+ expect(result).toBe("[paste text 100 chars]");
44
+ });
45
+ });
46
+
47
+ describe("multiple markers in one line", () => {
48
+ it("restyles all markers in a single line", () => {
49
+ const imageIds = new Set([1]);
50
+ const line =
51
+ "before [paste #1 58 chars] middle [paste #2 +10 lines] after";
52
+ const result = restyleMarkers(line, imageIds);
53
+ expect(result).toBe(
54
+ "before [paste image #1] middle [paste text +10] after",
55
+ );
56
+ });
57
+ });
58
+
59
+ describe("no markers", () => {
60
+ it("returns line unchanged when no markers present", () => {
61
+ const line = "just regular text with no paste markers";
62
+ expect(restyleMarkers(line, new Set())).toBe(line);
63
+ });
64
+
65
+ it("returns empty string unchanged", () => {
66
+ expect(restyleMarkers("", new Set())).toBe("");
67
+ });
68
+ });
69
+
70
+ describe("ANSI-styled markers", () => {
71
+ it("restyles markers embedded in ANSI sequences", () => {
72
+ const line = "\x1b[38;2;84;92;126m[paste #1 500 chars]\x1b[0m";
73
+ const result = restyleMarkers(line, new Set());
74
+ expect(result).toBe("\x1b[38;2;84;92;126m[paste text 500 chars]\x1b[0m");
75
+ });
76
+ });
77
+ });
78
+
79
+ // ─── Width safety (regression for crash) ─────────────────────────────────────
80
+
81
+ describe("paste-chips width safety", () => {
82
+ it("restyling a chars marker can increase visible width", () => {
83
+ // This is the core issue: "#1" (2 chars) → "text" (4 chars) = +2 width
84
+ const original = "[paste #1 2232 chars]";
85
+ const restyled = restyleMarkers(original, new Set());
86
+ expect(visibleWidth(restyled)).toBeGreaterThan(visibleWidth(original));
87
+ });
88
+
89
+ it("restyling a lines marker can decrease visible width", () => {
90
+ // "[paste #2 +42 lines]" (20 chars) → "[paste text +42]" (16 chars) = -4 width
91
+ const original = "[paste #2 +42 lines]";
92
+ const restyled = restyleMarkers(original, new Set());
93
+ expect(visibleWidth(restyled)).toBeLessThan(visibleWidth(original));
94
+ });
95
+
96
+ it("restyling to image label changes width", () => {
97
+ // "[paste #1 58 chars]" (19 chars) → "[paste image #1]" (16 chars)
98
+ const imageIds = new Set([1]);
99
+ const original = "[paste #1 58 chars]";
100
+ const restyled = restyleMarkers(original, imageIds);
101
+ expect(visibleWidth(restyled)).not.toBe(visibleWidth(original));
102
+ });
103
+
104
+ it("chars marker with large char count widens by 2", () => {
105
+ // "[paste #N CCCC chars]" → "[paste text CCCC chars]"
106
+ // "#N" (2 chars for single digit) → "text" (4 chars) = exactly +2
107
+ const original = "[paste #1 9999 chars]";
108
+ const restyled = restyleMarkers(original, new Set());
109
+ expect(visibleWidth(restyled) - visibleWidth(original)).toBe(2);
110
+ });
111
+
112
+ it("chars marker with multi-digit ID has smaller delta", () => {
113
+ // "#10" (3 chars) → "text" (4 chars) = +1
114
+ const original = "[paste #10 9999 chars]";
115
+ const restyled = restyleMarkers(original, new Set());
116
+ expect(visibleWidth(restyled) - visibleWidth(original)).toBe(1);
117
+ });
118
+
119
+ describe("reproduces crash scenario", () => {
120
+ it("restyled line exceeds terminal width without clamping", () => {
121
+ // Simulate the crash: a line at exactly terminal width (283)
122
+ // containing a marker that widens by 2 after restyling.
123
+ const terminalWidth = 283;
124
+ const marker = "[paste #1 2232 chars]"; // 21 chars
125
+ const padding = " ".repeat(terminalWidth - visibleWidth(marker));
126
+ const line = marker + padding;
127
+
128
+ // Verify the original line fits
129
+ expect(visibleWidth(line)).toBe(terminalWidth);
130
+
131
+ // Restyle it — this WOULD exceed width without the fix
132
+ const restyled = restyleMarkers(line, new Set());
133
+ expect(visibleWidth(restyled)).toBeGreaterThan(terminalWidth);
134
+ // Specifically: "[paste text 2232 chars]" is 23 chars, +2 over original 21
135
+ expect(visibleWidth(restyled)).toBe(terminalWidth + 2);
136
+ });
137
+ });
138
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * paste-chips — collapse pasted image paths into Pi paste markers, and
3
+ * re-render all paste markers with type-aware labels.
4
+ *
5
+ * /tmp/pi-clipboard-abc.png → buffer: [paste #1 58 chars]
6
+ * display: [paste image #1]
7
+ *
8
+ * long pasted text → buffer: [paste #2 +42 lines]
9
+ * display: [paste text +42]
10
+ *
11
+ * Atomic deletion + cursor handling come from Pi's marker grammar.
12
+ * On submit, getExpandedText() restores the real path/text for the model.
13
+ * The display rewrite is purely visual (render layer); buffer is untouched.
14
+ */
15
+
16
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
17
+ import type {
18
+ EditorFactory,
19
+ ExtensionAPI,
20
+ KeybindingsManager,
21
+ } from "@earendil-works/pi-coding-agent";
22
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
23
+ import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
24
+
25
+ // ─── Constants ────────────────────────────────────────────────────────────────
26
+
27
+ const IMAGE_EXTS = new Set([
28
+ ".png",
29
+ ".jpg",
30
+ ".jpeg",
31
+ ".gif",
32
+ ".webp",
33
+ ".bmp",
34
+ ".tif",
35
+ ".tiff",
36
+ ".heic",
37
+ ".heif",
38
+ ]);
39
+
40
+ // Group 1 = prefix char (or empty at start), Group 2 = path
41
+ const PATH_RE = /(^|[^\w/])((?:~|\/)[^\s,;'"(){}[\]]+)/g;
42
+
43
+ // Pi's marker grammar — must match exactly for atomic segmentation.
44
+ // e.g. `[paste #1 58 chars]` or `[paste #2 +42 lines]`
45
+ const MARKER_RE = /\[paste #(\d+)( (\+(\d+) lines|(\d+) chars))?\]/g;
46
+
47
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
48
+
49
+ function extOf(p: string): string {
50
+ const dot = p.lastIndexOf(".");
51
+ return dot >= 0 ? p.slice(dot).toLowerCase() : "";
52
+ }
53
+
54
+ function isImagePath(p: string): boolean {
55
+ return IMAGE_EXTS.has(extOf(p));
56
+ }
57
+
58
+ function makeMarker(id: number, charCount: number): string {
59
+ return `[paste #${id} ${charCount} chars]`;
60
+ }
61
+
62
+ // ─── Editor internals (Pi's TS-private fields are runtime-public JS) ─────────
63
+
64
+ type EditorInternals = {
65
+ pastes: Map<number, string>;
66
+ pasteCounter: number;
67
+ };
68
+
69
+ // ─── Path → marker rewriter ──────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Walk `text`, for each image path: allocate a new paste ID, register the
73
+ * real path in editor.pastes, remember the ID as an image, and emit a
74
+ * Pi-format marker so deletion is atomic.
75
+ */
76
+ function replaceImagePaths(
77
+ text: string,
78
+ internals: EditorInternals,
79
+ imageIds: Set<number>,
80
+ ): string {
81
+ return text.replace(PATH_RE, (_, prefix: string, rawPath: string) => {
82
+ if (!isImagePath(rawPath)) return prefix + rawPath;
83
+ internals.pasteCounter += 1;
84
+ const id = internals.pasteCounter;
85
+ internals.pastes.set(id, rawPath);
86
+ imageIds.add(id);
87
+ return prefix + makeMarker(id, rawPath.length);
88
+ });
89
+ }
90
+
91
+ // ─── Display rewriter ────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Re-style every paste marker in a rendered line:
95
+ * image → `[paste image #N]`
96
+ * text → `[paste text +Lines]` or `[paste text Chars]`
97
+ *
98
+ * Width-preserving is not required — Pi re-wraps each render call.
99
+ */
100
+ export function restyleMarkers(line: string, imageIds: Set<number>): string {
101
+ return line.replace(
102
+ MARKER_RE,
103
+ (_full, idStr, _g2, _g3, linesStr, charsStr) => {
104
+ const id = Number.parseInt(idStr, 10);
105
+ if (imageIds.has(id)) {
106
+ return `[paste image #${id}]`;
107
+ }
108
+ if (linesStr) {
109
+ return `[paste text +${linesStr}]`;
110
+ }
111
+ if (charsStr) {
112
+ return `[paste text ${charsStr} chars]`;
113
+ }
114
+ return `[paste text #${id}]`;
115
+ },
116
+ );
117
+ }
118
+
119
+ // ─── Custom editor ────────────────────────────────────────────────────────────
120
+
121
+ class ChipEditor extends CustomEditor {
122
+ private readonly imageIds = new Set<number>();
123
+
124
+ constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) {
125
+ super(tui, theme, keybindings);
126
+ }
127
+
128
+ override insertTextAtCursor(text: string): void {
129
+ const internals = this as unknown as EditorInternals;
130
+ super.insertTextAtCursor(replaceImagePaths(text, internals, this.imageIds));
131
+ }
132
+
133
+ override render(width: number): string[] {
134
+ const raw = super.render(width);
135
+ return raw.map((line) => {
136
+ const restyled = restyleMarkers(line, this.imageIds);
137
+ // Restyling may widen lines (e.g. "#1" → "text"), so clamp to width.
138
+ if (visibleWidth(restyled) > width) {
139
+ return truncateToWidth(restyled, width, "");
140
+ }
141
+ return restyled;
142
+ });
143
+ }
144
+ }
145
+
146
+ // ─── Extension ────────────────────────────────────────────────────────────────
147
+
148
+ export default function (pi: ExtensionAPI) {
149
+ pi.on("session_start", (_event, ctx) => {
150
+ if (!ctx.hasUI) return;
151
+ const factory: EditorFactory = (tui, theme, keybindings) =>
152
+ new ChipEditor(tui, theme, keybindings);
153
+ ctx.ui.setEditorComponent(factory);
154
+ });
155
+
156
+ pi.on("session_shutdown", (_event, ctx) => {
157
+ if (!ctx.hasUI) return;
158
+ ctx.ui.setEditorComponent(undefined);
159
+ });
160
+ }
@@ -0,0 +1,222 @@
1
+ import { basename, dirname } from "node:path";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+
4
+ import {
5
+ BOLD,
6
+ FG_BLUE,
7
+ FG_DIM,
8
+ FG_GREEN,
9
+ FG_RED,
10
+ FG_RULE,
11
+ FG_YELLOW,
12
+ RST,
13
+ } from "./ansi.js";
14
+ import { MAX_PREVIEW_LINES } from "./config.js";
15
+ import { hlBlock } from "./highlight.js";
16
+ import { dirIcon, fileIcon } from "./icons.js";
17
+ import { lang } from "./lang.js";
18
+ import { lnum, normalizeLineEndings, rule, termW } from "./utils.js";
19
+
20
+ /** Render syntax-highlighted file content with line numbers. */
21
+ export async function renderFileContent(
22
+ content: string,
23
+ filePath: string,
24
+ offset = 1,
25
+ maxLines = MAX_PREVIEW_LINES,
26
+ ): Promise<string> {
27
+ const normalizedContent = normalizeLineEndings(content);
28
+ const lines = normalizedContent.split("\n");
29
+ const total = lines.length;
30
+ const show = lines.slice(0, maxLines);
31
+ const lg = lang(filePath);
32
+ const hl = await hlBlock(show.join("\n"), lg);
33
+
34
+ const tw = termW();
35
+ const startLine = offset;
36
+ const endLine = startLine + show.length - 1;
37
+ const nw = Math.max(3, String(endLine).length);
38
+ const gw = nw + 3; // num + " │ "
39
+ const cw = Math.max(1, tw - gw);
40
+
41
+ const out: string[] = [];
42
+ out.push(rule(tw));
43
+
44
+ for (let i = 0; i < hl.length; i++) {
45
+ const ln = startLine + i;
46
+ const code = hl[i] ?? show[i] ?? "";
47
+ const display = truncateToWidth(code, cw, `${FG_DIM}›`);
48
+ out.push(`${lnum(ln, nw)} ${FG_RULE}│${RST} ${display}${RST}`);
49
+ }
50
+
51
+ out.push(rule(tw));
52
+ if (total > maxLines) {
53
+ out.push(
54
+ `${FG_DIM} … ${total - maxLines} more lines (${total} total)${RST}`,
55
+ );
56
+ }
57
+ return out.join("\n");
58
+ }
59
+
60
+ /** Render bash output with colored exit code and stderr highlighting. */
61
+ export function renderBashOutput(
62
+ text: string,
63
+ exitCode: number | null,
64
+ ): { summary: string; body: string } {
65
+ const isOk = exitCode === 0;
66
+ const statusFg = isOk ? FG_GREEN : FG_RED;
67
+ const statusIcon = isOk ? "✓" : "✗";
68
+ const codeStr =
69
+ exitCode !== null
70
+ ? `${statusFg}${statusIcon} exit ${exitCode}${RST}`
71
+ : `${FG_YELLOW}⚡ killed${RST}`;
72
+
73
+ const lines = text.split("\n");
74
+ const maxShow = MAX_PREVIEW_LINES;
75
+ const show = lines.slice(0, maxShow);
76
+ const remaining = lines.length - maxShow;
77
+
78
+ let body = show.join("\n");
79
+ if (remaining > 0) {
80
+ body += `\n${FG_DIM} … ${remaining} more lines${RST}`;
81
+ }
82
+
83
+ return { summary: codeStr, body };
84
+ }
85
+
86
+ /** Render ls output as a tree view with icons. */
87
+ export function renderTree(text: string, _basePath: string): string {
88
+ const lines = text.trim().split("\n").filter(Boolean);
89
+ if (!lines.length) return `${FG_DIM}(empty directory)${RST}`;
90
+
91
+ const out: string[] = [];
92
+ const total = lines.length;
93
+ const show = lines.slice(0, MAX_PREVIEW_LINES);
94
+
95
+ for (let i = 0; i < show.length; i++) {
96
+ const entry = show[i].trim();
97
+ const isLast = i === show.length - 1 && total <= MAX_PREVIEW_LINES;
98
+ const prefix = isLast ? "└── " : "├── ";
99
+ const connector = `${FG_RULE}${prefix}${RST}`;
100
+
101
+ // Detect directories (entries ending with /)
102
+ const isDir = entry.endsWith("/");
103
+ const name = isDir ? entry.slice(0, -1) : entry;
104
+ const icon = isDir ? dirIcon() : fileIcon(name);
105
+ const fg = isDir ? FG_BLUE + BOLD : "";
106
+ const reset = isDir ? RST : "";
107
+
108
+ out.push(`${connector}${icon}${fg}${name}${reset}`);
109
+ }
110
+
111
+ if (total > MAX_PREVIEW_LINES) {
112
+ out.push(
113
+ `${FG_RULE}└── ${RST}${FG_DIM}… ${total - MAX_PREVIEW_LINES} more entries${RST}`,
114
+ );
115
+ }
116
+
117
+ return out.join("\n");
118
+ }
119
+
120
+ /** Render find results grouped by directory with icons. */
121
+ export function renderFindResults(text: string): string {
122
+ const lines = text.trim().split("\n").filter(Boolean);
123
+ if (!lines.length) return `${FG_DIM}(no matches)${RST}`;
124
+
125
+ // Group by directory
126
+ const groups = new Map<string, string[]>();
127
+ for (const line of lines) {
128
+ const trimmed = line.trim();
129
+ const dir = dirname(trimmed) || ".";
130
+ const file = basename(trimmed);
131
+ if (!groups.has(dir)) groups.set(dir, []);
132
+ const bucket = groups.get(dir);
133
+ if (bucket) bucket.push(file);
134
+ }
135
+
136
+ const out: string[] = [];
137
+ let count = 0;
138
+
139
+ for (const [dir, files] of groups) {
140
+ if (count > 0) out.push(""); // blank line between groups
141
+ out.push(`${dirIcon()}${FG_BLUE}${BOLD}${dir}/${RST}`);
142
+ for (let i = 0; i < files.length; i++) {
143
+ if (count >= MAX_PREVIEW_LINES) {
144
+ out.push(` ${FG_DIM}… ${lines.length - count} more files${RST}`);
145
+ return out.join("\n");
146
+ }
147
+ const isLast = i === files.length - 1;
148
+ const prefix = isLast ? "└── " : "├── ";
149
+ const icon = fileIcon(files[i]);
150
+ out.push(` ${FG_RULE}${prefix}${RST}${icon}${files[i]}`);
151
+ count++;
152
+ }
153
+ }
154
+
155
+ return out.join("\n");
156
+ }
157
+
158
+ /** Render grep results with highlighted matches and line numbers. */
159
+ export async function renderGrepResults(
160
+ text: string,
161
+ pattern: string,
162
+ ): Promise<string> {
163
+ const lines = normalizeLineEndings(text).split("\n");
164
+ if (!lines.length || (lines.length === 1 && !lines[0].trim()))
165
+ return `${FG_DIM}(no matches)${RST}`;
166
+
167
+ const out: string[] = [];
168
+ let currentFile = "";
169
+ let count = 0;
170
+
171
+ // Try to build a regex for highlighting
172
+ let re: RegExp | null = null;
173
+ try {
174
+ re = new RegExp(`(${pattern})`, "gi");
175
+ } catch {
176
+ // invalid regex — skip highlighting
177
+ }
178
+
179
+ for (const line of lines) {
180
+ if (count >= MAX_PREVIEW_LINES) {
181
+ out.push(`${FG_DIM} … more matches${RST}`);
182
+ break;
183
+ }
184
+
185
+ // ripgrep-style: "file:line:content" or "file-line-content" or just "file"
186
+ const fileMatch = line.match(/^(.+?)[:-](\d+)[:-](.*)$/);
187
+ if (fileMatch) {
188
+ const [, file, lineNo, content] = fileMatch;
189
+ if (file !== currentFile) {
190
+ if (currentFile) out.push(""); // blank line between files
191
+ const icon = fileIcon(file);
192
+ out.push(`${icon}${FG_BLUE}${BOLD}${file}${RST}`);
193
+ currentFile = file;
194
+ }
195
+
196
+ const nw = Math.max(3, lineNo.length);
197
+ let display = content;
198
+ if (re) {
199
+ display = content.replace(re, `${RST}${FG_YELLOW}${BOLD}$1${RST}`);
200
+ }
201
+ out.push(
202
+ ` ${lnum(Number(lineNo), nw)} ${FG_RULE}│${RST} ${display}${RST}`,
203
+ );
204
+ count++;
205
+ } else if (line.trim() === "--") {
206
+ // ripgrep separator
207
+ out.push(` ${FG_DIM} ···${RST}`);
208
+ } else if (line.trim()) {
209
+ out.push(line);
210
+ count++;
211
+ }
212
+ }
213
+
214
+ return out.join("\n");
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // FFF integration (optional) — Fast File Finder with frecency & SIMD search
219
+ //
220
+ // If @ff-labs/fff-node is installed, find/grep use FFF for speed + frecency.
221
+ // If not, falls back to wrapping SDK tools (current behavior).
222
+ // ---------------------------------------------------------------------------