@heyhuynhgiabuu/pi-pretty 0.4.1 → 0.4.3
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/README.md +1 -1
- package/package.json +1 -1
- package/release-notes/v0.4.2.md +36 -0
- package/src/fff-helpers.ts +16 -4
- package/src/index.ts +225 -130
- package/src/multi-grep-fallback.ts +248 -0
- package/test/bash-rendering.test.ts +109 -0
- package/test/fff-integration.test.ts +284 -9
- package/test/image-rendering.test.ts +7 -25
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export type ConstraintParseResult = { ok: true; globs: string[]; tokens: string[] } | { ok: false; error: string };
|
|
4
|
+
|
|
5
|
+
export type MultiGrepRipgrepFallbackParams = {
|
|
6
|
+
cwd: string;
|
|
7
|
+
patterns: string[];
|
|
8
|
+
path?: string;
|
|
9
|
+
constraints?: string;
|
|
10
|
+
context?: number;
|
|
11
|
+
limit: number;
|
|
12
|
+
ignoreCase: boolean;
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type MultiGrepRipgrepFallbackResult = {
|
|
17
|
+
text: string;
|
|
18
|
+
matchCount: number;
|
|
19
|
+
limitReached: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type MultiGrepRipgrepFallback = (
|
|
23
|
+
params: MultiGrepRipgrepFallbackParams,
|
|
24
|
+
) => Promise<MultiGrepRipgrepFallbackResult>;
|
|
25
|
+
|
|
26
|
+
const GLOB_META_RE = /[*?[{]/;
|
|
27
|
+
|
|
28
|
+
function trimSlashes(value: string): string {
|
|
29
|
+
return value.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeConstraintPath(value: string): string {
|
|
33
|
+
let normalized = value.replace(/\\/g, "/").trim();
|
|
34
|
+
while (normalized.startsWith("./")) normalized = normalized.slice(2);
|
|
35
|
+
return normalized;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function tokenizeConstraints(constraints: string): ConstraintParseResult {
|
|
39
|
+
const tokens: string[] = [];
|
|
40
|
+
let current = "";
|
|
41
|
+
let quote: '"' | "'" | null = null;
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < constraints.length; i++) {
|
|
44
|
+
const char = constraints[i];
|
|
45
|
+
if (quote) {
|
|
46
|
+
if (char === quote) quote = null;
|
|
47
|
+
else current += char;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (char === '"' || char === "'") {
|
|
52
|
+
quote = char;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (/\s/.test(char)) {
|
|
57
|
+
if (current) {
|
|
58
|
+
tokens.push(current);
|
|
59
|
+
current = "";
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
current += char;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (quote) return { ok: false, error: "unterminated quoted constraint" };
|
|
68
|
+
if (current) tokens.push(current);
|
|
69
|
+
|
|
70
|
+
return { ok: true, globs: [], tokens };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function tokenToRipgrepGlob(token: string): { ok: true; glob: string } | { ok: false; error: string } {
|
|
74
|
+
let negated = false;
|
|
75
|
+
let body = token;
|
|
76
|
+
|
|
77
|
+
if (body.startsWith("!")) {
|
|
78
|
+
negated = true;
|
|
79
|
+
body = body.slice(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
body = normalizeConstraintPath(body);
|
|
83
|
+
if (!body) return { ok: false, error: `empty constraint token: ${token}` };
|
|
84
|
+
if (body.includes("\0")) return { ok: false, error: `invalid NUL byte in constraint token: ${token}` };
|
|
85
|
+
|
|
86
|
+
let glob: string;
|
|
87
|
+
if (body.endsWith("/")) {
|
|
88
|
+
const dir = trimSlashes(body);
|
|
89
|
+
if (!dir) return { ok: false, error: `empty directory constraint: ${token}` };
|
|
90
|
+
glob = `**/${dir}/**`;
|
|
91
|
+
} else if (GLOB_META_RE.test(body) || body.includes("/")) {
|
|
92
|
+
glob = body.replace(/^\/+/, "");
|
|
93
|
+
} else if (body.includes(".")) {
|
|
94
|
+
glob = `**/${body}`;
|
|
95
|
+
} else {
|
|
96
|
+
glob = `**/${body}/**`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { ok: true, glob: negated ? `!${glob}` : glob };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function parseMultiGrepConstraints(constraints: string | undefined): ConstraintParseResult {
|
|
103
|
+
const trimmed = constraints?.trim();
|
|
104
|
+
if (!trimmed) return { ok: true, globs: [], tokens: [] };
|
|
105
|
+
|
|
106
|
+
const tokenized = tokenizeConstraints(trimmed);
|
|
107
|
+
if (!tokenized.ok) return tokenized;
|
|
108
|
+
|
|
109
|
+
const globs: string[] = [];
|
|
110
|
+
for (const token of tokenized.tokens) {
|
|
111
|
+
const parsed = tokenToRipgrepGlob(token);
|
|
112
|
+
if (!parsed.ok) return parsed;
|
|
113
|
+
globs.push(parsed.glob);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { ok: true, globs, tokens: tokenized.tokens };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isRipgrepMatchLine(line: string): boolean {
|
|
120
|
+
return /^.+?:\d+:/.test(line);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function buildRipgrepArgs(params: MultiGrepRipgrepFallbackParams, globs: string[]): string[] {
|
|
124
|
+
const args = ["--line-number", "--with-filename", "--color=never", "--hidden", "--fixed-strings"];
|
|
125
|
+
|
|
126
|
+
if (params.ignoreCase) args.push("--ignore-case");
|
|
127
|
+
if (params.context && params.context > 0) args.push("--context", String(params.context));
|
|
128
|
+
|
|
129
|
+
for (const glob of globs) args.push("--glob", glob);
|
|
130
|
+
for (const pattern of params.patterns) args.push("-e", pattern);
|
|
131
|
+
|
|
132
|
+
const searchPath = params.path?.trim();
|
|
133
|
+
if (searchPath) args.push("--", searchPath);
|
|
134
|
+
|
|
135
|
+
return args;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function getMultiGrepRipgrepArgs(
|
|
139
|
+
params: MultiGrepRipgrepFallbackParams,
|
|
140
|
+
): ConstraintParseResult & { args?: string[] } {
|
|
141
|
+
const parsed = parseMultiGrepConstraints(params.constraints);
|
|
142
|
+
if (!parsed.ok) return parsed;
|
|
143
|
+
return { ...parsed, args: buildRipgrepArgs(params, parsed.globs) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function runMultiGrepRipgrepFallback(
|
|
147
|
+
params: MultiGrepRipgrepFallbackParams,
|
|
148
|
+
): Promise<MultiGrepRipgrepFallbackResult> {
|
|
149
|
+
const parsed = parseMultiGrepConstraints(params.constraints);
|
|
150
|
+
if (!parsed.ok) throw new Error(`unsupported constraints: ${parsed.error}`);
|
|
151
|
+
|
|
152
|
+
const args = buildRipgrepArgs(params, parsed.globs);
|
|
153
|
+
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
if (params.signal?.aborted) {
|
|
156
|
+
reject(new Error("Operation aborted"));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const child = spawn("rg", args, { cwd: params.cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
161
|
+
const outputLines: string[] = [];
|
|
162
|
+
let stderr = "";
|
|
163
|
+
let buffer = "";
|
|
164
|
+
let matchCount = 0;
|
|
165
|
+
let limitReached = false;
|
|
166
|
+
let killedForLimit = false;
|
|
167
|
+
let settled = false;
|
|
168
|
+
|
|
169
|
+
const settle = (fn: () => void): void => {
|
|
170
|
+
if (settled) return;
|
|
171
|
+
settled = true;
|
|
172
|
+
fn();
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const stopChild = (dueToLimit = false): void => {
|
|
176
|
+
if (!child.killed) {
|
|
177
|
+
killedForLimit = dueToLimit;
|
|
178
|
+
child.kill();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const onAbort = (): void => stopChild(false);
|
|
183
|
+
params.signal?.addEventListener("abort", onAbort, { once: true });
|
|
184
|
+
|
|
185
|
+
const cleanup = (): void => {
|
|
186
|
+
params.signal?.removeEventListener("abort", onAbort);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const handleLine = (line: string): void => {
|
|
190
|
+
if (limitReached) return;
|
|
191
|
+
outputLines.push(line);
|
|
192
|
+
if (isRipgrepMatchLine(line)) {
|
|
193
|
+
matchCount++;
|
|
194
|
+
if (matchCount >= params.limit) {
|
|
195
|
+
limitReached = true;
|
|
196
|
+
stopChild(true);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
202
|
+
buffer += chunk.toString("utf8");
|
|
203
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
204
|
+
while (newlineIndex >= 0) {
|
|
205
|
+
const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
|
|
206
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
207
|
+
handleLine(line);
|
|
208
|
+
newlineIndex = buffer.indexOf("\n");
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
213
|
+
stderr += chunk.toString("utf8");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
child.on("error", (error: NodeJS.ErrnoException) => {
|
|
217
|
+
cleanup();
|
|
218
|
+
const message =
|
|
219
|
+
error.code === "ENOENT" ? "ripgrep (rg) is not available" : `Failed to run ripgrep: ${error.message}`;
|
|
220
|
+
settle(() => reject(new Error(message)));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
child.on("close", (code) => {
|
|
224
|
+
cleanup();
|
|
225
|
+
|
|
226
|
+
if (params.signal?.aborted) {
|
|
227
|
+
settle(() => reject(new Error("Operation aborted")));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (buffer && !limitReached) handleLine(buffer.replace(/\r$/, ""));
|
|
232
|
+
|
|
233
|
+
if (!killedForLimit && code !== 0 && code !== 1) {
|
|
234
|
+
const message = stderr.trim() || `ripgrep exited with code ${code}`;
|
|
235
|
+
settle(() => reject(new Error(message)));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
settle(() =>
|
|
240
|
+
resolve({
|
|
241
|
+
text: outputLines.length ? outputLines.join("\n") : "No matches found",
|
|
242
|
+
matchCount,
|
|
243
|
+
limitReached,
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import piPrettyExtension from "../src/index.js";
|
|
4
|
+
|
|
5
|
+
class MockText {
|
|
6
|
+
private text = "";
|
|
7
|
+
constructor(_text = "", _x = 0, _y = 0) {}
|
|
8
|
+
setText(value: string) {
|
|
9
|
+
this.text = value;
|
|
10
|
+
}
|
|
11
|
+
getText() {
|
|
12
|
+
return this.text;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const mockTheme = {
|
|
17
|
+
fg: (_key: string, text: string) => text,
|
|
18
|
+
bold: (text: string) => text,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function mockToolFactory(exec: any) {
|
|
22
|
+
return (_cwd: string) => ({
|
|
23
|
+
name: "mock",
|
|
24
|
+
description: "mock",
|
|
25
|
+
parameters: { type: "object", properties: {} },
|
|
26
|
+
execute: exec,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadBashTool() {
|
|
31
|
+
const noopExec = async () => ({ content: [{ type: "text", text: "" }] });
|
|
32
|
+
const tools = new Map<string, any>();
|
|
33
|
+
const pi = {
|
|
34
|
+
registerTool: (tool: any) => tools.set(tool.name, tool),
|
|
35
|
+
registerCommand: () => {},
|
|
36
|
+
on: () => {},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
piPrettyExtension(pi, {
|
|
40
|
+
sdk: {
|
|
41
|
+
createReadToolDefinition: mockToolFactory(noopExec),
|
|
42
|
+
createBashToolDefinition: mockToolFactory(noopExec),
|
|
43
|
+
createLsToolDefinition: mockToolFactory(noopExec),
|
|
44
|
+
createFindToolDefinition: mockToolFactory(noopExec),
|
|
45
|
+
createGrepToolDefinition: mockToolFactory(noopExec),
|
|
46
|
+
getAgentDir: () => "/tmp/pi-pretty-test",
|
|
47
|
+
},
|
|
48
|
+
TextComponent: MockText,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return tools.get("bash");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("bash renderCall expansion", () => {
|
|
55
|
+
it("truncates long commands when collapsed", () => {
|
|
56
|
+
const bashTool = loadBashTool();
|
|
57
|
+
const command = `printf '${"x".repeat(120)}'`;
|
|
58
|
+
|
|
59
|
+
const rendered = bashTool.renderCall({ command }, mockTheme, {
|
|
60
|
+
lastComponent: new MockText(),
|
|
61
|
+
isError: false,
|
|
62
|
+
state: {},
|
|
63
|
+
expanded: false,
|
|
64
|
+
invalidate: () => {},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(rendered.getText()).toContain("bash");
|
|
68
|
+
expect(rendered.getText()).toContain("…");
|
|
69
|
+
expect(rendered.getText()).not.toContain(command);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("shows the full command when expanded", () => {
|
|
73
|
+
const bashTool = loadBashTool();
|
|
74
|
+
const command = `printf '${"x".repeat(120)}'`;
|
|
75
|
+
|
|
76
|
+
const rendered = bashTool.renderCall({ command }, mockTheme, {
|
|
77
|
+
lastComponent: new MockText(),
|
|
78
|
+
isError: false,
|
|
79
|
+
state: {},
|
|
80
|
+
expanded: true,
|
|
81
|
+
invalidate: () => {},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(rendered.getText()).toContain(command);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("preserves timeout text in both collapsed and expanded states", () => {
|
|
88
|
+
const bashTool = loadBashTool();
|
|
89
|
+
const command = `printf '${"x".repeat(120)}'`;
|
|
90
|
+
|
|
91
|
+
const collapsed = bashTool.renderCall({ command, timeout: 5 }, mockTheme, {
|
|
92
|
+
lastComponent: new MockText(),
|
|
93
|
+
isError: false,
|
|
94
|
+
state: {},
|
|
95
|
+
expanded: false,
|
|
96
|
+
invalidate: () => {},
|
|
97
|
+
});
|
|
98
|
+
const expanded = bashTool.renderCall({ command, timeout: 5 }, mockTheme, {
|
|
99
|
+
lastComponent: new MockText(),
|
|
100
|
+
isError: false,
|
|
101
|
+
state: {},
|
|
102
|
+
expanded: true,
|
|
103
|
+
invalidate: () => {},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(collapsed.getText()).toContain("5s timeout");
|
|
107
|
+
expect(expanded.getText()).toContain("5s timeout");
|
|
108
|
+
});
|
|
109
|
+
});
|