@xynogen/pix-pretty 1.3.1 → 1.3.2
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/fff.ts +0 -1
- package/src/renderers.ts +4 -102
- package/src/tools/find.ts +3 -19
- package/src/tools/grep.ts +11 -35
- package/src/tools/ls.ts +4 -9
- package/src/tools/multi-grep.ts +17 -35
- package/src/types.ts +0 -4
- package/src/utils.test.ts +93 -0
- package/src/utils.ts +61 -0
package/package.json
CHANGED
package/src/fff.ts
CHANGED
|
@@ -123,7 +123,6 @@ export class CursorStore {
|
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
125
|
* Convert FFF GrepResult items to ripgrep-style "file:line:content" text.
|
|
126
|
-
* This ensures pi-pretty's renderGrepResults works unchanged.
|
|
127
126
|
*/
|
|
128
127
|
export function fffFormatGrepText(items: GrepMatch[], limit: number): string {
|
|
129
128
|
const capped = items.slice(0, limit);
|
package/src/renderers.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { basename, dirname } from "node:path";
|
|
2
1
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
2
|
|
|
4
3
|
import {
|
|
@@ -15,7 +14,7 @@ import { MAX_PREVIEW_LINES } from "./config.js";
|
|
|
15
14
|
import { hlBlock } from "./highlight.js";
|
|
16
15
|
import { dirIcon, fileIcon } from "./icons.js";
|
|
17
16
|
import { lang } from "./lang.js";
|
|
18
|
-
import { lnum, normalizeLineEndings, rule, termW } from "./utils.js";
|
|
17
|
+
import { lnum, normalizeLineEndings, pluralize, rule, termW } from "./utils.js";
|
|
19
18
|
|
|
20
19
|
/** Render syntax-highlighted file content with line numbers. */
|
|
21
20
|
export async function renderFileContent(
|
|
@@ -51,7 +50,7 @@ export async function renderFileContent(
|
|
|
51
50
|
out.push(rule(tw));
|
|
52
51
|
if (total > maxLines) {
|
|
53
52
|
out.push(
|
|
54
|
-
`${FG_DIM} … ${total - maxLines
|
|
53
|
+
`${FG_DIM} … ${pluralize(total - maxLines, "more line")} (${total} total)${RST}`,
|
|
55
54
|
);
|
|
56
55
|
}
|
|
57
56
|
return out.join("\n");
|
|
@@ -77,7 +76,7 @@ export function renderBashOutput(
|
|
|
77
76
|
|
|
78
77
|
let body = show.join("\n");
|
|
79
78
|
if (remaining > 0) {
|
|
80
|
-
body += `\n${FG_DIM} … ${remaining
|
|
79
|
+
body += `\n${FG_DIM} … ${pluralize(remaining, "more line")}${RST}`;
|
|
81
80
|
}
|
|
82
81
|
|
|
83
82
|
return { summary: codeStr, body };
|
|
@@ -110,110 +109,13 @@ export function renderTree(text: string, _basePath: string): string {
|
|
|
110
109
|
|
|
111
110
|
if (total > MAX_PREVIEW_LINES) {
|
|
112
111
|
out.push(
|
|
113
|
-
`${FG_RULE}└── ${RST}${FG_DIM}… ${total - MAX_PREVIEW_LINES
|
|
112
|
+
`${FG_RULE}└── ${RST}${FG_DIM}… ${pluralize(total - MAX_PREVIEW_LINES, "more entry", "more entries")}${RST}`,
|
|
114
113
|
);
|
|
115
114
|
}
|
|
116
115
|
|
|
117
116
|
return out.join("\n");
|
|
118
117
|
}
|
|
119
118
|
|
|
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
119
|
// ---------------------------------------------------------------------------
|
|
218
120
|
// FFF integration (optional) — Fast File Finder with frecency & SIMD search
|
|
219
121
|
//
|
package/src/tools/find.ts
CHANGED
|
@@ -4,8 +4,6 @@ import type {
|
|
|
4
4
|
ToolRenderResultOptions,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
|
-
import { FG_DIM, RST } from "../ansi.js";
|
|
8
|
-
import { renderFindResults } from "../renderers.js";
|
|
9
7
|
import type {
|
|
10
8
|
FindParams,
|
|
11
9
|
FindResultDetails,
|
|
@@ -19,8 +17,8 @@ import {
|
|
|
19
17
|
appendNotices,
|
|
20
18
|
fillToolBackground,
|
|
21
19
|
getTextContent,
|
|
22
|
-
isTextContent,
|
|
23
20
|
makeTextResult,
|
|
21
|
+
renderDimPreview,
|
|
24
22
|
renderToolError,
|
|
25
23
|
setResultDetails,
|
|
26
24
|
} from "../utils.js";
|
|
@@ -137,22 +135,8 @@ export function registerFindTool(
|
|
|
137
135
|
return text;
|
|
138
136
|
}
|
|
139
137
|
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
const rendered = renderFindResults(d.text);
|
|
143
|
-
const info = `${FG_DIM}${d.matchCount} files${RST}`;
|
|
144
|
-
text.setText(fillToolBackground(` ${info}\n${rendered}`));
|
|
145
|
-
return text;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const fallback = result.content?.[0];
|
|
149
|
-
const fallbackText =
|
|
150
|
-
fallback && isTextContent(fallback) ? fallback.text : "found";
|
|
151
|
-
text.setText(
|
|
152
|
-
fillToolBackground(
|
|
153
|
-
` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
|
|
154
|
-
),
|
|
155
|
-
);
|
|
138
|
+
const output = getTextContent(result) || "found";
|
|
139
|
+
text.setText(renderDimPreview(output, theme));
|
|
156
140
|
return text;
|
|
157
141
|
},
|
|
158
142
|
});
|
package/src/tools/grep.ts
CHANGED
|
@@ -4,12 +4,9 @@ import type {
|
|
|
4
4
|
ToolRenderResultOptions,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
|
-
import { FG_DIM, RST } from "../ansi.js";
|
|
8
7
|
import { fffFormatGrepText } from "../fff.js";
|
|
9
|
-
import { renderGrepResults } from "../renderers.js";
|
|
10
8
|
import type {
|
|
11
9
|
GrepParams,
|
|
12
|
-
GrepRenderState,
|
|
13
10
|
GrepResultDetails,
|
|
14
11
|
PiPrettyApi,
|
|
15
12
|
RenderContextLike,
|
|
@@ -25,9 +22,10 @@ import {
|
|
|
25
22
|
isTextContent,
|
|
26
23
|
makeTextResult,
|
|
27
24
|
normalizeLineEndings,
|
|
25
|
+
pluralize,
|
|
26
|
+
renderDimPreview,
|
|
28
27
|
renderToolError,
|
|
29
28
|
setResultDetails,
|
|
30
|
-
termW,
|
|
31
29
|
} from "../utils.js";
|
|
32
30
|
import type { ToolContext } from "./context.js";
|
|
33
31
|
|
|
@@ -155,7 +153,7 @@ export function registerGrepTool(
|
|
|
155
153
|
result: ToolResultLike<GrepResultDetails>,
|
|
156
154
|
_opt: ToolRenderResultOptions,
|
|
157
155
|
theme: ThemeLike,
|
|
158
|
-
renderCtx: RenderContextLike
|
|
156
|
+
renderCtx: RenderContextLike,
|
|
159
157
|
) {
|
|
160
158
|
const text = renderCtx.lastComponent ?? new TextComponent("", 0, 0);
|
|
161
159
|
|
|
@@ -165,37 +163,15 @@ export function registerGrepTool(
|
|
|
165
163
|
}
|
|
166
164
|
|
|
167
165
|
const d = result.details;
|
|
168
|
-
|
|
169
|
-
const key = `grep:${d.pattern}:${d.matchCount}:${termW()}`;
|
|
170
|
-
if (renderCtx.state._gk !== key) {
|
|
171
|
-
renderCtx.state._gk = key;
|
|
172
|
-
const info = `${FG_DIM}${d.matchCount} matches${RST}`;
|
|
173
|
-
renderCtx.state._gt = fillToolBackground(` ${info}`);
|
|
174
|
-
|
|
175
|
-
renderGrepResults(d.text, d.pattern)
|
|
176
|
-
.then((rendered: string) => {
|
|
177
|
-
if (renderCtx.state._gk !== key) return;
|
|
178
|
-
renderCtx.state._gt = fillToolBackground(
|
|
179
|
-
` ${info}\n${rendered}`,
|
|
180
|
-
);
|
|
181
|
-
renderCtx.invalidate();
|
|
182
|
-
})
|
|
183
|
-
.catch(() => {});
|
|
184
|
-
}
|
|
185
|
-
text.setText(
|
|
186
|
-
renderCtx.state._gt ??
|
|
187
|
-
fillToolBackground(` ${FG_DIM}${d.matchCount} matches${RST}`),
|
|
188
|
-
);
|
|
189
|
-
return text;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const fallback = result.content?.[0];
|
|
193
|
-
const fallbackText =
|
|
194
|
-
fallback && isTextContent(fallback) ? fallback.text : "searched";
|
|
166
|
+
const output = getTextContent(result) || "searched";
|
|
195
167
|
text.setText(
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
168
|
+
renderDimPreview(output, theme, {
|
|
169
|
+
header:
|
|
170
|
+
d?._type === "grepResult"
|
|
171
|
+
? pluralize(d.matchCount, "match", "matches")
|
|
172
|
+
: undefined,
|
|
173
|
+
highlight: d?._type === "grepResult" ? d.pattern : undefined,
|
|
174
|
+
}),
|
|
199
175
|
);
|
|
200
176
|
return text;
|
|
201
177
|
},
|
package/src/tools/ls.ts
CHANGED
|
@@ -4,8 +4,6 @@ import type {
|
|
|
4
4
|
LsToolInput,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
|
-
import { FG_DIM, RST } from "../ansi.js";
|
|
8
|
-
import { renderTree } from "../renderers.js";
|
|
9
7
|
import type {
|
|
10
8
|
LsParams,
|
|
11
9
|
PiPrettyApi,
|
|
@@ -14,10 +12,11 @@ import type {
|
|
|
14
12
|
ToolFactory,
|
|
15
13
|
ToolResultLike,
|
|
16
14
|
} from "../types.js";
|
|
15
|
+
import { FG_DIM, RST } from "../ansi.js";
|
|
16
|
+
import { renderTree } from "../renderers.js";
|
|
17
17
|
import {
|
|
18
18
|
fillToolBackground,
|
|
19
19
|
getTextContent,
|
|
20
|
-
isTextContent,
|
|
21
20
|
renderToolError,
|
|
22
21
|
setResultDetails,
|
|
23
22
|
} from "../utils.js";
|
|
@@ -98,13 +97,9 @@ export function registerLsTool(
|
|
|
98
97
|
return text;
|
|
99
98
|
}
|
|
100
99
|
|
|
101
|
-
const
|
|
102
|
-
const fallbackText =
|
|
103
|
-
fallback && isTextContent(fallback) ? fallback.text : "listed";
|
|
100
|
+
const output = getTextContent(result) || "listed";
|
|
104
101
|
text.setText(
|
|
105
|
-
fillToolBackground(
|
|
106
|
-
` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
|
|
107
|
-
),
|
|
102
|
+
fillToolBackground(` ${theme.fg("dim", output.slice(0, 120))}`),
|
|
108
103
|
);
|
|
109
104
|
return text;
|
|
110
105
|
},
|
package/src/tools/multi-grep.ts
CHANGED
|
@@ -4,13 +4,10 @@ import type {
|
|
|
4
4
|
ToolRenderResultOptions,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
|
-
import { FG_DIM, RST } from "../ansi.js";
|
|
8
7
|
import { fffFormatGrepText } from "../fff.js";
|
|
9
|
-
import { renderGrepResults } from "../renderers.js";
|
|
10
8
|
import type {
|
|
11
9
|
GrepResultDetails,
|
|
12
10
|
MultiGrepParams,
|
|
13
|
-
MultiGrepRenderState,
|
|
14
11
|
PiPrettyApi,
|
|
15
12
|
RenderContextLike,
|
|
16
13
|
ThemeLike,
|
|
@@ -21,14 +18,16 @@ import {
|
|
|
21
18
|
appendNotices,
|
|
22
19
|
buildLiteralAlternationPattern,
|
|
23
20
|
countRipgrepMatches,
|
|
21
|
+
fillToolBackground,
|
|
24
22
|
getConstraintBackedPath,
|
|
25
23
|
getErrorMessage,
|
|
26
24
|
getTextContent,
|
|
27
|
-
isTextContent,
|
|
28
25
|
makeTextResult,
|
|
29
26
|
normalizeLineEndings,
|
|
27
|
+
pluralize,
|
|
28
|
+
renderDimPreview,
|
|
29
|
+
renderToolError,
|
|
30
30
|
shouldIgnoreCaseForPatterns,
|
|
31
|
-
termW,
|
|
32
31
|
trimToUndefined,
|
|
33
32
|
} from "../utils.js";
|
|
34
33
|
import type { ToolContext } from "./context.js";
|
|
@@ -278,7 +277,7 @@ export function registerMultiGrepTool(
|
|
|
278
277
|
theme.fg("accent", patterns.map((p) => `"${p}"`).join(", "));
|
|
279
278
|
content += path;
|
|
280
279
|
if (constraints) content += theme.fg("muted", ` (${constraints})`);
|
|
281
|
-
text.setText(content);
|
|
280
|
+
text.setText(fillToolBackground(content));
|
|
282
281
|
return text;
|
|
283
282
|
},
|
|
284
283
|
|
|
@@ -286,43 +285,26 @@ export function registerMultiGrepTool(
|
|
|
286
285
|
result: ToolResultLike<GrepResultDetails | { error?: string }>,
|
|
287
286
|
_opt: ToolRenderResultOptions,
|
|
288
287
|
theme: ThemeLike,
|
|
289
|
-
renderCtx: RenderContextLike
|
|
288
|
+
renderCtx: RenderContextLike,
|
|
290
289
|
) {
|
|
291
290
|
const text = renderCtx.lastComponent ?? new TextComponent("", 0, 0);
|
|
292
291
|
|
|
293
292
|
if (renderCtx.isError) {
|
|
294
|
-
text.setText(
|
|
295
|
-
`\n${theme.fg("error", getTextContent(result) || "Error")}`,
|
|
296
|
-
);
|
|
293
|
+
text.setText(renderToolError(getTextContent(result) || "Error", theme));
|
|
297
294
|
return text;
|
|
298
295
|
}
|
|
299
296
|
|
|
300
297
|
const d = result.details;
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
renderCtx.state._mgt = ` ${info}\n${rendered}`;
|
|
312
|
-
renderCtx.invalidate();
|
|
313
|
-
})
|
|
314
|
-
.catch(() => {});
|
|
315
|
-
}
|
|
316
|
-
text.setText(
|
|
317
|
-
renderCtx.state._mgt ?? ` ${FG_DIM}${d.matchCount} matches${RST}`,
|
|
318
|
-
);
|
|
319
|
-
return text;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const fallback = result.content?.[0];
|
|
323
|
-
const fallbackText =
|
|
324
|
-
fallback && isTextContent(fallback) ? fallback.text : "searched";
|
|
325
|
-
text.setText(` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`);
|
|
298
|
+
const isGrep = d && "_type" in d && d._type === "grepResult";
|
|
299
|
+
const output = getTextContent(result) || "searched";
|
|
300
|
+
text.setText(
|
|
301
|
+
renderDimPreview(output, theme, {
|
|
302
|
+
header: isGrep
|
|
303
|
+
? pluralize(d.matchCount, "match", "matches")
|
|
304
|
+
: undefined,
|
|
305
|
+
highlight: isGrep ? d.pattern : undefined,
|
|
306
|
+
}),
|
|
307
|
+
);
|
|
326
308
|
return text;
|
|
327
309
|
},
|
|
328
310
|
});
|
package/src/types.ts
CHANGED
|
@@ -211,8 +211,6 @@ export type MultiGrepParams = {
|
|
|
211
211
|
limit?: number;
|
|
212
212
|
};
|
|
213
213
|
|
|
214
|
-
export type GrepRenderState = { _gk?: string; _gt?: string };
|
|
215
|
-
|
|
216
214
|
export type EditRenderState = {
|
|
217
215
|
_pk?: string;
|
|
218
216
|
_pt?: string;
|
|
@@ -229,8 +227,6 @@ export type WriteRenderState = {
|
|
|
229
227
|
_nft?: string;
|
|
230
228
|
};
|
|
231
229
|
|
|
232
|
-
export type MultiGrepRenderState = { _mgk?: string; _mgt?: string };
|
|
233
|
-
|
|
234
230
|
export type FindResultDetails = {
|
|
235
231
|
_type: "findResult";
|
|
236
232
|
text: string;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { MAX_PREVIEW_LINES } from "./config.js";
|
|
4
|
+
import type { FgTheme } from "./types.js";
|
|
5
|
+
import { pluralize, renderDimPreview } from "./utils.js";
|
|
6
|
+
|
|
7
|
+
// Strip ANSI escapes so assertions test content, not color codes.
|
|
8
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
9
|
+
function plain(text: string): string {
|
|
10
|
+
return text.replace(ANSI, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Minimal theme: fg() passes text through untouched.
|
|
14
|
+
const theme: FgTheme = { fg: (_key, text) => text };
|
|
15
|
+
|
|
16
|
+
describe("pluralize", () => {
|
|
17
|
+
it("uses singular for count of 1", () => {
|
|
18
|
+
expect(pluralize(1, "match", "matches")).toBe("1 match");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("uses plural for count != 1", () => {
|
|
22
|
+
expect(pluralize(0, "match", "matches")).toBe("0 matches");
|
|
23
|
+
expect(pluralize(2, "match", "matches")).toBe("2 matches");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("defaults plural to noun + s", () => {
|
|
27
|
+
expect(pluralize(1, "line")).toBe("1 line");
|
|
28
|
+
expect(pluralize(3, "line")).toBe("3 lines");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("renderDimPreview", () => {
|
|
33
|
+
it("renders 'done' for empty input", () => {
|
|
34
|
+
expect(plain(renderDimPreview("", theme))).toContain("done");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("shows every line when under the cap", () => {
|
|
38
|
+
const out = plain(renderDimPreview("a\nb\nc", theme));
|
|
39
|
+
expect(out).toContain("a");
|
|
40
|
+
expect(out).toContain("b");
|
|
41
|
+
expect(out).toContain("c");
|
|
42
|
+
expect(out).not.toContain("more line");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("does not add overflow marker at exactly the cap", () => {
|
|
46
|
+
const body = Array.from({ length: MAX_PREVIEW_LINES }, (_, i) => `L${i}`);
|
|
47
|
+
const out = plain(renderDimPreview(body.join("\n"), theme));
|
|
48
|
+
expect(out).not.toContain("more line");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("adds singular overflow marker for 1 extra line", () => {
|
|
52
|
+
const body = Array.from(
|
|
53
|
+
{ length: MAX_PREVIEW_LINES + 1 },
|
|
54
|
+
(_, i) => `L${i}`,
|
|
55
|
+
);
|
|
56
|
+
const out = plain(renderDimPreview(body.join("\n"), theme));
|
|
57
|
+
expect(out).toContain("… 1 more line");
|
|
58
|
+
expect(out).not.toContain("more lines");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("adds plural overflow marker for many extra lines", () => {
|
|
62
|
+
const body = Array.from(
|
|
63
|
+
{ length: MAX_PREVIEW_LINES + 3 },
|
|
64
|
+
(_, i) => `L${i}`,
|
|
65
|
+
);
|
|
66
|
+
const out = plain(renderDimPreview(body.join("\n"), theme));
|
|
67
|
+
expect(out).toContain("… 3 more lines");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("respects a custom maxLines", () => {
|
|
71
|
+
const out = plain(renderDimPreview("a\nb\nc\nd", theme, { maxLines: 2 }));
|
|
72
|
+
expect(out).toContain("… 2 more lines");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("prepends a header line when given", () => {
|
|
76
|
+
const out = plain(renderDimPreview("body", theme, { header: "5 matches" }));
|
|
77
|
+
expect(out).toContain("5 matches");
|
|
78
|
+
expect(out).toContain("body");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("highlights matched keyword with non-dim styling", () => {
|
|
82
|
+
const raw = renderDimPreview("foo bar foo", theme, { highlight: "foo" });
|
|
83
|
+
// matched 'foo' wrapped in yellow/bold ANSI (not produced by stub fg)
|
|
84
|
+
expect(raw).toContain("\x1b[");
|
|
85
|
+
expect(plain(raw)).toContain("foo bar foo");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("does not throw on an invalid highlight regex", () => {
|
|
89
|
+
expect(() =>
|
|
90
|
+
renderDimPreview("text", theme, { highlight: "(" }),
|
|
91
|
+
).not.toThrow();
|
|
92
|
+
});
|
|
93
|
+
});
|
package/src/utils.ts
CHANGED
|
@@ -5,10 +5,13 @@ import {
|
|
|
5
5
|
ANSI_CAPTURE_RE,
|
|
6
6
|
BG_BASE,
|
|
7
7
|
BG_ERROR,
|
|
8
|
+
BOLD,
|
|
8
9
|
FG_LNUM,
|
|
9
10
|
FG_RULE,
|
|
11
|
+
FG_YELLOW,
|
|
10
12
|
RST,
|
|
11
13
|
} from "./ansi.js";
|
|
14
|
+
import { MAX_PREVIEW_LINES } from "./config.js";
|
|
12
15
|
import type {
|
|
13
16
|
FgTheme,
|
|
14
17
|
ToolContent,
|
|
@@ -48,6 +51,64 @@ export function fillToolBackground(text: string, bg = BG_BASE): string {
|
|
|
48
51
|
.join("\n");
|
|
49
52
|
}
|
|
50
53
|
|
|
54
|
+
export function pluralize(
|
|
55
|
+
count: number,
|
|
56
|
+
noun: string,
|
|
57
|
+
plural?: string,
|
|
58
|
+
): string {
|
|
59
|
+
return `${count} ${count === 1 ? noun : (plural ?? `${noun}s`)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type DimPreviewOptions = {
|
|
63
|
+
maxLines?: number;
|
|
64
|
+
header?: string;
|
|
65
|
+
/** Pattern whose matches are highlighted (yellow bold) inside dim lines. */
|
|
66
|
+
highlight?: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function safeHighlightRegex(pattern: string): RegExp | null {
|
|
70
|
+
try {
|
|
71
|
+
return new RegExp(`(${pattern})`, "gi");
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function dimLineWithHighlight(
|
|
78
|
+
line: string,
|
|
79
|
+
theme: FgTheme,
|
|
80
|
+
re: RegExp | null,
|
|
81
|
+
): string {
|
|
82
|
+
if (!re) return theme.fg("dim", line);
|
|
83
|
+
// split with capture group: odd indexes are matches
|
|
84
|
+
return line
|
|
85
|
+
.split(re)
|
|
86
|
+
.map((part, i) =>
|
|
87
|
+
i % 2 ? `${FG_YELLOW}${BOLD}${part}${RST}` : theme.fg("dim", part),
|
|
88
|
+
)
|
|
89
|
+
.join("");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function renderDimPreview(
|
|
93
|
+
text: string,
|
|
94
|
+
theme: FgTheme,
|
|
95
|
+
opts: DimPreviewOptions = {},
|
|
96
|
+
): string {
|
|
97
|
+
const maxLines = opts.maxLines ?? MAX_PREVIEW_LINES;
|
|
98
|
+
const re = opts.highlight ? safeHighlightRegex(opts.highlight) : null;
|
|
99
|
+
const output = normalizeLineEndings(text).trim() || "done";
|
|
100
|
+
const lines = output.split("\n");
|
|
101
|
+
const preview = lines
|
|
102
|
+
.slice(0, maxLines)
|
|
103
|
+
.map((line) => ` ${dimLineWithHighlight(line, theme, re)}`);
|
|
104
|
+
if (opts.header) preview.unshift(` ${theme.fg("dim", opts.header)}`);
|
|
105
|
+
if (lines.length > maxLines) {
|
|
106
|
+
const more = pluralize(lines.length - maxLines, "more line");
|
|
107
|
+
preview.push(` ${theme.fg("dim", `… ${more}`)}`);
|
|
108
|
+
}
|
|
109
|
+
return fillToolBackground(preview.join("\n"));
|
|
110
|
+
}
|
|
111
|
+
|
|
51
112
|
let _cachedTermW: number | undefined;
|
|
52
113
|
let _termWResizeBound = false;
|
|
53
114
|
|