@xynogen/pix-pretty 1.10.0 → 1.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-pretty",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Enhanced tool output rendering with syntax highlighting, file icons, tree views, diff rendering, and FFF search",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/config.ts CHANGED
@@ -20,6 +20,15 @@ const pc = config(prettySection);
20
20
 
21
21
  export const MAX_HL_CHARS = pixOrEnvInt("PRETTY_MAX_HL_CHARS", pc.maxHighlightChars, 80_000);
22
22
 
23
+ // Per-LINE guard. MAX_HL_CHARS caps the whole block, but cli-highlight's
24
+ // highlight.js tokenizer backtracks catastrophically on a single very long
25
+ // line (a multi-KB JSON string value, a minified one-liner) and freezes the
26
+ // render thread. JSON can't be hard-wrapped without splitting values mid-token
27
+ // (which breaks highlighting), so a block containing any line past this width
28
+ // is returned plain instead. ponytail: fixed threshold; make it a pix.json knob
29
+ // only if a real file legitimately needs highlighted lines wider than this.
30
+ export const MAX_HL_LINE_CHARS = envInt("PRETTY_MAX_HL_LINE_CHARS", 2_000);
31
+
23
32
  export const MAX_PREVIEW_LINES = pixOrEnvInt("PRETTY_MAX_PREVIEW_LINES", pc.maxPreviewLines, 80);
24
33
 
25
34
  export const CACHE_LIMIT = pixOrEnvInt("PRETTY_CACHE_LIMIT", pc.cacheLimit, 128);
@@ -23,4 +23,14 @@ describe("active-theme syntax highlighting", () => {
23
23
  await hlBlock("const value = 1", "typescript", theme("30;40;50"));
24
24
  expect(_cache.size).toBe(2);
25
25
  });
26
+
27
+ test("bails to plain (never highlights) when any line exceeds the per-line guard", async () => {
28
+ // Regression: a single multi-KB JSON string value made cli-highlight's
29
+ // tokenizer backtrack and froze the render thread. The guard returns the
30
+ // block unhighlighted (no ANSI, not cached) instead of tokenizing it.
31
+ const mega = JSON.stringify({ blurb: "x".repeat(5000) });
32
+ const out = await hlBlock(mega, "json", theme("10;20;30"));
33
+ expect(out.join("\n")).toBe(mega); // untouched, no ANSI escapes injected
34
+ expect(_cache.size).toBe(0); // not cached — it never went through highlight()
35
+ });
26
36
  });
package/src/highlight.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { normalizeShikiContrast } from "./ansi.js";
2
- import { CACHE_LIMIT, MAX_HL_CHARS } from "./config.js";
2
+ import { CACHE_LIMIT, MAX_HL_CHARS, MAX_HL_LINE_CHARS } from "./config.js";
3
3
  import type { BundledLanguage, FgTheme } from "./types.js";
4
4
 
5
5
  // Engine: cli-highlight (highlight.js-backed, synchronous ANSI output).
@@ -139,6 +139,13 @@ export async function hlBlock(
139
139
  ): Promise<string[]> {
140
140
  if (!code) return [""];
141
141
  if (!language || code.length > MAX_HL_CHARS) return code.split("\n");
142
+ // A single mega-line makes highlight.js backtrack catastrophically and freezes
143
+ // the render thread — bail to plain before it reaches cli-highlight. Cheap
144
+ // scan: split is already needed for the plain fallback and the cache miss path.
145
+ const rawLines = code.split("\n");
146
+ for (const line of rawLines) {
147
+ if (line.length > MAX_HL_LINE_CHARS) return rawLines;
148
+ }
142
149
 
143
150
  const hljsLang = toHljsLang(language);
144
151
  if (!hljsLang) return code.split("\n");
package/src/utils.test.ts CHANGED
@@ -4,10 +4,12 @@ import { MAX_PREVIEW_LINES } from "./config.js";
4
4
  import type { FgTheme } from "./types.js";
5
5
  import {
6
6
  formatCollapsedToolRow,
7
+ formatJson,
7
8
  hideCollapsedToolCall,
8
9
  pluralize,
9
10
  renderCollapsedToolRow,
10
11
  renderDimPreview,
12
+ ruleFrame,
11
13
  setResultDetails,
12
14
  } from "./utils.js";
13
15
 
@@ -17,6 +19,23 @@ function plain(text: string): string {
17
19
  return text.replace(ANSI, "");
18
20
  }
19
21
 
22
+ describe("ruleFrame", () => {
23
+ it("wraps body with a rule top and bottom, then footer below the close", () => {
24
+ const out = ruleFrame(["a", "b"], ["… +3 more"], 10);
25
+ expect(out).toHaveLength(5);
26
+ expect(plain(out[0]!)).toBe("─".repeat(10)); // top rule
27
+ expect(out.slice(1, 3)).toEqual(["a", "b"]); // body
28
+ expect(plain(out[3]!)).toBe("─".repeat(10)); // bottom rule closes the block
29
+ expect(plain(out[4]!)).toBe("… +3 more"); // footer after the close
30
+ });
31
+
32
+ it("closes the block even with no footer", () => {
33
+ const out = ruleFrame(["only"], [], 4);
34
+ expect(plain(out[0]!)).toBe("────");
35
+ expect(plain(out.at(-1)!)).toBe("────");
36
+ });
37
+ });
38
+
20
39
  // Minimal theme: fg() passes text through untouched.
21
40
  const theme: FgTheme = { fg: (_key, text) => text };
22
41
 
@@ -36,6 +55,55 @@ describe("pluralize", () => {
36
55
  });
37
56
  });
38
57
 
58
+ describe("formatJson", () => {
59
+ it("reindents a JSON string into a multiline block", () => {
60
+ expect(formatJson('{"a":1,"b":2}')).toBe('{\n "a": 1,\n "b": 2\n}');
61
+ });
62
+
63
+ it("reindents an object value", () => {
64
+ expect(formatJson({ a: 1 })).toBe('{\n "a": 1\n}');
65
+ });
66
+
67
+ it("falls back to the raw string for non-JSON input", () => {
68
+ expect(formatJson("not json")).toBe("not json");
69
+ });
70
+
71
+ it("reindenting a mega JSON one-liner breaks it into short, still-valid lines", () => {
72
+ // A JSON one-liner is the pathological render case. Reindenting alone splits
73
+ // it into short lines; it must NOT be hard-wrapped (that would split string
74
+ // values mid-token) so the block stays valid JSON for syntax highlighting.
75
+ const obj = { results: Array.from({ length: 300 }, (_, i) => ({ i, name: `item-${i}` })) };
76
+ const mega = JSON.stringify(obj); // one long line
77
+ const out = formatJson(mega, { wrapWidth: 80, maxLines: 9999 });
78
+ expect(out.split("\n").length).toBeGreaterThan(300); // broken into many lines
79
+ expect(() => JSON.parse(out)).not.toThrow(); // still valid JSON → highlightable
80
+ });
81
+
82
+ it("hard-wraps a NON-JSON mega-line but leaves JSON untouched", () => {
83
+ // A genuine non-JSON one-liner (multi-KB plain string) still gets wrapped so
84
+ // the TUI never measures a single huge line.
85
+ const plain = "x".repeat(7806); // not JSON
86
+ const wrapped = formatJson(plain, { wrapWidth: 80, maxLines: 9999 });
87
+ const lines = wrapped.split("\n");
88
+ expect(Math.max(...lines.map((l) => l.length))).toBeLessThanOrEqual(80);
89
+ expect(wrapped.replace(/\n/g, "").length).toBe(plain.length); // lossless
90
+ });
91
+
92
+ it("caps line count with a `+N more` footer", () => {
93
+ const obj = Object.fromEntries(Array.from({ length: 200 }, (_, i) => [`k${i}`, i]));
94
+ const out = formatJson(obj, { maxLines: 10 });
95
+ const lines = out.split("\n");
96
+ expect(lines.length).toBe(11); // 10 + footer
97
+ expect(lines.at(-1)).toMatch(/^… \+\d+ more$/);
98
+ });
99
+
100
+ it("applies a hard char ceiling as a last-resort guard", () => {
101
+ const out = formatJson({ blob: "y".repeat(5000) }, { maxChars: 100, maxLines: 999 });
102
+ expect(out.length).toBeLessThanOrEqual(100);
103
+ expect(out.endsWith("…")).toBe(true);
104
+ });
105
+ });
106
+
39
107
  describe("collapsed tool rows", () => {
40
108
  const rowTheme = { fg: (_key: string, text: string) => text, bold: (text: string) => text };
41
109
 
@@ -119,6 +187,24 @@ describe("renderDimPreview", () => {
119
187
  expect(out).toContain("body");
120
188
  });
121
189
 
190
+ it("frames the body with a rule top and bottom, header above the top rule", () => {
191
+ const out = plain(renderDimPreview("a\nb", theme, { frame: true, header: "2 files" }));
192
+ const lines = out.split("\n");
193
+ // header, top rule, a, b, bottom rule
194
+ expect(lines[0]).toContain("2 files");
195
+ expect(lines[1]).toMatch(/^─+$/); // top rule
196
+ expect(lines.at(-1)).toMatch(/^─+$/); // bottom rule closes the block
197
+ });
198
+
199
+ it("frames overflow footer below the bottom rule", () => {
200
+ const body = Array.from({ length: MAX_PREVIEW_LINES + 2 }, (_, i) => `L${i}`);
201
+ const out = plain(renderDimPreview(body.join("\n"), theme, { frame: true }));
202
+ const lines = out.split("\n");
203
+ // overflow marker is the LAST line, below the closing rule
204
+ expect(lines.at(-1)).toContain("… 2 more lines");
205
+ expect(lines.at(-2)).toMatch(/^─+$/); // bottom rule sits above the footer
206
+ });
207
+
122
208
  it("highlights matched keyword with non-dim styling", () => {
123
209
  const raw = renderDimPreview("foo bar foo", theme, { highlight: "foo" });
124
210
  // matched 'foo' wrapped in yellow/bold ANSI (not produced by stub fg)
package/src/utils.ts CHANGED
@@ -52,6 +52,77 @@ export function pluralize(count: number, noun: string, plural?: string): string
52
52
  return `${count} ${count === 1 ? noun : (plural ?? `${noun}s`)}`;
53
53
  }
54
54
 
55
+ export interface FormatJsonOptions {
56
+ /** Hard char ceiling for the whole block; a longer block is clipped with an ellipsis. */
57
+ maxChars?: number;
58
+ /** Line ceiling; excess lines are dropped for a `… +N more` footer. Default MAX_PREVIEW_LINES. */
59
+ maxLines?: number;
60
+ /**
61
+ * Fallback per-line hard-wrap for NON-JSON input only. When the value parses
62
+ * as JSON, reindenting already breaks any mega-line into short lines AND the
63
+ * result stays valid JSON, so callers can still syntax-highlight it — wrapping
64
+ * it would split string values mid-token and defeat highlighting. This only
65
+ * bites a genuine non-JSON one-liner (a multi-KB plain string) the TUI can't
66
+ * wrap on its own. 0 disables.
67
+ */
68
+ wrapWidth?: number;
69
+ }
70
+
71
+ // Hard-wrap a single line into `width`-char chunks. Preserves all characters
72
+ // (this is wrapping, not truncation) so the data stays complete.
73
+ function hardWrapLine(line: string, width: number): string[] {
74
+ if (width <= 0 || line.length <= width) return [line];
75
+ const out: string[] = [];
76
+ for (let i = 0; i < line.length; i += width) out.push(line.slice(i, i + width));
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * Pretty-print a JSON-ish value for terminal display and bound its cost.
82
+ *
83
+ * Splitting an object into short lines is itself a win: the TUI measures/wraps
84
+ * per line, so one huge line is the pathological case; a re-serialized object
85
+ * is many cheap lines even when it has more total chars.
86
+ *
87
+ * Bounds applied in order: parse+reindent → (non-JSON only) hard-wrap long
88
+ * lines → line cap (`… +N more`) → char cap. This shapes only the DISPLAY
89
+ * string; callers keep the untruncated payload for the model. The formatted
90
+ * JSON stays valid so callers can syntax-highlight it. Pure and host-agnostic.
91
+ */
92
+ export function formatJson(value: unknown, options: FormatJsonOptions = {}): string {
93
+ const { maxChars, maxLines = MAX_PREVIEW_LINES, wrapWidth = 0 } = options;
94
+
95
+ let text: string;
96
+ let isJson = true;
97
+ try {
98
+ const parsed = typeof value === "string" ? JSON.parse(value) : value;
99
+ text = JSON.stringify(parsed, null, 2);
100
+ } catch {
101
+ // Not JSON (or a circular object) — fall back to a plain string, still bounded.
102
+ text = typeof value === "string" ? value : String(value);
103
+ isJson = false;
104
+ }
105
+
106
+ let lines = text.split("\n");
107
+ // Only hard-wrap non-JSON: wrapping reindented JSON would split string values
108
+ // mid-token and invalidate the block for downstream highlighting. JSON is
109
+ // already short-lined after reindent, so it never needs this guard.
110
+ if (wrapWidth > 0 && !isJson) lines = lines.flatMap((line) => hardWrapLine(line, wrapWidth));
111
+
112
+ if (lines.length > maxLines) {
113
+ const hidden = lines.length - maxLines;
114
+ lines = [...lines.slice(0, maxLines), `… +${hidden} more`];
115
+ }
116
+
117
+ let out = lines.join("\n");
118
+ if (maxChars !== undefined && out.length > maxChars) {
119
+ // Char cap is a last-resort guard (e.g. many wrapped lines under the line
120
+ // cap still exceeding the budget).
121
+ out = `${out.slice(0, Math.max(0, maxChars - 1))}…`;
122
+ }
123
+ return out;
124
+ }
125
+
55
126
  export type CollapsedToolStatus = "success" | "error" | "warning";
56
127
 
57
128
  type CollapsedToolTheme = {
@@ -103,9 +174,16 @@ export function hideCollapsedToolCall(
103
174
 
104
175
  export type DimPreviewOptions = {
105
176
  maxLines?: number;
177
+ /** Header line shown above the body (or above the top rule when framed).
178
+ * Pass the tool's SEMANTIC count here (e.g. `pluralize(matchCount, "match")`)
179
+ * — the same value the collapsed summary row uses — so top and collapsed
180
+ * never disagree. Do not recount the body: notices/blank lines make a body
181
+ * line-count diverge from the semantic count. */
106
182
  header?: string;
107
183
  /** Pattern whose matches are highlighted (green bold) inside dim lines. */
108
184
  highlight?: string;
185
+ /** Wrap the body in a top/bottom rule frame (header above, overflow below), like bash/read/mcp. */
186
+ frame?: boolean;
109
187
  };
110
188
 
111
189
  function dimLineWithHighlight(line: string, theme: FgTheme, pattern?: string): string {
@@ -136,14 +214,25 @@ export function renderDimPreview(
136
214
  const highlight = opts.highlight;
137
215
  const output = normalizeLineEndings(text).trim() || "done";
138
216
  const lines = output.split("\n");
139
- const preview = lines
217
+ const body = lines
140
218
  .slice(0, maxLines)
141
219
  .map((line) => ` ${dimLineWithHighlight(line, theme, highlight)}`);
142
- if (opts.header) preview.unshift(` ${theme.fg("dim", opts.header)}`);
143
- if (lines.length > maxLines) {
144
- const more = pluralize(lines.length - maxLines, "more line");
145
- preview.push(` ${theme.fg("dim", `… ${more}`)}`);
220
+ const header = opts.header ? ` ${theme.fg("dim", opts.header)}` : undefined;
221
+ const overflow =
222
+ lines.length > maxLines
223
+ ? ` ${theme.fg("dim", `… ${pluralize(lines.length - maxLines, "more line")}`)}`
224
+ : undefined;
225
+
226
+ if (opts.frame) {
227
+ // Same layout as bash/read/mcp: header above the top rule, body between the
228
+ // rules, overflow footer below the bottom rule.
229
+ const out = [...(header ? [header] : []), ...ruleFrame(body, overflow ? [overflow] : [])];
230
+ return fillToolBackground(out.join("\n"));
146
231
  }
232
+
233
+ const preview = body;
234
+ if (header) preview.unshift(header);
235
+ if (overflow) preview.push(overflow);
147
236
  return fillToolBackground(preview.join("\n"));
148
237
  }
149
238
 
@@ -220,6 +309,21 @@ export function rule(w: number): string {
220
309
  return `${FG_RULE}${"─".repeat(w)}${RST}`;
221
310
  }
222
311
 
312
+ /**
313
+ * Frame tool output the way bash/read/sudo do: a top rule, the body lines, a
314
+ * bottom rule, then any footer lines (e.g. `… +N more`) below the close. The
315
+ * single source of the "rule top, rule bottom" invariant so every tool's result
316
+ * block is framed identically — MCP and the shell tools share this.
317
+ */
318
+ export function ruleFrame(
319
+ bodyLines: string[],
320
+ footerLines: string[] = [],
321
+ width?: number,
322
+ ): string[] {
323
+ const r = rule(width ?? termW());
324
+ return [r, ...bodyLines, r, ...footerLines];
325
+ }
326
+
223
327
  export function lnum(n: number, w: number): string {
224
328
  const v = String(n);
225
329
  return `${FG_LNUM}${" ".repeat(Math.max(0, w - v.length))}${v}${RST}`;