pi-hashline-edit-pro 4.0.1 → 4.2.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 +17 -12
- package/index.ts +70 -23
- package/package.json +1 -1
- package/prompts/insert-guidelines.md +2 -1
- package/prompts/insert.md +1 -1
- package/prompts/read-guidelines.md +1 -0
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +5 -5
- package/prompts/replace.md +1 -1
- package/src/anchor-registry.ts +11 -8
- package/src/batch.ts +614 -0
- package/src/boundary-bypass.ts +6 -0
- package/src/commit.ts +1 -1
- package/src/config-ui.ts +108 -0
- package/src/config.ts +48 -1
- package/src/edit-common.ts +115 -1
- package/src/fs-write.ts +6 -0
- package/src/grep.ts +84 -1
- package/src/hashline/alphabet.ts +1 -1
- package/src/hashline/apply.ts +128 -84
- package/src/hashline/index.ts +3 -0
- package/src/hashline/resolve.ts +5 -4
- package/src/insert.ts +127 -74
- package/src/payload-contract.ts +50 -2
- package/src/read.ts +10 -11
- package/src/replace-render.ts +2 -2
- package/src/replace-response.ts +3 -1
- package/src/replace.ts +94 -37
package/src/config-ui.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { readConfig, type Config } from "./config";
|
|
4
|
+
|
|
5
|
+
export type ConfigToggleKey = "autoRead" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "boundaryDedupMode";
|
|
6
|
+
|
|
7
|
+
export interface ConfigRow {
|
|
8
|
+
key: ConfigToggleKey;
|
|
9
|
+
label: string;
|
|
10
|
+
hint: string;
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
mode?: string;
|
|
13
|
+
cycle?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function configRows(config: Config): ConfigRow[] {
|
|
17
|
+
return [
|
|
18
|
+
{ key: "autoRead", label: "Auto-read", hint: "Anchors after write + post-edit diffs", enabled: config.autoRead !== false },
|
|
19
|
+
{ key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
|
|
20
|
+
{ key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
|
|
21
|
+
{ key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
|
|
22
|
+
{ key: "boundaryDedupMode", label: "Boundary dedup", hint: "Strip edge lines: on, strict (reject), off (literal)", enabled: (config.boundaryDedupMode ?? "on") !== "off", mode: config.boundaryDedupMode ?? "on", cycle: ["on", "strict", "off"] },
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function padRow(theme: Theme, innerWidth: number, content: string): string {
|
|
27
|
+
const padded = content + " ".repeat(Math.max(0, innerWidth - visibleWidth(content)));
|
|
28
|
+
return theme.fg("border", "│") + padded + theme.fg("border", "│");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function modeBox(theme: Theme, mode: string): string {
|
|
32
|
+
if (mode === "off") return theme.fg("dim", `[${mode}]`);
|
|
33
|
+
if (mode === "strict") return theme.fg("accent", `[${mode}]`);
|
|
34
|
+
return theme.fg("success", `[${mode}]`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class HashlineConfigOverlay {
|
|
38
|
+
private rows: ConfigRow[];
|
|
39
|
+
private selected = 0;
|
|
40
|
+
|
|
41
|
+
constructor(private readonly opts: { tui: { requestRender(force?: boolean): void }; theme: Theme; done: () => void; onToggle: (key: ConfigToggleKey) => Promise<void> }) {
|
|
42
|
+
this.rows = [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async load(): Promise<void> {
|
|
46
|
+
this.rows = configRows(await readConfig());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private toggleSelected(): void {
|
|
50
|
+
const row = this.rows[this.selected];
|
|
51
|
+
if (!row) return;
|
|
52
|
+
if (row.cycle && row.mode !== undefined) {
|
|
53
|
+
const next = row.cycle[(row.cycle.indexOf(row.mode) + 1) % row.cycle.length] ?? row.cycle[0];
|
|
54
|
+
if (next === undefined) return;
|
|
55
|
+
row.mode = next;
|
|
56
|
+
row.enabled = next !== "off";
|
|
57
|
+
} else {
|
|
58
|
+
row.enabled = !row.enabled;
|
|
59
|
+
}
|
|
60
|
+
this.opts.tui.requestRender(true);
|
|
61
|
+
void this.opts.onToggle(row.key).then(async () => {
|
|
62
|
+
this.rows = configRows(await readConfig());
|
|
63
|
+
this.opts.tui.requestRender(true);
|
|
64
|
+
}).catch((error: unknown) => {
|
|
65
|
+
console.error("Failed to toggle hashline setting:", error);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
handleInput(data: string): void {
|
|
70
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
71
|
+
this.selected = (this.selected + this.rows.length - 1) % this.rows.length;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (matchesKey(data, Key.down) || data === "j") {
|
|
75
|
+
this.selected = (this.selected + 1) % this.rows.length;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (matchesKey(data, Key.space) || matchesKey(data, Key.enter) || data === " " || data === "\r" || data === "\n") {
|
|
79
|
+
this.toggleSelected();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
83
|
+
this.opts.done();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
invalidate(): void {
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
render(width: number): string[] {
|
|
91
|
+
const theme = this.opts.theme;
|
|
92
|
+
const innerWidth = width - 2;
|
|
93
|
+
const lines: string[] = [];
|
|
94
|
+
lines.push(theme.fg("border", `╭${"─".repeat(innerWidth)}╮`));
|
|
95
|
+
lines.push(padRow(theme, innerWidth, ` ${theme.fg("accent", theme.bold("Hashline Config"))}`));
|
|
96
|
+
lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
|
|
97
|
+
this.rows.forEach((row, index) => {
|
|
98
|
+
const cursor = index === this.selected ? theme.fg("accent", "> ") : " ";
|
|
99
|
+
const box = row.mode !== undefined ? modeBox(theme, row.mode) : row.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
|
|
100
|
+
const label = index === this.selected ? theme.fg("accent", theme.bold(row.label)) : row.label;
|
|
101
|
+
lines.push(padRow(theme, innerWidth, `${cursor}${box} ${label} ${theme.fg("dim", `— ${row.hint}`)}`));
|
|
102
|
+
});
|
|
103
|
+
lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
|
|
104
|
+
lines.push(padRow(theme, innerWidth, theme.fg("dim", " ↑↓ navigate · space toggle · q close")));
|
|
105
|
+
lines.push(theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
|
|
106
|
+
return lines;
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -3,16 +3,33 @@ import { configPath } from "./paths";
|
|
|
3
3
|
import { errCode, isRec } from "./utils";
|
|
4
4
|
import { writeAtomic } from "./fs-write";
|
|
5
5
|
|
|
6
|
+
export type BoundaryDedupMode = "on" | "off" | "strict";
|
|
7
|
+
|
|
6
8
|
export interface Config {
|
|
7
9
|
autoRead: boolean;
|
|
8
10
|
anchorGrepEnabled: boolean;
|
|
11
|
+
requirePath?: boolean;
|
|
12
|
+
strictInput?: boolean;
|
|
13
|
+
boundaryDedupMode?: BoundaryDedupMode;
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
const DEFAULT_CONFIG: Config = {
|
|
12
17
|
autoRead: true,
|
|
13
|
-
anchorGrepEnabled:
|
|
18
|
+
anchorGrepEnabled: true,
|
|
19
|
+
requirePath: false,
|
|
20
|
+
strictInput: false,
|
|
21
|
+
boundaryDedupMode: "on"
|
|
14
22
|
};
|
|
15
23
|
|
|
24
|
+
const BOUNDARY_DEDUP_MODES: BoundaryDedupMode[] = ["on", "strict", "off"];
|
|
25
|
+
|
|
26
|
+
function parseBoundaryDedupMode(mode: unknown, legacy: unknown): BoundaryDedupMode {
|
|
27
|
+
if (mode === "on" || mode === "strict" || mode === "off") return mode;
|
|
28
|
+
if (legacy === true) return "on";
|
|
29
|
+
if (legacy === false) return "off";
|
|
30
|
+
return DEFAULT_CONFIG.boundaryDedupMode ?? "on";
|
|
31
|
+
}
|
|
32
|
+
|
|
16
33
|
function parseConfig(content: string): Config {
|
|
17
34
|
const parsed = JSON.parse(content) as unknown;
|
|
18
35
|
const autoRead = isRec(parsed) ? parsed.autoRead : undefined;
|
|
@@ -20,9 +37,16 @@ function parseConfig(content: string): Config {
|
|
|
20
37
|
throw new Error("config.json must be an object with a boolean autoRead field");
|
|
21
38
|
}
|
|
22
39
|
const anchorGrepEnabled = isRec(parsed) ? parsed.anchorGrepEnabled : undefined;
|
|
40
|
+
const requirePath = isRec(parsed) ? parsed.requirePath : undefined;
|
|
41
|
+
const strictInput = isRec(parsed) ? parsed.strictInput : undefined;
|
|
42
|
+
const boundaryDedupMode = isRec(parsed) ? parsed.boundaryDedupMode : undefined;
|
|
43
|
+
const legacyBoundaryDedup = isRec(parsed) ? parsed.boundaryDedupEnabled : undefined;
|
|
23
44
|
return {
|
|
24
45
|
autoRead,
|
|
25
46
|
anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
|
|
47
|
+
requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
|
|
48
|
+
strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
|
|
49
|
+
boundaryDedupMode: parseBoundaryDedupMode(boundaryDedupMode, legacyBoundaryDedup),
|
|
26
50
|
};
|
|
27
51
|
}
|
|
28
52
|
|
|
@@ -56,3 +80,26 @@ export async function toggleAnchorGrep(): Promise<boolean> {
|
|
|
56
80
|
await writeConfig(config);
|
|
57
81
|
return config.anchorGrepEnabled;
|
|
58
82
|
}
|
|
83
|
+
|
|
84
|
+
export async function toggleRequirePath(): Promise<boolean> {
|
|
85
|
+
const config = await readConfig();
|
|
86
|
+
config.requirePath = !config.requirePath;
|
|
87
|
+
await writeConfig(config);
|
|
88
|
+
return config.requirePath;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function toggleStrictInput(): Promise<boolean> {
|
|
92
|
+
const config = await readConfig();
|
|
93
|
+
config.strictInput = !(config.strictInput === true);
|
|
94
|
+
await writeConfig(config);
|
|
95
|
+
return config.strictInput === true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function cycleBoundaryDedupMode(): Promise<BoundaryDedupMode> {
|
|
99
|
+
const config = await readConfig();
|
|
100
|
+
const current = config.boundaryDedupMode ?? "on";
|
|
101
|
+
const next = BOUNDARY_DEDUP_MODES[(BOUNDARY_DEDUP_MODES.indexOf(current) + 1) % BOUNDARY_DEDUP_MODES.length] ?? "on";
|
|
102
|
+
config.boundaryDedupMode = next;
|
|
103
|
+
await writeConfig(config);
|
|
104
|
+
return next;
|
|
105
|
+
}
|
package/src/edit-common.ts
CHANGED
|
@@ -3,11 +3,84 @@ import { resolveInCwd } from "./fs-write";
|
|
|
3
3
|
import { abortIf, makePrepareArguments } from "./utils";
|
|
4
4
|
import { ownerOf } from "./anchor-registry";
|
|
5
5
|
import { parseHashRef, stripAnchorRow } from "./hashline";
|
|
6
|
+
import { readConfig, type BoundaryDedupMode } from "./config";
|
|
6
7
|
import { makeRenderCall, renderEditResult, type RPreview, type FgT } from "./replace-render";
|
|
7
8
|
import type { ReplaceDetails } from "./replace";
|
|
8
|
-
|
|
9
9
|
export const editPrepare = makePrepareArguments();
|
|
10
10
|
|
|
11
|
+
export interface EditToolFlags {
|
|
12
|
+
requirePath: boolean;
|
|
13
|
+
strictInput: boolean;
|
|
14
|
+
boundaryDedupMode: BoundaryDedupMode;
|
|
15
|
+
autoRead: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_EDIT_FLAGS: EditToolFlags = {
|
|
19
|
+
requirePath: false,
|
|
20
|
+
strictInput: false,
|
|
21
|
+
boundaryDedupMode: "on",
|
|
22
|
+
autoRead: true
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export async function currentEditFlags(): Promise<EditToolFlags> {
|
|
26
|
+
const config = await readConfig();
|
|
27
|
+
return {
|
|
28
|
+
requirePath: config.requirePath === true,
|
|
29
|
+
strictInput: config.strictInput === true,
|
|
30
|
+
boundaryDedupMode: config.boundaryDedupMode ?? "on",
|
|
31
|
+
autoRead: config.autoRead !== false
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function withReplacePrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
36
|
+
let description = base.description;
|
|
37
|
+
const snippetParts = [base.snippet];
|
|
38
|
+
let guidelines = [...base.guidelines];
|
|
39
|
+
if (!flags.autoRead) {
|
|
40
|
+
description = description.replace(" Anchor follow-up edits on the `+anchor│` and ` anchor│` rows of the post-edit diff instead of re-reading.", "");
|
|
41
|
+
guidelines = guidelines.map((guideline) => guideline.includes("post-edit diff") ? "`replace`: one batch per file per turn; verify each result before the next edit on that file." : guideline);
|
|
42
|
+
}
|
|
43
|
+
const descriptionParts = [description];
|
|
44
|
+
if (flags.requirePath) {
|
|
45
|
+
descriptionParts.push("Also give `path` matching the file the anchors were served for; it is required and must match anchor ownership.");
|
|
46
|
+
snippetParts.push("; include `path` (required)");
|
|
47
|
+
guidelines.push("`replace`: include `path` matching the file the anchors were served for; it is required.");
|
|
48
|
+
}
|
|
49
|
+
if (flags.strictInput) {
|
|
50
|
+
descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
|
|
51
|
+
guidelines.push("`replace`: strict-input is on: auto-fixable slips are rejected instead of fixed.");
|
|
52
|
+
}
|
|
53
|
+
if (flags.boundaryDedupMode === "off") {
|
|
54
|
+
descriptionParts.push("Boundary dedup is off: edits apply literally.");
|
|
55
|
+
guidelines.push("`replace`: boundary dedup is off: edits apply literally.");
|
|
56
|
+
} else if (flags.boundaryDedupMode === "strict") {
|
|
57
|
+
descriptionParts.push("Boundary dedup is strict: edits that re-include edge lines are rejected instead of stripped.");
|
|
58
|
+
guidelines.push("`replace`: boundary dedup is strict: edits that re-include edge lines are rejected instead of stripped.");
|
|
59
|
+
}
|
|
60
|
+
return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function withReadPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
64
|
+
if (flags.autoRead) return { description: base.description, snippet: base.snippet, guidelines: [...base.guidelines] };
|
|
65
|
+
const guidelines = base.guidelines.map((guideline) => guideline.startsWith("`read`: call again after an edit") ? "`read`: call again after an edit when you need anchors you lack." : guideline);
|
|
66
|
+
return { description: base.description, snippet: base.snippet, guidelines };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function withInsertPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
70
|
+
const descriptionParts = [base.description];
|
|
71
|
+
const snippetParts = [base.snippet];
|
|
72
|
+
const guidelines = [...base.guidelines];
|
|
73
|
+
if (flags.requirePath) {
|
|
74
|
+
descriptionParts.push("Also give `path` matching the file the anchor was served for; it is required and must match anchor ownership.");
|
|
75
|
+
snippetParts.push("; include `path` (required)");
|
|
76
|
+
guidelines.push("`insert`: include `path` matching the file the anchor was served for; it is required.");
|
|
77
|
+
}
|
|
78
|
+
if (flags.strictInput) {
|
|
79
|
+
descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
|
|
80
|
+
guidelines.push("`insert`: strict-input is on: auto-fixable slips are rejected instead of fixed.");
|
|
81
|
+
}
|
|
82
|
+
return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
|
|
83
|
+
}
|
|
11
84
|
export function resolveEditTarget(removeFrom: string, removeTo?: string): string {
|
|
12
85
|
const refs = [removeFrom, removeTo].filter((value): value is string => typeof value === "string");
|
|
13
86
|
const owners = refs.map((ref) => ownerOf(parseHashRef(stripAnchorRow(ref.trim(), "anchor entry")).hash));
|
|
@@ -35,6 +108,47 @@ export function tryResolveEditTarget(removeFrom: string | undefined, removeTo?:
|
|
|
35
108
|
}
|
|
36
109
|
}
|
|
37
110
|
|
|
111
|
+
export interface PathRequirementInput {
|
|
112
|
+
removeFrom?: string;
|
|
113
|
+
removeTo?: string;
|
|
114
|
+
anchor?: string;
|
|
115
|
+
providedPath?: unknown;
|
|
116
|
+
cwd: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function resolveEditTargetWithRequirement(input: PathRequirementInput): Promise<string> {
|
|
120
|
+
const { requirePath } = await readConfig();
|
|
121
|
+
if (!requirePath && input.providedPath !== undefined) {
|
|
122
|
+
throw new Error("[E_BAD_SHAPE] Edit request contains unknown or unsupported fields: path. Path resolution is anchor-only; retry without `path`.");
|
|
123
|
+
}
|
|
124
|
+
if (requirePath && (typeof input.providedPath !== "string" || input.providedPath.length === 0)) {
|
|
125
|
+
throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string when require-path mode is on. Provide `path` matching the file the anchors were served for.');
|
|
126
|
+
}
|
|
127
|
+
const anchorTarget = typeof input.anchor === "string"
|
|
128
|
+
? resolveEditTarget(input.anchor)
|
|
129
|
+
: resolveEditTarget(input.removeFrom as string, input.removeTo);
|
|
130
|
+
if (requirePath) {
|
|
131
|
+
const { resolved } = await resolveInCwd(input.providedPath as string, input.cwd);
|
|
132
|
+
if (resolved !== anchorTarget) {
|
|
133
|
+
throw new Error(`[E_BAD_SHAPE] Provided "path" "${input.providedPath}" does not match anchor ownership "${anchorTarget}".`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return anchorTarget;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function throwIfStrictInput(warnings: string[]): Promise<void> {
|
|
140
|
+
const fixes = warnings.filter((warning) => warning.startsWith("[W_"));
|
|
141
|
+
if (fixes.length === 0) return;
|
|
142
|
+
const { strictInput } = await readConfig();
|
|
143
|
+
if (strictInput === true) {
|
|
144
|
+
throw new Error(`[E_BAD_SHAPE] Strict-input mode rejects auto-fixable input:\n${fixes.join("\n")}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function getBoundaryDedupMode(): Promise<BoundaryDedupMode> {
|
|
149
|
+
return (await readConfig()).boundaryDedupMode ?? "on";
|
|
150
|
+
}
|
|
151
|
+
|
|
38
152
|
export function editRenderCallWrapper(
|
|
39
153
|
preview: (args: unknown, cwd: string, signal?: AbortSignal) => Promise<RPreview>,
|
|
40
154
|
getInput?: (args: unknown) => { path?: string } | null,
|
package/src/fs-write.ts
CHANGED
|
@@ -101,12 +101,18 @@ const TEMP_PREFIX = ".tmp-";
|
|
|
101
101
|
const TEMP_UUID_RE = /^\.tmp-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
102
102
|
const STALE_TEMP_MS = 60 * 60 * 1000;
|
|
103
103
|
const sweptDirs = new Map<string, number>();
|
|
104
|
+
const SWEPT_DIRS_LIMIT = 256;
|
|
104
105
|
|
|
105
106
|
async function sweepStaleTemps(dir: string): Promise<void> {
|
|
106
107
|
const sweepNow = Date.now();
|
|
107
108
|
const lastSweep = sweptDirs.get(dir);
|
|
108
109
|
if (lastSweep !== undefined && sweepNow - lastSweep < STALE_TEMP_MS) return;
|
|
110
|
+
sweptDirs.delete(dir);
|
|
109
111
|
sweptDirs.set(dir, sweepNow);
|
|
112
|
+
if (sweptDirs.size > SWEPT_DIRS_LIMIT) {
|
|
113
|
+
const oldest = sweptDirs.keys().next().value;
|
|
114
|
+
if (oldest !== undefined) sweptDirs.delete(oldest);
|
|
115
|
+
}
|
|
110
116
|
try {
|
|
111
117
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
112
118
|
for (const entry of entries) {
|
package/src/grep.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { normReq } from "./payload-contract";
|
|
|
14
14
|
import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines } from "./utils";
|
|
15
15
|
import { markServed as markServedScoped } from "./anchor-registry";
|
|
16
16
|
import { buildServedMap } from "./served";
|
|
17
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
18
|
+
import { expandHint, getResultText, reuseText, type CallT, type FgT } from "./replace-render";
|
|
17
19
|
const GREP_KS = new Set(["pattern", "path", "glob", "context", "ignoreCase", "literal", "limit"]);
|
|
18
20
|
|
|
19
21
|
function cmp(a: string, b: string): number {
|
|
@@ -452,6 +454,79 @@ const grepToolSchema = Type.Object(
|
|
|
452
454
|
{ additionalProperties: false },
|
|
453
455
|
);
|
|
454
456
|
|
|
457
|
+
|
|
458
|
+
const GREP_PREVIEW_LINES = 16;
|
|
459
|
+
const GREP_PREVIEW_LINES_EXPANDED = 40;
|
|
460
|
+
|
|
461
|
+
export function fmtGrepCall(args: { pattern?: unknown; path?: unknown; glob?: unknown; literal?: unknown; ignoreCase?: unknown; context?: unknown; limit?: unknown } | undefined, theme: CallT): string {
|
|
462
|
+
const pattern = typeof args?.pattern === "string" && args.pattern.length > 0 ? theme.fg("accent", args.pattern) : theme.fg("toolOutput", "...");
|
|
463
|
+
const qualifiers: string[] = [];
|
|
464
|
+
if (typeof args?.path === "string" && args.path.length > 0) qualifiers.push(args.path);
|
|
465
|
+
if (typeof args?.glob === "string" && args.glob.length > 0) qualifiers.push(args.glob);
|
|
466
|
+
if (args?.literal === true) qualifiers.push("literal");
|
|
467
|
+
if (args?.ignoreCase === true) qualifiers.push("case-insensitive");
|
|
468
|
+
if (typeof args?.context === "number") qualifiers.push(`context ${args.context}`);
|
|
469
|
+
if (typeof args?.limit === "number") qualifiers.push(`limit ${args.limit}`);
|
|
470
|
+
let text = `${theme.fg("toolTitle", theme.bold("anchor_grep"))} ${pattern}`;
|
|
471
|
+
if (qualifiers.length > 0) text += ` ${theme.fg("dim", qualifiers.join(" "))}`;
|
|
472
|
+
return text;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function highlightRegex(args: unknown): RegExp | undefined {
|
|
476
|
+
if (!isRec(args) || typeof args.pattern !== "string" || args.pattern.length === 0) return undefined;
|
|
477
|
+
try {
|
|
478
|
+
const validated = buildRegex(args.pattern, args.literal === true, args.ignoreCase === true);
|
|
479
|
+
return new RegExp(validated.source, validated.flags.includes("i") ? "giu" : "gu");
|
|
480
|
+
} catch {
|
|
481
|
+
return undefined;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function highlightMatches(text: string, regex: RegExp, theme: FgT): string {
|
|
486
|
+
let out = "";
|
|
487
|
+
let last = 0;
|
|
488
|
+
regex.lastIndex = 0;
|
|
489
|
+
for (;;) {
|
|
490
|
+
const match = regex.exec(text);
|
|
491
|
+
if (!match || match[0].length === 0) break;
|
|
492
|
+
out += text.slice(last, match.index) + theme.fg("accent", match[0]);
|
|
493
|
+
last = match.index + match[0].length;
|
|
494
|
+
}
|
|
495
|
+
return out + text.slice(last);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const ANCHORED_ROW_RE = /^[A-Za-z0-9]{4}│/;
|
|
499
|
+
|
|
500
|
+
function highlightHitRow(row: string, highlight: RegExp, theme: FgT): string {
|
|
501
|
+
const anchored = row.match(ANCHORED_ROW_RE);
|
|
502
|
+
if (!anchored) return highlightMatches(row, highlight, theme);
|
|
503
|
+
return anchored[0] + highlightMatches(row.slice(anchored[0].length), highlight, theme);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function styleGrepLine(line: string, highlight: RegExp | undefined, theme: FgT): string {
|
|
507
|
+
if (line.startsWith("=== ")) return theme.fg("accent", line);
|
|
508
|
+
if (line.startsWith("[grep:") || line.startsWith("... ")) return theme.fg("dim", line);
|
|
509
|
+
if (!highlight) return line;
|
|
510
|
+
const gutter = line.indexOf(" │ ");
|
|
511
|
+
if (gutter < 0) return highlightHitRow(line, highlight, theme);
|
|
512
|
+
const cut = gutter + " │ ".length;
|
|
513
|
+
return line.slice(0, cut) + highlightHitRow(line.slice(cut), highlight, theme);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export function renderGrepResult(result: { content?: Array<{ type: string; text?: string }> }, options: { isPartial: boolean; expanded?: boolean } | boolean, theme: FgT, context: any): Text {
|
|
517
|
+
const isPartial = typeof options === "boolean" ? options : options.isPartial;
|
|
518
|
+
const expanded = typeof options === "boolean" ? context.expanded === true : options.expanded === true || context.expanded === true;
|
|
519
|
+
if (isPartial) return reuseText(context, theme.fg("warning", "Searching..."));
|
|
520
|
+
const raw = getResultText(result);
|
|
521
|
+
if (context.isError) return raw ? reuseText(context, `\n${theme.fg("error", raw)}`) : new Text("", 0, 0);
|
|
522
|
+
if (!raw) return new Text("", 0, 0);
|
|
523
|
+
const maxLines = expanded ? GREP_PREVIEW_LINES_EXPANDED : GREP_PREVIEW_LINES;
|
|
524
|
+
const lines = raw.split("\n");
|
|
525
|
+
const highlight = highlightRegex((context as { args?: unknown }).args);
|
|
526
|
+
const shown = lines.slice(0, maxLines).map((line) => styleGrepLine(line, highlight, theme));
|
|
527
|
+
if (lines.length > maxLines) shown.push(theme.fg("muted", `... ${lines.length - maxLines} more grep lines (${expandHint()})`));
|
|
528
|
+
return reuseText(context, shown.join("\n"));
|
|
529
|
+
}
|
|
455
530
|
export function regGrep(pi: ExtensionAPI): void {
|
|
456
531
|
pi.registerTool({
|
|
457
532
|
name: "anchor_grep",
|
|
@@ -462,6 +537,14 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
462
537
|
prepareArguments: makePrepareArguments(),
|
|
463
538
|
parameters: grepToolSchema,
|
|
464
539
|
executionMode: "sequential",
|
|
540
|
+
renderCall(args: any, theme: CallT, context: any) {
|
|
541
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
542
|
+
text.setText(fmtGrepCall(args as { pattern?: unknown; path?: unknown } | undefined, theme));
|
|
543
|
+
return text;
|
|
544
|
+
},
|
|
545
|
+
renderResult(result, opts, theme, context) {
|
|
546
|
+
return renderGrepResult(result as never, opts as { isPartial: boolean; expanded?: boolean }, theme as never, context as never);
|
|
547
|
+
},
|
|
465
548
|
|
|
466
549
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
467
550
|
const canonical = normReq(params);
|
|
@@ -594,7 +677,7 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
594
677
|
if (rowTruncated) notes.push(`[grep: output truncated at ${DEFAULT_MAX_LINES} rows or ${formatSize(DEFAULT_MAX_BYTES)}; refine the pattern to see more.]`);
|
|
595
678
|
if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
|
|
596
679
|
if (linesReplaced > 0) notes.push(`[grep: ${linesReplaced} line(s) exceed ${formatSize(MAX_GREP_LINE_BYTES)} and are shown as truncated fragments; use read to see the full lines.]`);
|
|
597
|
-
if (poolSkipped > 0) notes.push(`[grep: ${poolSkipped} file(s) skipped because the session's anchor pool is exhausted;
|
|
680
|
+
if (poolSkipped > 0) notes.push(`[grep: ${poolSkipped} file(s) skipped because the session's anchor pool is exhausted; narrow the search.]`);
|
|
598
681
|
const truncated = limitTruncated || rowTruncated;
|
|
599
682
|
const truncation: TruncationResult | undefined = rowTruncated
|
|
600
683
|
? {
|
package/src/hashline/alphabet.ts
CHANGED
|
@@ -12,7 +12,7 @@ export const ALPH_RE = new RegExp(`^[${ALNUM}]+$`);
|
|
|
12
12
|
|
|
13
13
|
export const HASH_CLASS = `[${ALNUM}]{${HASH_LEN}}`;
|
|
14
14
|
|
|
15
|
-
export const HASH_RUN = `[${ALNUM}]{
|
|
15
|
+
export const HASH_RUN = `[${ALNUM}]{${HASH_LEN},${HASH_LEN + 1}}`;
|
|
16
16
|
|
|
17
17
|
export const HASH_RE = new RegExp(`^${HASH_CLASS}$`);
|
|
18
18
|
|