@heyhuynhgiabuu/pi-pretty 0.5.2 → 0.6.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/README.md +21 -0
- package/bun.lock +598 -0
- package/package.json +6 -8
- package/pi-pretty.example.json +6 -0
- package/release-notes/v0.5.3.md +29 -0
- package/src/config.ts +250 -0
- package/src/fff.ts +147 -0
- package/src/helpers.ts +124 -0
- package/src/image.ts +129 -0
- package/src/index.ts +157 -1980
- package/src/render.ts +402 -0
- package/src/tools/bash.ts +115 -0
- package/src/tools/find.ts +87 -0
- package/src/tools/grep.ts +99 -0
- package/src/tools/ls.ts +66 -0
- package/src/tools/metrics.ts +40 -0
- package/src/tools/multi-grep.ts +171 -0
- package/src/tools/read.ts +112 -0
- package/src/types.ts +227 -0
- package/test/bash-rendering.test.ts +104 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/* pi-pretty: grep tool -- FFF-backed text search with SDK fallback. */
|
|
2
|
+
|
|
3
|
+
import { type ToolDefinition, type ExtensionAPI, type ExtensionContext, type AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { SdkToolDef, GrepDetails, FffServiceWithCursor, TextContent, ThemeLike, RenderCtxLike } from "../types.js";
|
|
5
|
+
import { resolveBaseBackground, MAX_PREVIEW_LINES, BG_BASE, BG_ERROR, FG_DIM, RST } from "../config.js";
|
|
6
|
+
import { shortPath, normalizeLineEndings } from "../helpers.js";
|
|
7
|
+
import { wrapExecuteWithMetrics } from "./metrics.js";
|
|
8
|
+
import { renderToolError, renderToolMetrics, fillToolBackground } from "../render.js";
|
|
9
|
+
import { fffFormatGrepText } from "../fff-helpers.js";
|
|
10
|
+
|
|
11
|
+
type Result = AgentToolResult<Record<string, unknown>>;
|
|
12
|
+
|
|
13
|
+
export function registerGrepTool(
|
|
14
|
+
pi: ExtensionAPI,
|
|
15
|
+
cwd: string,
|
|
16
|
+
fffService: FffServiceWithCursor | null | undefined,
|
|
17
|
+
sdkTool: SdkToolDef,
|
|
18
|
+
TextComp?: new (t?: string, x?: number, y?: number) => { setText(v: string): void },
|
|
19
|
+
): void {
|
|
20
|
+
const T = TextComp ?? (() => { const m = require("@earendil-works/pi-tui") as { Text: new (t?: string, x?: number, y?: number) => { setText(v: string): void } }; return m.Text; })();
|
|
21
|
+
const home = process.env.HOME ?? "";
|
|
22
|
+
|
|
23
|
+
pi.registerTool({
|
|
24
|
+
name: "grep",
|
|
25
|
+
label: "Grep",
|
|
26
|
+
description: sdkTool.description ?? "Search file contents by pattern",
|
|
27
|
+
parameters: sdkTool.parameters,
|
|
28
|
+
renderShell: "self",
|
|
29
|
+
|
|
30
|
+
execute: wrapExecuteWithMetrics(async (tid, params, sig, _upd, ctx: ExtensionContext) => {
|
|
31
|
+
const p = params as any;
|
|
32
|
+
const pattern = String(p.pattern ?? "");
|
|
33
|
+
const path = p.path ? String(p.path) : undefined;
|
|
34
|
+
const glob = p.glob ? String(p.glob) : undefined;
|
|
35
|
+
const context = typeof p.context === "number" ? p.context : 0;
|
|
36
|
+
const limit = typeof p.limit === "number" ? p.limit : 200;
|
|
37
|
+
const literal = p.literal === true;
|
|
38
|
+
|
|
39
|
+
if (fffService?.isAvailable && !path && !glob) {
|
|
40
|
+
try {
|
|
41
|
+
const fff = fffService.getFinder();
|
|
42
|
+
if (!fff) throw new Error("FFF finder not available");
|
|
43
|
+
const effectiveLimit = Math.max(1, limit);
|
|
44
|
+
const grepResult = fff.grep(pattern, { pageSize: effectiveLimit, mode: literal ? "plain" : "regex", beforeContext: context, afterContext: context });
|
|
45
|
+
if (grepResult.ok) {
|
|
46
|
+
const grep = grepResult.value;
|
|
47
|
+
const items = grep.items.slice(0, effectiveLimit);
|
|
48
|
+
const cursorStore = fffService.getCursorStore();
|
|
49
|
+
const notices: string[] = [];
|
|
50
|
+
if (fffService.partialIndex) notices.push("Warning: partial file index");
|
|
51
|
+
if (items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
|
|
52
|
+
if (grep.regexFallbackError) notices.push(`Regex failed: ${grep.regexFallbackError}, used literal match`);
|
|
53
|
+
if (grep.nextCursor) {
|
|
54
|
+
const cursorId = cursorStore.store(grep.nextCursor);
|
|
55
|
+
notices.push(`More results available: cursor="${cursorId}"`);
|
|
56
|
+
}
|
|
57
|
+
const text = appendNotices(fffFormatGrepText(items, effectiveLimit), notices);
|
|
58
|
+
return { content: [{ type: "text" as const, text }], details: { _type: "grepResult", text, pattern, matchCount: items.length } as GrepDetails };
|
|
59
|
+
}
|
|
60
|
+
} catch { /* fall through */ }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const result = await sdkTool.execute(tid, p, sig, undefined, ctx) as Result;
|
|
64
|
+
for (const c of (result.content ?? []) as any[]) { if (c.type === "text") c.text = normalizeLineEndings(c.text); }
|
|
65
|
+
const tc = ((result.content ?? []) as TextContent[]).filter((c) => c.type === "text").map((c) => c.text).join("\n") ?? "";
|
|
66
|
+
result.details = { _type: "grepResult", text: tc, pattern, matchCount: tc ? tc.trim().split("\n").filter(Boolean).length : 0 } as GrepDetails;
|
|
67
|
+
return result;
|
|
68
|
+
}),
|
|
69
|
+
|
|
70
|
+
renderCall(args: any, theme: ThemeLike, ctx: RenderCtxLike) {
|
|
71
|
+
resolveBaseBackground(theme);
|
|
72
|
+
const text = ctx.lastComponent ?? new T("", 0, 0);
|
|
73
|
+
const p = args.path ? ` ${theme.fg("muted", `in ${shortPath(cwd, home, String(args.path))}`)}` : "";
|
|
74
|
+
const lit = args.literal ? ` ${theme.fg("dim", "[literal]")}` : "";
|
|
75
|
+
text.setText(fillToolBackground(`\n ${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", String(args.pattern ?? ""))}${p}${lit}`, ctx.isError ? BG_ERROR : undefined));
|
|
76
|
+
return text;
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
renderResult(result: Result, _opt: unknown, theme: ThemeLike, ctx: RenderCtxLike) {
|
|
80
|
+
resolveBaseBackground(theme);
|
|
81
|
+
const text = ctx.lastComponent ?? new T("", 0, 0);
|
|
82
|
+
if (ctx.isError) { text.setText(renderToolError(((result.content ?? []) as TextContent[]).filter((c) => c.type === "text").map((c) => c.text).join("\n") || "Error", theme)); return text; }
|
|
83
|
+
const d = result.details as GrepDetails | undefined;
|
|
84
|
+
if (d?._type === "grepResult" && d.text) {
|
|
85
|
+
const lines = d.text.split("\n");
|
|
86
|
+
const maxShow = ctx.expanded ? lines.length : Math.min(lines.length, MAX_PREVIEW_LINES);
|
|
87
|
+
const preview = lines.slice(0, maxShow).map(l => ` ${l}`).join("\n");
|
|
88
|
+
const more = lines.length > maxShow ? `\n${FG_DIM} ... ${lines.length - maxShow} more lines${RST}` : "";
|
|
89
|
+
text.setText(fillToolBackground(` ${FG_DIM}${d.matchCount} matches${RST}${renderToolMetrics(result)}\n${preview}${more}`));
|
|
90
|
+
return text;
|
|
91
|
+
}
|
|
92
|
+
const fc = result.content?.[0];
|
|
93
|
+
text.setText(fillToolBackground(` ${theme.fg("dim", fc && "text" in fc ? String(fc.text).slice(0, 120) : "no matches")}`));
|
|
94
|
+
return text;
|
|
95
|
+
},
|
|
96
|
+
} as unknown as ToolDefinition<any, any, any>);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function appendNotices(text: string, notices: string[]): string { return notices.length ? `${text}\n\n[${notices.join(". ")}]` : text; }
|
package/src/tools/ls.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/* pi-pretty: ls tool -- directory listing with styled output. */
|
|
2
|
+
|
|
3
|
+
import { type ToolDefinition, type ExtensionAPI, type ExtensionContext, type AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { SdkToolDef, LsDetails, TextContent, ComponentLike, ThemeLike, RenderCtxLike } from "../types.js";
|
|
5
|
+
import { resolveBaseBackground, termWidth, MAX_PREVIEW_LINES, BG_BASE, BG_ERROR, FG_DIM, RST } from "../config.js";
|
|
6
|
+
import { wrapExecuteWithMetrics } from "./metrics.js";
|
|
7
|
+
import { renderTree, renderToolError, renderToolMetrics, fillToolBackground } from "../render.js";
|
|
8
|
+
|
|
9
|
+
type Result = AgentToolResult<Record<string, unknown>>;
|
|
10
|
+
|
|
11
|
+
export function registerLsTool(
|
|
12
|
+
pi: ExtensionAPI,
|
|
13
|
+
_cwd: string,
|
|
14
|
+
_fffService: unknown,
|
|
15
|
+
sdkTool: SdkToolDef,
|
|
16
|
+
TextComp?: new (t?: string, x?: number, y?: number) => { setText(v: string): void },
|
|
17
|
+
): void {
|
|
18
|
+
const TC = TextComp ?? (() => {
|
|
19
|
+
const { Text } = require("@earendil-works/pi-tui") as { Text: new (t?: string, x?: number, y?: number) => { setText(v: string): void } };
|
|
20
|
+
return Text;
|
|
21
|
+
})();
|
|
22
|
+
|
|
23
|
+
pi.registerTool({
|
|
24
|
+
name: "ls",
|
|
25
|
+
label: "List",
|
|
26
|
+
description: sdkTool.description ?? "List directory contents",
|
|
27
|
+
parameters: sdkTool.parameters,
|
|
28
|
+
renderShell: "self",
|
|
29
|
+
|
|
30
|
+
execute: wrapExecuteWithMetrics(async (tid, params, sig, _upd, ctx: ExtensionContext) => {
|
|
31
|
+
const result = await sdkTool.execute(tid, params, sig, undefined, ctx) as Result;
|
|
32
|
+
const tc = getText(result);
|
|
33
|
+
result.details = { _type: "lsResult", text: tc, path: String((params as any).path ?? ""), entryCount: tc ? tc.trim().split("\n").filter(Boolean).length : 0 } as LsDetails;
|
|
34
|
+
return result;
|
|
35
|
+
}),
|
|
36
|
+
|
|
37
|
+
renderCall(args: any, theme: ThemeLike, ctx: RenderCtxLike) {
|
|
38
|
+
resolveBaseBackground(theme);
|
|
39
|
+
|
|
40
|
+
const text = ctx.lastComponent ?? new TC("", 0, 0);
|
|
41
|
+
const p = String(args.path ?? ".");
|
|
42
|
+
text.setText(fillToolBackground(`\n ${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", p)}`, ctx.isError ? BG_ERROR : undefined));
|
|
43
|
+
return text;
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
renderResult(result: Result, _opt: unknown, theme: ThemeLike, ctx: RenderCtxLike) {
|
|
47
|
+
resolveBaseBackground(theme);
|
|
48
|
+
|
|
49
|
+
const text = ctx.lastComponent ?? new TC("", 0, 0);
|
|
50
|
+
if (ctx.isError) { text.setText(renderToolError(getText(result) || "Error", theme)); return text; }
|
|
51
|
+
const d = result.details as LsDetails | undefined;
|
|
52
|
+
if (d?._type === "lsResult" && d.text) {
|
|
53
|
+
const rendered = renderTree(d.text, d.path).split("\n").map(l => ` ${l}`).join("\n");
|
|
54
|
+
text.setText(fillToolBackground(` ${FG_DIM}${d.entryCount} entries${RST}${renderToolMetrics(result)}\n${rendered}`));
|
|
55
|
+
return text;
|
|
56
|
+
}
|
|
57
|
+
const fc = result.content?.[0];
|
|
58
|
+
text.setText(fillToolBackground(` ${theme.fg("dim", fc && "text" in fc ? String(fc.text).slice(0, 120) : "done")}`));
|
|
59
|
+
return text;
|
|
60
|
+
},
|
|
61
|
+
} as unknown as ToolDefinition<any, any, any>);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getText(result: Result): string {
|
|
65
|
+
return ((result.content ?? []) as TextContent[]).filter((c) => c.type === "text").map((c) => c.text).join("\n") ?? "";
|
|
66
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-pretty: tool metrics wrapper — elapsed time + output size.
|
|
3
|
+
*
|
|
4
|
+
* Wraps execute functions to record performance metadata in result.details.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { ELAPSED_KEY, CHARS_KEY } from "../helpers.js";
|
|
9
|
+
|
|
10
|
+
type ExecuteFn = (
|
|
11
|
+
tid: string,
|
|
12
|
+
params: any,
|
|
13
|
+
sig: AbortSignal | undefined,
|
|
14
|
+
_upd: unknown,
|
|
15
|
+
ctx: ExtensionContext,
|
|
16
|
+
) => Promise<AgentToolResult<Record<string, unknown>>>;
|
|
17
|
+
|
|
18
|
+
export function wrapExecuteWithMetrics(execute: ExecuteFn): ExecuteFn {
|
|
19
|
+
return async (tid, params, sig, upd, ctx) => {
|
|
20
|
+
const start = performance.now();
|
|
21
|
+
const result = await execute(tid, params, sig, upd, ctx);
|
|
22
|
+
const elapsedMs = performance.now() - start;
|
|
23
|
+
const details = (result.details ?? {}) as Record<string, unknown>;
|
|
24
|
+
details[ELAPSED_KEY] = elapsedMs;
|
|
25
|
+
details[CHARS_KEY] = getOutputCharCount(result);
|
|
26
|
+
(result as { details: Record<string, unknown> }).details = details;
|
|
27
|
+
return result;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getOutputCharCount(result: AgentToolResult<unknown>): number {
|
|
32
|
+
const content = result.content;
|
|
33
|
+
if (!Array.isArray(content)) return 0;
|
|
34
|
+
let length = 0;
|
|
35
|
+
for (const block of content) {
|
|
36
|
+
if (block.type !== "text") continue;
|
|
37
|
+
length += String(block.text ?? "").replace(/\r/g, "").length;
|
|
38
|
+
}
|
|
39
|
+
return length;
|
|
40
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/* pi-pretty: multi_grep tool -- FFF-backed multi-pattern search with ripgrep/SDK fallback. */
|
|
2
|
+
|
|
3
|
+
import { type ToolDefinition, type ExtensionAPI, type ExtensionContext, type AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import type { SdkToolDef, GrepDetails, FffServiceWithCursor, TextContent, ComponentLike, ThemeLike, RenderCtxLike } from "../types.js";
|
|
6
|
+
import { resolveBaseBackground, MAX_PREVIEW_LINES, BG_BASE, BG_ERROR, FG_DIM, RST } from "../config.js";
|
|
7
|
+
import { shortPath, normalizeLineEndings } from "../helpers.js";
|
|
8
|
+
import { wrapExecuteWithMetrics } from "./metrics.js";
|
|
9
|
+
import { renderToolError, renderToolMetrics, fillToolBackground } from "../render.js";
|
|
10
|
+
import { fffFormatGrepText } from "../fff-helpers.js";
|
|
11
|
+
import { parseMultiGrepConstraints } from "../multi-grep-fallback.js";
|
|
12
|
+
import type { MultiGrepFallback } from "../types.js";
|
|
13
|
+
|
|
14
|
+
type Result = AgentToolResult<Record<string, unknown>>;
|
|
15
|
+
|
|
16
|
+
const noopFallback: MultiGrepFallback = async () => ({ text: "", matchCount: 0, limitReached: false });
|
|
17
|
+
|
|
18
|
+
export function registerMultiGrepTool(
|
|
19
|
+
pi: ExtensionAPI,
|
|
20
|
+
cwd: string,
|
|
21
|
+
fffService: FffServiceWithCursor | null | undefined,
|
|
22
|
+
sdkGrepTool?: SdkToolDef,
|
|
23
|
+
ripgrepFallback: MultiGrepFallback = noopFallback,
|
|
24
|
+
TextComp?: new (t?: string, x?: number, y?: number) => { setText(v: string): void },
|
|
25
|
+
): void {
|
|
26
|
+
const TC = TextComp ?? (() => {
|
|
27
|
+
const { Text } = require("@earendil-works/pi-tui") as { Text: new (t?: string, x?: number, y?: number) => { setText(v: string): void } };
|
|
28
|
+
return Text;
|
|
29
|
+
})();
|
|
30
|
+
const home = process.env.HOME ?? "";
|
|
31
|
+
|
|
32
|
+
pi.registerTool({
|
|
33
|
+
name: "multi_grep",
|
|
34
|
+
label: "Multi Grep",
|
|
35
|
+
description: "Search file contents using multiple patterns (OR logic)",
|
|
36
|
+
parameters: Type.Object({
|
|
37
|
+
patterns: Type.Array(Type.String()),
|
|
38
|
+
path: Type.Optional(Type.String()),
|
|
39
|
+
constraints: Type.Optional(Type.String()),
|
|
40
|
+
context: Type.Optional(Type.Number()),
|
|
41
|
+
limit: Type.Optional(Type.Number()),
|
|
42
|
+
}),
|
|
43
|
+
renderShell: "self",
|
|
44
|
+
|
|
45
|
+
execute: wrapExecuteWithMetrics(async (tid, params, sig, _upd, ctx: ExtensionContext) => {
|
|
46
|
+
const p = params as any;
|
|
47
|
+
const patterns = Array.isArray(p.patterns) ? p.patterns.map(String) : [String(p.patterns ?? "")];
|
|
48
|
+
|
|
49
|
+
// Guard: empty patterns
|
|
50
|
+
if (!patterns.length || (patterns.length === 1 && !patterns[0])) {
|
|
51
|
+
return { content: [{ text: "patterns array must have at least 1 element", type: "text" as const }], details: { _type: "grepResult" } as GrepDetails };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Guard: aborted signal
|
|
55
|
+
if (sig?.aborted) {
|
|
56
|
+
return { content: [{ text: "Aborted", type: "text" as const }], details: { _type: "grepResult" } as GrepDetails };
|
|
57
|
+
}
|
|
58
|
+
const constraintsStr = p.constraints ? String(p.constraints) : undefined;
|
|
59
|
+
const context = typeof p.context === "number" ? p.context : undefined;
|
|
60
|
+
const effectiveLimit = typeof p.limit === "number" ? p.limit : 200;
|
|
61
|
+
const alternationPattern = patterns.length === 1 ? patterns[0] : patterns.join("|");
|
|
62
|
+
|
|
63
|
+
const hasNativeConstraints = Boolean(constraintsStr);
|
|
64
|
+
const parsedConstraints = constraintsStr ? parseMultiGrepConstraints(constraintsStr) : null;
|
|
65
|
+
const requestedConstraints = parsedConstraints?.ok ? constraintsStr : undefined;
|
|
66
|
+
let effectivePath = p.path ? String(p.path) : undefined;
|
|
67
|
+
const requestedPath = parsedConstraints?.ok ? parsedConstraints.tokens[0] : undefined;
|
|
68
|
+
if (requestedPath && !effectivePath) effectivePath = requestedPath;
|
|
69
|
+
|
|
70
|
+
// 1. FFF multiGrep (no constraints AND no path)
|
|
71
|
+
if (fffService?.isAvailable && !hasNativeConstraints && !effectivePath) {
|
|
72
|
+
try {
|
|
73
|
+
const fff = fffService.getFinder();
|
|
74
|
+
if (!fff) throw new Error("FFF finder not available");
|
|
75
|
+
const grepResult = fff.multiGrep({
|
|
76
|
+
patterns,
|
|
77
|
+
pageSize: effectiveLimit,
|
|
78
|
+
smartCase: !shouldIgnoreCase(patterns),
|
|
79
|
+
beforeContext: context ?? 0,
|
|
80
|
+
afterContext: context ?? 0,
|
|
81
|
+
});
|
|
82
|
+
if (grepResult.ok) {
|
|
83
|
+
const grep = grepResult.value;
|
|
84
|
+
const items = grep.items.slice(0, effectiveLimit);
|
|
85
|
+
const cursorStore = fffService.getCursorStore();
|
|
86
|
+
const notices: string[] = [];
|
|
87
|
+
if (fffService.partialIndex) notices.push("Warning: partial file index");
|
|
88
|
+
if (items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
|
|
89
|
+
if (grep.nextCursor) {
|
|
90
|
+
const cursorId = cursorStore.store(grep.nextCursor);
|
|
91
|
+
notices.push(`More results available: cursor="${cursorId}"`);
|
|
92
|
+
}
|
|
93
|
+
const text = appendNotices(fffFormatGrepText(items, effectiveLimit), notices);
|
|
94
|
+
return { content: [{ type: "text" as const, text }], details: { _type: "grepResult", text, pattern: alternationPattern, matchCount: items.length } as GrepDetails };
|
|
95
|
+
}
|
|
96
|
+
// FFF failure -> return error directly
|
|
97
|
+
return { content: [{ type: "text" as const, text: grepResult.error || "multi_grep failed" }], details: { _type: "grepResult", text: "", pattern: alternationPattern, matchCount: 0 } as GrepDetails };
|
|
98
|
+
} catch { /* fall through */ }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2. Ripgrep fallback
|
|
102
|
+
if (requestedConstraints || !sdkGrepTool) {
|
|
103
|
+
try {
|
|
104
|
+
const pathBacked = Boolean(requestedConstraints && requestedPath && !Boolean(p.path) && !requestedConstraints.includes("*") && !requestedConstraints.includes("?"));
|
|
105
|
+
const constraintsForRg = pathBacked ? undefined : requestedConstraints;
|
|
106
|
+
const notices: string[] = [];
|
|
107
|
+
if (!fffService?.isAvailable) notices.push("FFF unavailable, used ripgrep fallback");
|
|
108
|
+
else if (hasNativeConstraints) notices.push("Used ripgrep fallback for constrained search");
|
|
109
|
+
else notices.push("Used ripgrep fallback");
|
|
110
|
+
|
|
111
|
+
const rgResult = await ripgrepFallback({
|
|
112
|
+
cwd, patterns, path: effectivePath, constraints: constraintsForRg,
|
|
113
|
+
ignoreCase: shouldIgnoreCase(patterns), context, limit: effectiveLimit, signal: sig,
|
|
114
|
+
});
|
|
115
|
+
const text = normalizeLineEndings(rgResult.text) || "No matches found";
|
|
116
|
+
if (rgResult.limitReached) notices.push(`${effectiveLimit} limit reached`);
|
|
117
|
+
return { content: [{ type: "text" as const, text: appendNotices(text, notices) }], details: { _type: "grepResult", text, pattern: alternationPattern, matchCount: rgResult.matchCount } as GrepDetails };
|
|
118
|
+
} catch (error: unknown) {
|
|
119
|
+
return { content: [{ type: "text" as const, text: `multi_grep error: ${error instanceof Error ? error.message : String(error)}` }], details: { _type: "grepResult", text: "", pattern: alternationPattern, matchCount: 0 } as GrepDetails };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 3. SDK grep fallback
|
|
124
|
+
try {
|
|
125
|
+
const notices: string[] = [];
|
|
126
|
+
if (!fffService?.isAvailable) notices.push("FFF unavailable, used SDK grep fallback");
|
|
127
|
+
const result = await sdkGrepTool.execute(tid, { pattern: alternationPattern, path: effectivePath, ignoreCase: shouldIgnoreCase(patterns), context, limit: effectiveLimit }, sig, null, ctx) as Result;
|
|
128
|
+
const tc = getText(result);
|
|
129
|
+
result.content = [{ type: "text" as const, text: appendNotices(tc, notices) }];
|
|
130
|
+
result.details = { _type: "grepResult", text: tc, pattern: alternationPattern, matchCount: tc ? tc.trim().split("\n").filter(Boolean).length : 0 } as GrepDetails;
|
|
131
|
+
return result;
|
|
132
|
+
} catch (error: unknown) {
|
|
133
|
+
return { content: [{ type: "text" as const, text: `multi_grep error: ${error instanceof Error ? error.message : String(error)}` }], details: { _type: "grepResult", text: "", pattern: alternationPattern, matchCount: 0 } as GrepDetails };
|
|
134
|
+
}
|
|
135
|
+
}),
|
|
136
|
+
|
|
137
|
+
renderCall(args: any, theme: ThemeLike, ctx: RenderCtxLike) {
|
|
138
|
+
resolveBaseBackground(theme);
|
|
139
|
+
|
|
140
|
+
const text = ctx.lastComponent ?? new TC("", 0, 0);
|
|
141
|
+
const pats = (Array.isArray(args.patterns) ? args.patterns.map(String) : [String(args.patterns ?? "")]);
|
|
142
|
+
const label = pats.length > 2 ? `${pats[0]}, ${pats[1]}, +${pats.length - 2}` : pats.join(", ");
|
|
143
|
+
const p = args.path ? ` ${theme.fg("muted", `in ${shortPath(cwd, home, String(args.path))}`)}` : "";
|
|
144
|
+
text.setText(fillToolBackground(`\n ${theme.fg("toolTitle", theme.bold("mgrep"))} ${theme.fg("accent", label)}${p}`, ctx.isError ? BG_ERROR : undefined));
|
|
145
|
+
return text;
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
renderResult(result: Result, _opt: unknown, theme: ThemeLike, ctx: RenderCtxLike) {
|
|
149
|
+
resolveBaseBackground(theme);
|
|
150
|
+
|
|
151
|
+
const text = ctx.lastComponent ?? new TC("", 0, 0);
|
|
152
|
+
if (ctx.isError) { text.setText(renderToolError(getText(result) || "Error", theme)); return text; }
|
|
153
|
+
const d = result.details as GrepDetails | undefined;
|
|
154
|
+
if (d?._type === "grepResult" && d.text) {
|
|
155
|
+
const lines = d.text.split("\n");
|
|
156
|
+
const maxShow = ctx.expanded ? lines.length : Math.min(lines.length, MAX_PREVIEW_LINES);
|
|
157
|
+
const preview = lines.slice(0, maxShow).map(l => ` ${l}`).join("\n");
|
|
158
|
+
const more = lines.length > maxShow ? `\n${FG_DIM} ... ${lines.length - maxShow} more lines${RST}` : "";
|
|
159
|
+
text.setText(fillToolBackground(` ${FG_DIM}${d.matchCount} matches${RST}${renderToolMetrics(result)}\n${preview}${more}`));
|
|
160
|
+
return text;
|
|
161
|
+
}
|
|
162
|
+
const fc = result.content?.[0];
|
|
163
|
+
text.setText(fillToolBackground(` ${theme.fg("dim", fc && "text" in fc ? String(fc.text).slice(0, 120) : "no matches")}`));
|
|
164
|
+
return text;
|
|
165
|
+
},
|
|
166
|
+
} as unknown as ToolDefinition<any, any, any>);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function shouldIgnoreCase(patterns: string[]): boolean { return !patterns.some((p) => /[A-Z]/.test(p)); }
|
|
170
|
+
function appendNotices(text: string, notices: string[]): string { return notices.length ? `${text}\n\n[${notices.join(". ")}]` : text; }
|
|
171
|
+
function getText(result: Result): string { return ((result.content ?? []) as TextContent[]).filter((c) => c.type === "text").map((c) => c.text).join("\n") ?? ""; }
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/* pi-pretty: read tool -- file reading with syntax highlighting and inline image support. */
|
|
2
|
+
|
|
3
|
+
import { type ToolDefinition, type ExtensionAPI, type ExtensionContext, type AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { SdkToolDef, ReadDetails, TextContent, ComponentLike, ThemeLike, RenderCtxLike } from "../types.js";
|
|
5
|
+
import { resolveBaseBackground, termWidth, MAX_PREVIEW_LINES, BG_BASE, BG_ERROR, FG_DIM, RST } from "../config.js";
|
|
6
|
+
import { shortPath, normalizeLineEndings } from "../helpers.js";
|
|
7
|
+
import { wrapExecuteWithMetrics } from "./metrics.js";
|
|
8
|
+
import { renderFindResults, renderToolError, renderToolMetrics, fillToolBackground } from "../render.js";
|
|
9
|
+
|
|
10
|
+
// Simple terminal image support check
|
|
11
|
+
function isImageTerminal(): boolean {
|
|
12
|
+
const term = (process.env.TERM_PROGRAM ?? process.env.TERM ?? "").toLowerCase();
|
|
13
|
+
const proto = (process.env.PRETTY_IMAGE_PROTOCOL ?? "").toLowerCase();
|
|
14
|
+
if (proto === "kitty" || proto === "iterm2") return true;
|
|
15
|
+
if (proto === "none") return false;
|
|
16
|
+
return ["ghostty", "kitty", "iterm.app", "wezterm", "mintty"].some((t) => term.includes(t)) || process.env.LC_TERMINAL === "iTerm2";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Result = AgentToolResult<Record<string, unknown>>;
|
|
20
|
+
|
|
21
|
+
export function registerReadTool(
|
|
22
|
+
pi: ExtensionAPI,
|
|
23
|
+
cwd: string,
|
|
24
|
+
_fffService: unknown,
|
|
25
|
+
sdkTool: SdkToolDef,
|
|
26
|
+
TextComp?: new (t?: string, x?: number, y?: number) => { setText(v: string): void },
|
|
27
|
+
): void {
|
|
28
|
+
const TC = TextComp ?? (() => {
|
|
29
|
+
const { Text } = require("@earendil-works/pi-tui") as { Text: new (t?: string, x?: number, y?: number) => { setText(v: string): void } };
|
|
30
|
+
return Text;
|
|
31
|
+
})();
|
|
32
|
+
const home = process.env.HOME ?? "";
|
|
33
|
+
|
|
34
|
+
pi.registerTool({
|
|
35
|
+
name: "read",
|
|
36
|
+
label: "Read",
|
|
37
|
+
description: sdkTool.description ?? "Read file contents",
|
|
38
|
+
parameters: sdkTool.parameters,
|
|
39
|
+
renderShell: "self",
|
|
40
|
+
|
|
41
|
+
execute: wrapExecuteWithMetrics(async (tid, params, sig, _upd, ctx: ExtensionContext) => {
|
|
42
|
+
const p = params as any;
|
|
43
|
+
const result = await sdkTool.execute(tid, p, sig, undefined, ctx) as Result;
|
|
44
|
+
|
|
45
|
+
const imageBlock = (result.content as any[])?.find((c: any) => c.type === "image");
|
|
46
|
+
if (imageBlock) {
|
|
47
|
+
result.details = { _type: "readImage", filePath: String(p.path ?? ""), data: imageBlock.data, mimeType: imageBlock.mimeType ?? "image/png" } as ReadDetails;
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const tc = normalizeLineEndings(getText(result));
|
|
52
|
+
result.details = { _type: "readFile", filePath: String(p.path ?? ""), content: tc, offset: typeof p.offset === "number" ? p.offset : 0, lineCount: tc ? tc.split("\n").length : 0 } as ReadDetails;
|
|
53
|
+
return result;
|
|
54
|
+
}),
|
|
55
|
+
|
|
56
|
+
renderCall(args: any, theme: ThemeLike, ctx: RenderCtxLike) {
|
|
57
|
+
resolveBaseBackground(theme);
|
|
58
|
+
|
|
59
|
+
const text = ctx.lastComponent ?? new TC("", 0, 0);
|
|
60
|
+
const p = shortPath(cwd, home, String(args.path ?? ""));
|
|
61
|
+
const off = typeof args.offset === "number" ? `:${args.offset}` : "";
|
|
62
|
+
text.setText(fillToolBackground(`\n ${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", p)}${theme.fg("dim", off)}`, ctx.isError ? BG_ERROR : undefined));
|
|
63
|
+
return text;
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
renderResult(result: Result, _opt: unknown, theme: ThemeLike, ctx: RenderCtxLike) {
|
|
67
|
+
resolveBaseBackground(theme);
|
|
68
|
+
|
|
69
|
+
const text = ctx.lastComponent ?? new TC("", 0, 0);
|
|
70
|
+
|
|
71
|
+
if (ctx.isError) { text.setText(renderToolError(getText(result) || "Error", theme)); return text; }
|
|
72
|
+
|
|
73
|
+
const d = result.details as ReadDetails | undefined;
|
|
74
|
+
|
|
75
|
+
// Image rendering
|
|
76
|
+
if (d?._type === "readImage") {
|
|
77
|
+
if ((ctx as any).showImages && isImageTerminal()) {
|
|
78
|
+
try {
|
|
79
|
+
const T = require("@earendil-works/pi-tui").Text as new (t?: string, x?: number, y?: number) => ComponentLike;
|
|
80
|
+
const img = new T("", 0, 0);
|
|
81
|
+
if (d.mimeType.startsWith("image/svg")) {
|
|
82
|
+
img.setText(d.data);
|
|
83
|
+
} else {
|
|
84
|
+
const pngData = (require("@earendil-works/pi-coding-agent") as any).convertToPng?.(d.data) ?? d.data;
|
|
85
|
+
img.setText(`\x1b_Ga=T,f=100,m=${d.mimeType === "image/png" ? "1" : "0"};${pngData}\x1b\\\\`);
|
|
86
|
+
}
|
|
87
|
+
return img;
|
|
88
|
+
} catch { /* fall through */ }
|
|
89
|
+
}
|
|
90
|
+
const fc = result.content?.[0];
|
|
91
|
+
text.setText(fillToolBackground(` ${theme.fg("dim", fc && "text" in fc ? String(fc.text).slice(0, 80) : `[image: ${d.filePath}]`)}`));
|
|
92
|
+
return text;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// File content
|
|
96
|
+
if (d?._type === "readFile" && d.content) {
|
|
97
|
+
const rendered = renderFindResults(d.content).split("\n").map(l => ` ${l}`).join("\n");
|
|
98
|
+
text.setText(fillToolBackground(rendered));
|
|
99
|
+
(ctx as any).state._rt = rendered;
|
|
100
|
+
return text;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const fc = result.content?.[0];
|
|
104
|
+
text.setText(fillToolBackground(` ${theme.fg("dim", fc && "text" in fc ? String(fc.text).slice(0, 120) : "done")}`));
|
|
105
|
+
return text;
|
|
106
|
+
},
|
|
107
|
+
} as unknown as ToolDefinition<any, any, any>);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getText(result: Result): string {
|
|
111
|
+
return ((result.content ?? []) as TextContent[]).filter((c) => c.type === "text").map((c) => c.text).join("\n") ?? "";
|
|
112
|
+
}
|