@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 +1 -1
- package/src/config.ts +9 -0
- package/src/highlight.test.ts +10 -0
- package/src/highlight.ts +8 -1
- package/src/utils.test.ts +86 -0
- package/src/utils.ts +109 -5
package/package.json
CHANGED
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);
|
package/src/highlight.test.ts
CHANGED
|
@@ -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
|
|
217
|
+
const body = lines
|
|
140
218
|
.slice(0, maxLines)
|
|
141
219
|
.map((line) => ` ${dimLineWithHighlight(line, theme, highlight)}`);
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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}`;
|