pi-hashline-edit-pro 6.0.1 → 6.1.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 +18 -5
- package/index.ts +60 -5
- package/package.json +3 -3
- package/prompts/undo-last-change-guidelines.md +1 -0
- package/src/batch.ts +19 -0
- package/src/commit.ts +2 -0
- package/src/config-ui.ts +31 -30
- package/src/config.ts +26 -3
- package/src/copy-move.ts +19 -2
- package/src/edit-common.ts +28 -11
- package/src/fs-write.ts +0 -10
- package/src/grep.ts +32 -4
- package/src/insert.ts +4 -2
- package/src/model-gate.ts +56 -0
- package/src/read.ts +50 -4
- package/src/replace-match.ts +4 -2
- package/src/replace-response.ts +37 -1
- package/src/replace-undo.ts +22 -23
- package/src/replace.ts +4 -2
- package/src/structured.ts +240 -0
- package/src/write-hook.ts +3 -1
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ What "pro" adds over upstream `pi-hashline-edit`: the anchor table is built from
|
|
|
68
68
|
|
|
69
69
|
Prerequisites:
|
|
70
70
|
|
|
71
|
-
- [pi-coding-agent](https://github.com/earendil-works/pi) `>= 0.
|
|
71
|
+
- [pi-coding-agent](https://github.com/earendil-works/pi) `>= 0.99.0` (`@earendil-works/pi-coding-agent`).
|
|
72
72
|
- Node.js 22.19 or newer, or a Bun build that ships `bun:sqlite`.
|
|
73
73
|
- An SQLite runtime. The extension uses `node:sqlite` on Node 22.19+ and falls back to `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`, so run pi under Node or a Bun build that ships SQLite. Without a runtime, every tool fails with `[E_STORE_UNAVAILABLE]`.
|
|
74
74
|
|
|
@@ -322,7 +322,7 @@ The hashline tools are sequential in pi, so a message that contains one runs all
|
|
|
322
322
|
|
|
323
323
|
## Auto-read
|
|
324
324
|
|
|
325
|
-
Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call.
|
|
325
|
+
Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call. A model matched by `disableOnModels` skips the block, and the post-edit diff substitution below is skipped with it.
|
|
326
326
|
|
|
327
327
|
After `replace`, `replace_match`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N (queued)`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
|
|
328
328
|
|
|
@@ -334,7 +334,7 @@ Auto-read keeps the same 50KB and 2000-line budget as `read`. Auto-read and Diff
|
|
|
334
334
|
|
|
335
335
|
Auto-read all is off by default and has three modes, selected in `/hashline-config`: `off` injects nothing, `on` discovers every file in the working directory that is not git-ignored (`git ls-files`, falling back to `ripgrep`, then to a directory walk), and `git` uses `git ls-files` only, injecting nothing when the working directory is not a git repository. On the first turn of a session, the extension discovers the files, reads each one, and attaches the resulting `anchor│content` rows to the conversation as one extension message before the model answers. Those anchors are served exactly like `read` output, so the model can `replace` and `insert` immediately without calling `read` first. The message is injected once per session; resumed, forked, and cloned sessions that already contain it skip the injection.
|
|
336
336
|
|
|
337
|
-
Files are filtered before injection: symlinks, directories, image extensions (including SVG), binary files (a NUL byte in the first 8KB), files over 200KB, any path with a vendored segment (vendor, node_modules, bower_components, third_party, thirdparty, jspm_packages, .venv, venv, site-packages, __pycache__, .tox, .gradle, .terraform, Pods, Carthage, DerivedData, coreui, coreui-icons, case-insensitive), and vendored or generated names and patterns (*.min.js, *.min.css, *.min.mjs, *-min.js, *-min.css, *.bundle.*, *.chunk.*, *.umd.js, *.map, *.lock, package-lock.json, yarn.lock, composer.lock, Gemfile.lock, Cargo.lock, poetry.lock, Pipfile.lock, go.sum, flake.lock, *.generated.*, *.gen.*, *_pb2.py, *.pb.go, *.g.dart, *.freezed.dart, *.designer.cs, *.g.cs, *.snap, .eslintcache, coreui-icons.*, coreui.css) are skipped. The attachment stops at 500 files or at a byte budget derived from the model context window (200KB floor, 2MB ceiling), and it never drops below one file. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand.
|
|
337
|
+
Files are filtered before injection: symlinks, directories, image extensions (including SVG), binary files (a NUL byte in the first 8KB), files over 200KB, any path with a vendored segment (vendor, node_modules, bower_components, third_party, thirdparty, jspm_packages, .venv, venv, site-packages, __pycache__, .tox, .gradle, .terraform, Pods, Carthage, DerivedData, coreui, coreui-icons, case-insensitive), and vendored or generated names and patterns (*.min.js, *.min.css, *.min.mjs, *-min.js, *-min.css, *.bundle.*, *.chunk.*, *.umd.js, *.map, *.lock, package-lock.json, yarn.lock, composer.lock, Gemfile.lock, Cargo.lock, poetry.lock, Pipfile.lock, go.sum, flake.lock, *.generated.*, *.gen.*, *_pb2.py, *.pb.go, *.g.dart, *.freezed.dart, *.designer.cs, *.g.cs, *.snap, .eslintcache, coreui-icons.*, coreui.css) are skipped. The attachment stops at 500 files or at a byte budget derived from the model context window (200KB floor, 2MB ceiling), and it never drops below one file. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand. A model matched by `disableOnModels` skips the injection entirely.
|
|
338
338
|
|
|
339
339
|
Each attached file is shown as `=== path ===` followed by its `anchor│content` rows. Edit directly from the attachment with replace and insert, so no `read` is needed. Files attach whole.
|
|
340
340
|
|
|
@@ -346,7 +346,7 @@ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as
|
|
|
346
346
|
|
|
347
347
|
| Command | Description |
|
|
348
348
|
| --- | --- |
|
|
349
|
-
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, diff context lines, `anchor_grep` tool, copy/move tools, replace_match tool, required `path`, and strict input. Persists across sessions. |
|
|
349
|
+
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, disable on models, diff context lines, `anchor_grep` tool, copy/move tools, replace_match tool, required `path`, and strict input. Persists across sessions. |
|
|
350
350
|
| `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
|
|
351
351
|
|
|
352
352
|
Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
|
|
@@ -361,7 +361,8 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
361
361
|
"replaceMatchEnabled": true,
|
|
362
362
|
"requirePath": false,
|
|
363
363
|
"strictInput": false,
|
|
364
|
-
"diffContextLines": 1
|
|
364
|
+
"diffContextLines": 1,
|
|
365
|
+
"disableOnModels": []
|
|
365
366
|
}
|
|
366
367
|
```
|
|
367
368
|
|
|
@@ -376,6 +377,9 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
376
377
|
| `requirePath` | Require path | `false` | `replace`, `replace_match`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
|
|
377
378
|
| `strictInput` | Strict input | `false` | Reject auto-fixable slips (`[W_BAD_SHAPE]`, `[W_BAD_REF]`, `[W_INVALID_PATCH]`, `[W_BARE_HASH_PREFIX]`) with `[E_BAD_SHAPE]` instead of applying them with a warning. |
|
|
378
379
|
| `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
|
|
380
|
+
| `disableOnModels` | Disable on models | `[]` | Model globs matched case-insensitively against `provider/id`, the bare `id`, and `api`, with `*` and `?` wildcards. A matching model gets no hashline tools, no auto-read, and no write-echo refusal. |
|
|
381
|
+
|
|
382
|
+
`disableOnModels` turns off the whole anchored surface for the models you name, so another edit tool (`apply_patch`, a shell read) can own the session without competing instructions. A match removes `read`, `replace`, `replace_match`, `insert`, `copy`, `move`, `anchor_grep`, and `undo_last_change` from the active tools, restoring the built-in `grep` when it was active, and it also skips the auto-read-all injection, the auto-read block after `write`, the post-edit diff substitution, and the `write` hook that refuses a `write` echoing a served anchor. The list is checked on `session_start`, on `model_select`, and again on `before_agent_start`, so a model change inside a session switches the surface immediately and another extension cannot re-add the tools mid-session. The default `[]` leaves every model unchanged.
|
|
379
383
|
|
|
380
384
|
When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFIG_HOME` when set (falling back to `~/.config`); on Windows the directory always uses `~/.config`, where `~` is `%USERPROFILE%`. To move the directory explicitly, see [Isolated state](#isolated-state).
|
|
381
385
|
|
|
@@ -403,6 +407,14 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
|
|
|
403
407
|
|
|
404
408
|
All eight tools return machine-readable metadata in `details` alongside the model-visible text.
|
|
405
409
|
|
|
410
|
+
All eight tools also declare an `outputSchema` and return `structuredContent`, so codemode scripts receive anchors, line numbers, diffs, and errors as data instead of parsing the rendered text. The model still receives `content`. Every structured result is either `{ ok: true, kind, ... }` or `{ ok: false, kind: "error", error: { code, message } }`; a failed call is still a tool error (`isError: true`) and also carries `structuredContent`, so a script can branch on the code without matching the message.
|
|
411
|
+
|
|
412
|
+
`read` returns `{ kind: "read", path, text, lines, totalLines, startLine, nextOffset, truncated, blockedByLongLine, hadUtf8DecodeErrors }`, where each entry of `lines` is `{ line, text, anchor, rendered }` for a row that was served (`text` is what was shown, so an oversized line appears as its marker while keeping its anchor). An image read returns `{ kind: "image", path, mimeType }`.
|
|
413
|
+
|
|
414
|
+
`replace`, `replace_match`, `insert`, `copy`, `move`, and `undo_last_change` return `{ kind: "edit", verb, classification, path, text, diff, warnings, hints, firstChangedLine, anchors, anchorsOmitted }`, where `verb` is `"replaced"`, `"inserted"`, `"copied"`, `"moved"`, or `"undone"`, `anchors` are the live rows of the post-edit diff, and `anchorsOmitted` is true when the diff was truncated or carried no rows, meaning a fresh read is needed. A cross-file move reports the destination file and its anchors.
|
|
415
|
+
|
|
416
|
+
`anchor_grep` returns `{ kind: "grep", text, matches, files, truncated, results }`, where each entry of `results` is `{ path, matchLines, lines, hadUtf8DecodeErrors }` and `lines` uses the same `{ line, text, anchor, rendered }` shape as `read`.
|
|
417
|
+
|
|
406
418
|
| Tool | `details` |
|
|
407
419
|
| --- | --- |
|
|
408
420
|
| `read` | `truncation` (set when output was truncated), `snapshotId` (a `v2\|path\|ino\|mtime\|ctime\|size` fingerprint), `nextOffset` (use as the next `offset`), and `metrics` with `truncated` and `next_offset`. |
|
|
@@ -453,6 +465,7 @@ Full reference:
|
|
|
453
465
|
| `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
|
|
454
466
|
| `[E_UNDO_STALE]` | `undo_last_change` refused: the file was modified after the last edit. The undo record is kept until the file matches the edited state again or a new edit replaces it. |
|
|
455
467
|
| `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the edit was refused and the file was left unchanged. |
|
|
468
|
+
| `[E_UNDO_NONE]` | `undo_last_change` found no recorded edit for the file. Nothing was changed; make an edit first. |
|
|
456
469
|
| `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown; a pure deletion checks only its first and last line). The edit was refused; the current range is returned with fresh anchors. |
|
|
457
470
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
|
|
458
471
|
| `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
|
package/index.ts
CHANGED
|
@@ -8,14 +8,17 @@ import { regCopy, regMove } from "./src/copy-move";
|
|
|
8
8
|
import { regGrep } from "./src/grep";
|
|
9
9
|
import { regUndo, clearUndo } from "./src/replace-undo";
|
|
10
10
|
import { regRead, fmtReadPreview } from "./src/read";
|
|
11
|
+
import { ANCHOR_TOOL_NAMES, modelDisabled, type ModelLike } from "./src/model-gate";
|
|
11
12
|
import { buildAutoReadAllInjection, autoReadAllBudget } from "./src/auto-read-all";
|
|
12
13
|
import { clearAutoReadAllComplete } from "./src/auto-read-all-state";
|
|
13
14
|
import type { RMetrics } from "./src/replace-response";
|
|
14
15
|
import type { ReplaceDetails } from "./src/replace";
|
|
15
16
|
import { extractHints, extractWarnings } from "./src/replace-render";
|
|
16
17
|
import { MAX_HASH_LINES } from "./src/hashline";
|
|
18
|
+
import { withStructuredText } from "./src/structured";
|
|
17
19
|
import type { AutoReadAllMode } from "./src/config";
|
|
18
20
|
import {
|
|
21
|
+
readConfig,
|
|
19
22
|
readConfigWithStatus,
|
|
20
23
|
toggleAutoRead,
|
|
21
24
|
cycleAutoReadAllMode,
|
|
@@ -26,6 +29,7 @@ import {
|
|
|
26
29
|
toggleStrictInput,
|
|
27
30
|
adjustDiffContextLines,
|
|
28
31
|
setAutoReadAllIgnoreFromText,
|
|
32
|
+
setDisableOnModelsFromText,
|
|
29
33
|
} from "./src/config";
|
|
30
34
|
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
31
35
|
import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
|
|
@@ -51,17 +55,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
51
55
|
regMove(pi);
|
|
52
56
|
regGrep(pi);
|
|
53
57
|
regUndo(pi);
|
|
54
|
-
registerWriteHook(pi);
|
|
58
|
+
registerWriteHook(pi, (model) => modelDisabled(model, disableOnModels));
|
|
55
59
|
|
|
56
60
|
let autoRead = true;
|
|
57
61
|
let autoReadAll: AutoReadAllMode = "off";
|
|
58
62
|
let autoReadAllIgnore: string[] = [];
|
|
63
|
+
let disableOnModels: string[] = [];
|
|
59
64
|
let autoReadAllInjected = false;
|
|
60
65
|
let grepWasActive = false;
|
|
66
|
+
const baseAnchorTools = new Set<string>();
|
|
67
|
+
let gateApplied = false;
|
|
61
68
|
|
|
62
69
|
async function refreshEditTools(): Promise<void> {
|
|
63
70
|
try {
|
|
64
|
-
const flags = await currentEditFlags();
|
|
71
|
+
const flags = await currentEditFlags(pi.getActiveTools().includes("codemode"));
|
|
65
72
|
regRead(pi, flags);
|
|
66
73
|
regReplace(pi, flags);
|
|
67
74
|
regReplaceMatch(pi, flags);
|
|
@@ -75,9 +82,37 @@ export default function (pi: ExtensionAPI): void {
|
|
|
75
82
|
}
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
async function syncModelGate(model: ModelLike | undefined): Promise<void> {
|
|
86
|
+
if (disableOnModels.length > 0 && modelDisabled(model, disableOnModels)) {
|
|
87
|
+
const active = pi.getActiveTools();
|
|
88
|
+
const next = active.filter((tool) => !ANCHOR_TOOL_NAMES.includes(tool));
|
|
89
|
+
if (grepWasActive && !next.includes("grep")) next.push("grep");
|
|
90
|
+
pi.setActiveTools(next);
|
|
91
|
+
gateApplied = true;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!gateApplied) return;
|
|
95
|
+
gateApplied = false;
|
|
96
|
+
const config = await readConfig();
|
|
97
|
+
const enabled = ANCHOR_TOOL_NAMES.filter((tool) => {
|
|
98
|
+
if (!baseAnchorTools.has(tool)) return false;
|
|
99
|
+
if (tool === "replace_match") return config.replaceMatchEnabled !== false;
|
|
100
|
+
if (tool === "copy" || tool === "move") return config.copyMoveEnabled !== false;
|
|
101
|
+
if (tool === "anchor_grep") return config.anchorGrepEnabled === true;
|
|
102
|
+
return true;
|
|
103
|
+
});
|
|
104
|
+
let next = [...new Set([...pi.getActiveTools(), ...enabled])];
|
|
105
|
+
if (enabled.includes("anchor_grep") && grepWasActive) next = next.filter((tool) => tool !== "grep");
|
|
106
|
+
pi.setActiveTools(next);
|
|
107
|
+
}
|
|
108
|
+
|
|
78
109
|
pi.on("session_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
|
|
79
110
|
const active = pi.getActiveTools();
|
|
80
111
|
grepWasActive = active.includes("grep");
|
|
112
|
+
baseAnchorTools.clear();
|
|
113
|
+
for (const tool of ANCHOR_TOOL_NAMES) {
|
|
114
|
+
if (active.includes(tool)) baseAnchorTools.add(tool);
|
|
115
|
+
}
|
|
81
116
|
pi.setActiveTools(active.filter((t) => t !== "edit"));
|
|
82
117
|
await initHasher();
|
|
83
118
|
loadHashStore()
|
|
@@ -97,6 +132,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
97
132
|
autoRead = config.autoRead;
|
|
98
133
|
autoReadAll = config.autoReadAll ?? "off";
|
|
99
134
|
autoReadAllIgnore = config.autoReadAllIgnore ?? [];
|
|
135
|
+
disableOnModels = config.disableOnModels ?? [];
|
|
100
136
|
const sessionBranch = (ctx as { sessionManager?: { getBranch?: () => Array<{ type?: string; customType?: string }> } }).sessionManager?.getBranch?.() ?? [];
|
|
101
137
|
autoReadAllInjected = sessionBranch.some((entry) => entry.type === "custom_message" && entry.customType === AUTO_READ_ALL_CUSTOM_TYPE);
|
|
102
138
|
await refreshEditTools();
|
|
@@ -108,6 +144,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
108
144
|
return true;
|
|
109
145
|
}),
|
|
110
146
|
);
|
|
147
|
+
await syncModelGate(ctx.model);
|
|
111
148
|
const debugValue = process.env.PI_HASHLINE_DEBUG;
|
|
112
149
|
if (debugValue === "1" || debugValue === "true") {
|
|
113
150
|
ctx.ui.notify(`Hashline Edit mode active`, "info");
|
|
@@ -123,9 +160,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
123
160
|
console.error("Failed to release anchor registry session:", error);
|
|
124
161
|
}
|
|
125
162
|
});
|
|
163
|
+
pi.on("model_select", async (event, ctx) => withAnchorSession(ctx, async () => {
|
|
164
|
+
await syncModelGate(event.model);
|
|
165
|
+
}));
|
|
126
166
|
|
|
127
167
|
pi.on("before_agent_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
|
|
168
|
+
await syncModelGate(ctx.model);
|
|
128
169
|
if (autoReadAll === "off" || autoReadAllInjected) return;
|
|
170
|
+
if (modelDisabled(ctx.model, disableOnModels)) return;
|
|
129
171
|
autoReadAllInjected = true;
|
|
130
172
|
try {
|
|
131
173
|
const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model), autoReadAll, autoReadAllIgnore, sessionKeyFor(ctx));
|
|
@@ -139,7 +181,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
139
181
|
}));
|
|
140
182
|
|
|
141
183
|
pi.registerCommand("hashline-config", {
|
|
142
|
-
description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, copy/move, replace_match, path, strict input)",
|
|
184
|
+
description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, disable on models, diff context, grep, copy/move, replace_match, path, strict input)",
|
|
143
185
|
handler: async (_args, ctx) => {
|
|
144
186
|
if (!ctx.hasUI) {
|
|
145
187
|
ctx.ui.notify("/hashline-config requires interactive mode", "error");
|
|
@@ -154,25 +196,35 @@ export default function (pi: ExtensionAPI): void {
|
|
|
154
196
|
if (key === "autoRead") autoRead = await toggleAutoRead();
|
|
155
197
|
else if (key === "autoReadAll") { autoReadAll = await cycleAutoReadAllMode(); autoReadAllInjected = false; }
|
|
156
198
|
else if (key === "autoReadAllIgnore") autoReadAllIgnore = await setAutoReadAllIgnoreFromText(value ?? "");
|
|
199
|
+
else if (key === "disableOnModels") disableOnModels = await setDisableOnModelsFromText(value ?? "");
|
|
157
200
|
else if (key === "diffContextLines") await adjustDiffContextLines(delta ?? 1);
|
|
158
201
|
else if (key === "anchorGrepEnabled") {
|
|
159
202
|
const enabled = await toggleAnchorGrep();
|
|
160
203
|
const active = pi.getActiveTools();
|
|
161
204
|
pi.setActiveTools(enabled ? [...new Set([...active.filter((t) => t !== "grep"), "anchor_grep"])] : [...new Set([...active.filter((t) => t !== "anchor_grep"), ...(grepWasActive ? ["grep"] : [])])]);
|
|
205
|
+
if (enabled) baseAnchorTools.add("anchor_grep");
|
|
206
|
+
else baseAnchorTools.delete("anchor_grep");
|
|
162
207
|
}
|
|
163
208
|
else if (key === "copyMoveEnabled") {
|
|
164
209
|
const enabled = await toggleCopyMove();
|
|
165
210
|
const active = pi.getActiveTools();
|
|
166
211
|
pi.setActiveTools(enabled ? [...new Set([...active, "copy", "move"])] : active.filter((t) => t !== "copy" && t !== "move"));
|
|
212
|
+
for (const tool of ["copy", "move"]) {
|
|
213
|
+
if (enabled) baseAnchorTools.add(tool);
|
|
214
|
+
else baseAnchorTools.delete(tool);
|
|
215
|
+
}
|
|
167
216
|
}
|
|
168
217
|
else if (key === "replaceMatchEnabled") {
|
|
169
218
|
const enabled = await toggleReplaceMatch();
|
|
170
219
|
const active = pi.getActiveTools();
|
|
171
220
|
pi.setActiveTools(enabled ? [...new Set([...active, "replace_match"])] : active.filter((t) => t !== "replace_match"));
|
|
221
|
+
if (enabled) baseAnchorTools.add("replace_match");
|
|
222
|
+
else baseAnchorTools.delete("replace_match");
|
|
172
223
|
}
|
|
173
224
|
else if (key === "requirePath") await toggleRequirePath();
|
|
174
225
|
else if (key === "strictInput") await toggleStrictInput();
|
|
175
226
|
await refreshEditTools();
|
|
227
|
+
await syncModelGate(ctx.model);
|
|
176
228
|
},
|
|
177
229
|
});
|
|
178
230
|
await overlay.load();
|
|
@@ -208,6 +260,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
208
260
|
});
|
|
209
261
|
pi.on("tool_result", async (event, ctx) => withAnchorSession(ctx, async () => {
|
|
210
262
|
if (event.isError) return;
|
|
263
|
+
const gated = modelDisabled(ctx.model, disableOnModels);
|
|
211
264
|
|
|
212
265
|
if (event.toolName === "write") {
|
|
213
266
|
const writtenPath = (event.input as Record<string, unknown>)?.path;
|
|
@@ -221,7 +274,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
221
274
|
console.error("Failed to clear undo after write:", error);
|
|
222
275
|
}
|
|
223
276
|
}
|
|
224
|
-
if (!autoRead) return;
|
|
277
|
+
if (!autoRead || gated) return;
|
|
225
278
|
if (typeof writtenPath !== "string") return;
|
|
226
279
|
try {
|
|
227
280
|
resolvedPath ??= (await resolveInCwd(writtenPath, ctx.cwd)).resolved;
|
|
@@ -268,7 +321,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
268
321
|
event.toolName !== "move" &&
|
|
269
322
|
event.toolName !== "undo_last_change"
|
|
270
323
|
) return;
|
|
271
|
-
if (!autoRead) return;
|
|
324
|
+
if (!autoRead || gated) return;
|
|
272
325
|
|
|
273
326
|
const metrics = (event.details as { metrics?: RMetrics } | undefined)?.metrics;
|
|
274
327
|
if (metrics?.classification === "noop") return;
|
|
@@ -294,6 +347,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
294
347
|
const notices = [warnings, hints].filter((part): part is string => part !== undefined).join("\n\n");
|
|
295
348
|
const emptyDiffNotice = "[post-edit] applied successfully; the diff is empty (no content change: whitespace or line endings only).";
|
|
296
349
|
const noticeText = hasDiff ? (notices ? `${diff}\n\n${notices}` : diff) : notices ? `${emptyDiffNotice}\n\n${notices}` : emptyDiffNotice;
|
|
350
|
+
const structured = (event as { structuredContent?: unknown }).structuredContent;
|
|
297
351
|
return {
|
|
298
352
|
content: [
|
|
299
353
|
{
|
|
@@ -301,6 +355,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
301
355
|
text: noticeText,
|
|
302
356
|
},
|
|
303
357
|
],
|
|
358
|
+
...(structured !== undefined ? { structuredContent: withStructuredText(structured, noticeText) } : {}),
|
|
304
359
|
};
|
|
305
360
|
}));
|
|
306
361
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "6.0
|
|
3
|
+
"version": "6.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"xxhash-wasm": "^1.1.0"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
47
|
+
"@earendil-works/pi-coding-agent": ">=0.99.0",
|
|
48
48
|
"@earendil-works/pi-tui": "*",
|
|
49
49
|
"typebox": "*"
|
|
50
50
|
},
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"typecheck": "tsc --noEmit"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
63
|
+
"@earendil-works/pi-coding-agent": "^1.0.3",
|
|
64
64
|
"@eslint/js": "^10.0.1",
|
|
65
65
|
"@types/node": "^24",
|
|
66
66
|
"@vitest/coverage-v8": "^4.1.11",
|
|
@@ -1,2 +1,3 @@
|
|
|
1
|
+
- `undo_last_change`: history is one slot per file, not a stack; a second undo returns `E_UNDO_NONE`.
|
|
1
2
|
- `undo_last_change`: a `write` clears the history, so undo right after a bad diff — review the diff's `-anchor│` rows first to confirm what you're restoring.
|
|
2
3
|
- `undo_last_change`: a cross-file `move` records one undo entry per file; undo both the source and the destination to revert the whole move, because undoing one side alone leaves the moved lines duplicated or missing.
|
package/src/batch.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { PipelineResult } from "./replace";
|
|
|
25
25
|
import { genPatch } from "./replace-diff";
|
|
26
26
|
import { saveUndo } from "./replace-undo";
|
|
27
27
|
import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
|
|
28
|
+
import { toEditVerb, withStructuredText, type EditStructured } from "./structured";
|
|
28
29
|
import { serveRows, servedHashesFromDiff } from "./served";
|
|
29
30
|
import { abortIf, assertByteLimit, assertLineLimit, errCode, isRec, splitLines } from "./utils";
|
|
30
31
|
|
|
@@ -472,6 +473,20 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
|
|
|
472
473
|
classification: piece.noop ? "noop" : "applied",
|
|
473
474
|
...(piece.noop ? {} : { added_lines: added, removed_lines: piece.end - piece.start + 1 }),
|
|
474
475
|
};
|
|
476
|
+
const structuredContent: EditStructured = {
|
|
477
|
+
ok: true,
|
|
478
|
+
kind: "edit",
|
|
479
|
+
verb: toEditVerb(piece.kind),
|
|
480
|
+
classification: piece.noop ? "noop" : "applied",
|
|
481
|
+
path: member.target,
|
|
482
|
+
text: `In batch ${member.display} (queued)`,
|
|
483
|
+
diff: "",
|
|
484
|
+
warnings: [],
|
|
485
|
+
hints: [],
|
|
486
|
+
firstChangedLine: piece.noop ? null : piece.start,
|
|
487
|
+
anchors: [],
|
|
488
|
+
anchorsOmitted: false,
|
|
489
|
+
};
|
|
475
490
|
return {
|
|
476
491
|
content: [
|
|
477
492
|
{
|
|
@@ -488,6 +503,7 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
|
|
|
488
503
|
metrics,
|
|
489
504
|
batch: { id: member.display, size: member.size, last: false, total: member.total },
|
|
490
505
|
},
|
|
506
|
+
structuredContent,
|
|
491
507
|
};
|
|
492
508
|
}
|
|
493
509
|
|
|
@@ -1044,6 +1060,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
1044
1060
|
: "";
|
|
1045
1061
|
const undoNote = sourceFiles.length > 0 ? "one undo reverts the destination edits" : "one undo reverts them";
|
|
1046
1062
|
changed.content[0]!.text = `${header}\n${changed.content[0]!.text}\nBatch ${member.display}: ${executed} edit${executed === 1 ? "" : "s"} applied as one commit; ${undoNote}.${sourceNote}`;
|
|
1063
|
+
changed.structuredContent = withStructuredText(changed.structuredContent, changed.content[0]!.text);
|
|
1047
1064
|
changed.details.batch = { id: member.display, size: member.size, last: true, total: member.total };
|
|
1048
1065
|
return changed;
|
|
1049
1066
|
}
|
|
@@ -1065,10 +1082,12 @@ async function combinedNoop(path: string, member: PlannedMember, runtime: BatchS
|
|
|
1065
1082
|
removedLines: 0,
|
|
1066
1083
|
},
|
|
1067
1084
|
warnings: dedupeWarnings(warnings),
|
|
1085
|
+
verb: "edited",
|
|
1068
1086
|
},
|
|
1069
1087
|
"Batch",
|
|
1070
1088
|
);
|
|
1071
1089
|
noop.content[0]!.text += `\nBatch ${member.display}: ${executed} edits produced no net change; undo history preserved.`;
|
|
1090
|
+
noop.structuredContent = withStructuredText(noop.structuredContent, noop.content[0]!.text);
|
|
1072
1091
|
noop.details.batch = { id: member.display, size: member.size, last: true, total: member.total };
|
|
1073
1092
|
return noop;
|
|
1074
1093
|
}
|
package/src/commit.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { lineHashes } from "./hashline";
|
|
|
12
12
|
import { spanForEdit } from "./replace";
|
|
13
13
|
import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
|
|
14
14
|
import { applyEndingOverrides, joinSeparators, separatorsForSpans } from "./line-endings";
|
|
15
|
+
import { toEditVerb } from "./structured";
|
|
15
16
|
export interface CommitMeta {
|
|
16
17
|
editAnchors?: [string, string];
|
|
17
18
|
anchorCarry?: number;
|
|
@@ -62,6 +63,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
62
63
|
removedLines: 0,
|
|
63
64
|
},
|
|
64
65
|
warnings,
|
|
66
|
+
verb: toEditVerb(meta.verb),
|
|
65
67
|
},
|
|
66
68
|
meta.noopNoun,
|
|
67
69
|
);
|
package/src/config-ui.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
|
|
|
2
2
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { readConfig, type Config } from "./config";
|
|
4
4
|
|
|
5
|
-
export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "requirePath" | "strictInput" | "diffContextLines";
|
|
5
|
+
export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "requirePath" | "strictInput" | "diffContextLines" | "disableOnModels";
|
|
6
6
|
|
|
7
7
|
export interface ConfigRow {
|
|
8
8
|
key: ConfigToggleKey;
|
|
@@ -12,7 +12,7 @@ export interface ConfigRow {
|
|
|
12
12
|
mode?: string;
|
|
13
13
|
cycle?: string[];
|
|
14
14
|
value?: number;
|
|
15
|
-
|
|
15
|
+
entries?: string[];
|
|
16
16
|
disabled?: boolean;
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -20,13 +20,14 @@ export function configRows(config: Config): ConfigRow[] {
|
|
|
20
20
|
return [
|
|
21
21
|
{ key: "autoRead", label: "Auto-read", hint: "Anchors after write + post-edit diffs", enabled: config.autoRead !== false },
|
|
22
22
|
{ key: "autoReadAll", label: "Auto-read all", hint: "Attach files on the first turn: off, on, git (git repos only)", enabled: (config.autoReadAll ?? "off") !== "off", mode: config.autoReadAll ?? "off", cycle: ["off", "on", "git"] },
|
|
23
|
-
{ key: "autoReadAllIgnore", label: "Ignore folders/files", hint: "Extra folders, files, or globs skipped by auto-read all (comma-separated)", enabled: (config.autoReadAllIgnore ?? []).length > 0,
|
|
23
|
+
{ key: "autoReadAllIgnore", label: "Ignore folders/files", hint: "Extra folders, files, or globs skipped by auto-read all (comma-separated)", enabled: (config.autoReadAllIgnore ?? []).length > 0, entries: config.autoReadAllIgnore ?? [] },
|
|
24
24
|
{ key: "diffContextLines", label: "Diff context", hint: "Surrounding lines in post-edit diffs (needs Auto-read)", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1, disabled: config.autoRead === false },
|
|
25
25
|
{ key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
|
|
26
26
|
{ key: "copyMoveEnabled", label: "Copy/move", hint: "copy and move tools (both off while disabled)", enabled: config.copyMoveEnabled !== false },
|
|
27
27
|
{ key: "requirePath", label: "Require path", hint: "replace, insert, copy, move need path (RPC visibility)", enabled: config.requirePath === true },
|
|
28
28
|
{ key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
|
|
29
29
|
{ key: "replaceMatchEnabled", label: "Replace match", hint: "replace_match tool (off while disabled)", enabled: config.replaceMatchEnabled !== false },
|
|
30
|
+
{ key: "disableOnModels", label: "Disable on models", hint: "Model globs (provider/id, model id, or api) that turn off read and the hashline tools (comma-separated)", enabled: (config.disableOnModels ?? []).length > 0, entries: config.disableOnModels ?? [] },
|
|
30
31
|
];
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -45,19 +46,19 @@ function numberBox(theme: Theme, value: number, disabled?: boolean): string {
|
|
|
45
46
|
if (disabled) return theme.fg("dim", `[${value}]`);
|
|
46
47
|
return theme.fg("accent", `[${value}]`);
|
|
47
48
|
}
|
|
48
|
-
function
|
|
49
|
-
return theme.fg("accent", `[${
|
|
49
|
+
function listBox(theme: Theme, entries: string[]): string {
|
|
50
|
+
return theme.fg("accent", `[${entries.length}]`);
|
|
50
51
|
}
|
|
51
|
-
function
|
|
52
|
-
if (
|
|
53
|
-
const joined =
|
|
52
|
+
function formatList(entries: string[]): string {
|
|
53
|
+
if (entries.length === 0) return "(empty)";
|
|
54
|
+
const joined = entries.join(", ");
|
|
54
55
|
return joined.length > 40 ? `${joined.slice(0, 37)}...` : joined;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
export class HashlineConfigOverlay {
|
|
58
59
|
private rows: ConfigRow[];
|
|
59
60
|
private selected = 0;
|
|
60
|
-
private
|
|
61
|
+
private editingList = false;
|
|
61
62
|
private editBuffer = "";
|
|
62
63
|
|
|
63
64
|
constructor(private readonly opts: { tui: { requestRender(force?: boolean): void }; theme: Theme; done: () => void; onToggle: (key: ConfigToggleKey, delta?: number, value?: string) => Promise<void> }) {
|
|
@@ -72,37 +73,37 @@ export class HashlineConfigOverlay {
|
|
|
72
73
|
this.opts.tui.requestRender(true);
|
|
73
74
|
void this.opts.onToggle(row.key, delta, value).then(async () => {
|
|
74
75
|
this.rows = configRows(await readConfig());
|
|
75
|
-
this.
|
|
76
|
+
this.editingList = false;
|
|
76
77
|
this.opts.tui.requestRender(true);
|
|
77
78
|
}).catch((error: unknown) => {
|
|
78
79
|
console.error("Failed to toggle hashline setting:", error);
|
|
79
80
|
});
|
|
80
81
|
}
|
|
81
|
-
private
|
|
82
|
-
this.
|
|
83
|
-
this.editBuffer = (row.
|
|
82
|
+
private startListEdit(row: ConfigRow): void {
|
|
83
|
+
this.editingList = true;
|
|
84
|
+
this.editBuffer = (row.entries ?? []).join(", ");
|
|
84
85
|
this.opts.tui.requestRender(true);
|
|
85
86
|
}
|
|
86
|
-
private
|
|
87
|
+
private commitListEdit(): void {
|
|
87
88
|
const row = this.rows[this.selected];
|
|
88
|
-
if (!row || row.
|
|
89
|
-
this.
|
|
89
|
+
if (!row || row.entries === undefined) {
|
|
90
|
+
this.editingList = false;
|
|
90
91
|
return;
|
|
91
92
|
}
|
|
92
93
|
const value = this.editBuffer;
|
|
93
|
-
this.
|
|
94
|
+
this.editingList = false;
|
|
94
95
|
this.runToggle(row, undefined, value);
|
|
95
96
|
}
|
|
96
|
-
private
|
|
97
|
-
this.
|
|
97
|
+
private cancelListEdit(): void {
|
|
98
|
+
this.editingList = false;
|
|
98
99
|
this.opts.tui.requestRender(true);
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
private toggleSelected(): void {
|
|
102
103
|
const row = this.rows[this.selected];
|
|
103
104
|
if (!row || row.disabled) return;
|
|
104
|
-
if (row.
|
|
105
|
-
this.
|
|
105
|
+
if (row.entries !== undefined) {
|
|
106
|
+
this.startListEdit(row);
|
|
106
107
|
return;
|
|
107
108
|
}
|
|
108
109
|
if (row.value !== undefined) {
|
|
@@ -129,13 +130,13 @@ export class HashlineConfigOverlay {
|
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
handleInput(data: string): void {
|
|
132
|
-
if (this.
|
|
133
|
+
if (this.editingList) {
|
|
133
134
|
if (matchesKey(data, Key.escape) || data === "\x1b") {
|
|
134
|
-
this.
|
|
135
|
+
this.cancelListEdit();
|
|
135
136
|
return;
|
|
136
137
|
}
|
|
137
138
|
if (matchesKey(data, Key.enter) || data === "\r" || data === "\n") {
|
|
138
|
-
this.
|
|
139
|
+
this.commitListEdit();
|
|
139
140
|
return;
|
|
140
141
|
}
|
|
141
142
|
if (data === "\x7f" || data === "\b" || data === "\x08") {
|
|
@@ -176,8 +177,8 @@ export class HashlineConfigOverlay {
|
|
|
176
177
|
return;
|
|
177
178
|
}
|
|
178
179
|
const row = this.rows[this.selected];
|
|
179
|
-
if ((data === "e" || data === "E") && row && row.
|
|
180
|
-
this.
|
|
180
|
+
if ((data === "e" || data === "E") && row && row.entries !== undefined && !row.disabled) {
|
|
181
|
+
this.startListEdit(row);
|
|
181
182
|
return;
|
|
182
183
|
}
|
|
183
184
|
if (matchesKey(data, Key.escape) || data === "q") {
|
|
@@ -197,16 +198,16 @@ export class HashlineConfigOverlay {
|
|
|
197
198
|
lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
|
|
198
199
|
this.rows.forEach((row, index) => {
|
|
199
200
|
const cursor = index === this.selected ? theme.fg("accent", "> ") : " ";
|
|
200
|
-
const box = row.
|
|
201
|
+
const box = row.entries !== undefined ? listBox(theme, row.entries) : row.value !== undefined ? numberBox(theme, row.value, row.disabled) : row.mode !== undefined ? modeBox(theme, row.mode) : row.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
|
|
201
202
|
const label = row.disabled ? theme.fg("dim", row.label) : index === this.selected ? theme.fg("accent", theme.bold(row.label)) : row.label;
|
|
202
|
-
const suffix = row.
|
|
203
|
+
const suffix = row.entries !== undefined ? ` — ${row.hint}: ${formatList(row.entries)}` : ` — ${row.hint}`;
|
|
203
204
|
lines.push(padRow(theme, innerWidth, `${cursor}${box} ${label} ${theme.fg("dim", suffix)}`));
|
|
204
|
-
if (row.
|
|
205
|
+
if (row.entries !== undefined && index === this.selected && this.editingList) {
|
|
205
206
|
lines.push(padRow(theme, innerWidth, ` ${theme.fg("accent", "edit:")} ${this.editBuffer}█ ${theme.fg("dim", "(Enter save · Esc cancel · Ctrl-U clear)")}`));
|
|
206
207
|
}
|
|
207
208
|
});
|
|
208
209
|
lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
|
|
209
|
-
const footer = this.
|
|
210
|
+
const footer = this.editingList ? " type to edit · Enter save · Esc cancel" : " ↑↓ navigate · space toggle · ←/→ or -/+ adjust · e edit list · q close";
|
|
210
211
|
lines.push(padRow(theme, innerWidth, theme.fg("dim", footer)));
|
|
211
212
|
lines.push(theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
|
|
212
213
|
return lines;
|
package/src/config.ts
CHANGED
|
@@ -20,6 +20,7 @@ export interface Config {
|
|
|
20
20
|
requirePath?: boolean;
|
|
21
21
|
strictInput?: boolean;
|
|
22
22
|
diffContextLines?: number;
|
|
23
|
+
disableOnModels?: string[];
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
const DEFAULT_CONFIG: Config = {
|
|
@@ -31,7 +32,8 @@ const DEFAULT_CONFIG: Config = {
|
|
|
31
32
|
autoReadAllIgnore: [],
|
|
32
33
|
requirePath: false,
|
|
33
34
|
strictInput: false,
|
|
34
|
-
diffContextLines: DEFAULT_DIFF_CONTEXT_LINES
|
|
35
|
+
diffContextLines: DEFAULT_DIFF_CONTEXT_LINES,
|
|
36
|
+
disableOnModels: []
|
|
35
37
|
};
|
|
36
38
|
|
|
37
39
|
function parseAutoReadAllMode(value: unknown): AutoReadAllMode {
|
|
@@ -45,13 +47,13 @@ export function normalizeAutoReadAllIgnoreEntry(entry: string): string {
|
|
|
45
47
|
return entry.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").replace(/\/{2,}/g, "/");
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
|
|
50
|
+
function parseStringList(value: unknown, normalize: (entry: string) => string): string[] {
|
|
49
51
|
const raw = typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : [];
|
|
50
52
|
const seen = new Set<string>();
|
|
51
53
|
const out: string[] = [];
|
|
52
54
|
for (const item of raw) {
|
|
53
55
|
if (typeof item !== "string") continue;
|
|
54
|
-
const cleaned =
|
|
56
|
+
const cleaned = normalize(item);
|
|
55
57
|
if (cleaned.length === 0) continue;
|
|
56
58
|
const lower = cleaned.toLowerCase();
|
|
57
59
|
if (seen.has(lower)) continue;
|
|
@@ -61,6 +63,14 @@ export function parseAutoReadAllIgnore(value: unknown): string[] {
|
|
|
61
63
|
return out;
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
export function parseAutoReadAllIgnore(value: unknown): string[] {
|
|
67
|
+
return parseStringList(value, normalizeAutoReadAllIgnoreEntry);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseDisableOnModels(value: unknown): string[] {
|
|
71
|
+
return parseStringList(value, (entry) => entry.trim());
|
|
72
|
+
}
|
|
73
|
+
|
|
64
74
|
export function normalizeDiffContextLines(value: unknown): number {
|
|
65
75
|
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_DIFF_CONTEXT_LINES;
|
|
66
76
|
const floored = Math.floor(value);
|
|
@@ -83,6 +93,7 @@ function parseConfig(content: string): Config {
|
|
|
83
93
|
const strictInput = parsed.strictInput;
|
|
84
94
|
const diffContextLines = parsed.diffContextLines;
|
|
85
95
|
const autoReadAllIgnore = parsed.autoReadAllIgnore;
|
|
96
|
+
const disableOnModels = parsed.disableOnModels;
|
|
86
97
|
return {
|
|
87
98
|
autoRead: typeof autoRead === "boolean" ? autoRead : DEFAULT_CONFIG.autoRead,
|
|
88
99
|
anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
|
|
@@ -93,6 +104,7 @@ function parseConfig(content: string): Config {
|
|
|
93
104
|
strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
|
|
94
105
|
diffContextLines: normalizeDiffContextLines(diffContextLines),
|
|
95
106
|
autoReadAllIgnore: parseAutoReadAllIgnore(autoReadAllIgnore),
|
|
107
|
+
disableOnModels: parseDisableOnModels(disableOnModels),
|
|
96
108
|
};
|
|
97
109
|
}
|
|
98
110
|
|
|
@@ -243,3 +255,14 @@ export async function setAutoReadAllIgnore(dirs: string[]): Promise<string[]> {
|
|
|
243
255
|
export async function setAutoReadAllIgnoreFromText(text: string): Promise<string[]> {
|
|
244
256
|
return setAutoReadAllIgnore(text.split(","));
|
|
245
257
|
}
|
|
258
|
+
export async function setDisableOnModels(patterns: string[]): Promise<string[]> {
|
|
259
|
+
let next: string[] = [];
|
|
260
|
+
await updateConfig((c) => {
|
|
261
|
+
next = parseDisableOnModels(patterns);
|
|
262
|
+
c.disableOnModels = next;
|
|
263
|
+
});
|
|
264
|
+
return next;
|
|
265
|
+
}
|
|
266
|
+
export async function setDisableOnModelsFromText(text: string): Promise<string[]> {
|
|
267
|
+
return setDisableOnModels(text.split(","));
|
|
268
|
+
}
|