pi-hashline-edit-pro 4.3.2 → 4.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -4
- package/index.ts +8 -3
- package/package.json +1 -1
- package/prompts/grep-guidelines.md +0 -1
- package/prompts/grep.md +1 -1
- package/prompts/insert-guidelines.md +1 -2
- package/prompts/insert.md +1 -1
- package/prompts/read-guidelines.md +2 -3
- package/prompts/replace-guidelines.md +4 -4
- package/prompts/replace.md +1 -1
- package/src/auto-read-all-state.ts +13 -0
- package/src/auto-read-all.ts +49 -27
- package/src/batch.ts +23 -32
- package/src/config-ui.ts +79 -7
- package/src/config.ts +35 -0
- package/src/edit-common.ts +25 -7
- package/src/file-reader.ts +2 -3
- package/src/grep.ts +176 -20
- package/src/hash-store.ts +21 -7
- package/src/hashline/apply.ts +1 -1
- package/src/hashline/hash.ts +3 -15
- package/src/hashline/hasher.ts +15 -1
- package/src/insert.ts +1 -1
- package/src/paths.ts +5 -1
- package/src/payload-contract.ts +2 -2
- package/src/read.ts +20 -1
- package/src/replace-undo.ts +10 -4
- package/src/replace.ts +2 -3
- package/src/utils.ts +1 -1
package/README.md
CHANGED
|
@@ -162,11 +162,12 @@ Auto-read keeps the same 50KB and 2000-line budget as `read`. Auto-read and Diff
|
|
|
162
162
|
|
|
163
163
|
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.
|
|
164
164
|
|
|
165
|
-
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.
|
|
165
|
+
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.
|
|
166
166
|
|
|
167
|
-
Each attached file
|
|
167
|
+
Each attached file is shown as `=== path ===` followed by its `anchor│content` rows — edit directly from the attachment with replace and insert, no read needed. Files attach whole.
|
|
168
|
+
A coverage line after the header reports the complete count. A `[files complete: [...] omitted: [...]]` line right after it lists every attached complete file and omitted file in one place — check that line instead of scanning sections.
|
|
168
169
|
|
|
169
|
-
The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"on"`, or `"git"`; older configs with `true` or `false` are read as `"on"` or `"off"`).
|
|
170
|
+
The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"on"`, or `"git"`; older configs with `true` or `false` are read as `"on"` or `"off"`). Extra folders are ignored via `/hashline-config` as Ignore folders and via `config.json` as `autoReadAllIgnore` (array of folder names, for example `["docs", "tmp"]`); a single-segment entry skips any path containing that folder (case-insensitive), while an entry with a slash (for example `"src/tmp"`) skips that folder path. Custom ignores are counted with the vendor/name/pattern skips in the footer.
|
|
170
171
|
|
|
171
172
|
## Tool result details
|
|
172
173
|
|
|
@@ -183,7 +184,7 @@ All five tools return machine-readable metadata in `details` alongside the model
|
|
|
183
184
|
|
|
184
185
|
| Command | Description |
|
|
185
186
|
| --- | --- |
|
|
186
|
-
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, diff context lines, `anchor_grep` tool, required `path`, strict input, and boundary dedup. Persists across sessions. |
|
|
187
|
+
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders, diff context lines, `anchor_grep` tool, required `path`, strict input, and boundary dedup. Persists across sessions. |
|
|
187
188
|
| `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
|
|
188
189
|
|
|
189
190
|
Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
|
|
@@ -192,6 +193,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
192
193
|
{
|
|
193
194
|
"autoRead": true,
|
|
194
195
|
"autoReadAll": "off",
|
|
196
|
+
"autoReadAllIgnore": [],
|
|
195
197
|
"anchorGrepEnabled": true,
|
|
196
198
|
"requirePath": false,
|
|
197
199
|
"strictInput": false,
|
|
@@ -252,6 +254,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
|
|
|
252
254
|
| `[E_BATCH_OVERLAP]` | Batched `replace`/`insert` calls target overlapping ranges; the whole batch was refused. One `before` plus one `after` insert on the same anchor line is not an overlap. Retry with disjoint ranges. |
|
|
253
255
|
| `[E_OP_ABORTED]` | An edit aborted (a same-message batch member failed, or the file changed or was deleted after the edit started). Nothing was written. Fix the sibling failure and retry the batch, otherwise call `read` for fresh anchors and retry. The abort names the failing call and its error code when one is known. |
|
|
254
256
|
| `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
|
|
257
|
+
| `[E_AUTO_READ_ALL]` | File already attached and unchanged since this session's start; the attached content is still exact. |
|
|
255
258
|
|
|
256
259
|
## Troubleshooting
|
|
257
260
|
|
package/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
toggleStrictInput,
|
|
22
22
|
cycleBoundaryDedupMode,
|
|
23
23
|
adjustDiffContextLines,
|
|
24
|
+
setAutoReadAllIgnoreFromText,
|
|
24
25
|
} from "./src/config";
|
|
25
26
|
import { loadHashStore, persistSnapshot, pruneMissing } from "./src/hash-store";
|
|
26
27
|
import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession } from "./src/anchor-registry";
|
|
@@ -47,6 +48,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
47
48
|
|
|
48
49
|
let autoRead = true;
|
|
49
50
|
let autoReadAll: AutoReadAllMode = "off";
|
|
51
|
+
let autoReadAllIgnore: string[] = [];
|
|
50
52
|
let autoReadAllInjected = false;
|
|
51
53
|
let grepWasActive = false;
|
|
52
54
|
|
|
@@ -56,6 +58,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
56
58
|
regRead(pi, flags);
|
|
57
59
|
regReplace(pi, flags);
|
|
58
60
|
regInsert(pi, flags);
|
|
61
|
+
regUndo(pi, flags);
|
|
59
62
|
} catch (error) {
|
|
60
63
|
console.error("Failed to refresh edit tools:", error);
|
|
61
64
|
}
|
|
@@ -82,6 +85,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
82
85
|
if (corrupted && (ctx as { hasUI?: boolean }).hasUI) ctx.ui.notify("Hashline config was corrupt and was reset to defaults", "warning");
|
|
83
86
|
autoRead = config.autoRead;
|
|
84
87
|
autoReadAll = config.autoReadAll ?? "off";
|
|
88
|
+
autoReadAllIgnore = config.autoReadAllIgnore ?? [];
|
|
85
89
|
const sessionBranch = (ctx as { sessionManager?: { getBranch?: () => Array<{ type?: string; customType?: string }> } }).sessionManager?.getBranch?.() ?? [];
|
|
86
90
|
autoReadAllInjected = sessionBranch.some((entry) => entry.type === "custom_message" && entry.customType === AUTO_READ_ALL_CUSTOM_TYPE);
|
|
87
91
|
await refreshEditTools();
|
|
@@ -109,7 +113,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
109
113
|
if (autoReadAll === "off" || autoReadAllInjected) return;
|
|
110
114
|
autoReadAllInjected = true;
|
|
111
115
|
try {
|
|
112
|
-
const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model), autoReadAll);
|
|
116
|
+
const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model), autoReadAll, autoReadAllIgnore);
|
|
113
117
|
if (!injection) return;
|
|
114
118
|
if (ctx.hasUI) ctx.ui.notify(`Auto-read all: attached ${injection.files} file(s) with anchors`, "info");
|
|
115
119
|
return { message: { customType: AUTO_READ_ALL_CUSTOM_TYPE, content: injection.text, display: false } };
|
|
@@ -120,7 +124,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
120
124
|
}));
|
|
121
125
|
|
|
122
126
|
pi.registerCommand("hashline-config", {
|
|
123
|
-
description: "Open the hashline settings window (auto-read, auto-read all, diff context, grep, path, strict input, dedup)",
|
|
127
|
+
description: "Open the hashline settings window (auto-read, auto-read all, ignore folders, diff context, grep, path, strict input, dedup)",
|
|
124
128
|
handler: async (_args, ctx) => {
|
|
125
129
|
if (!ctx.hasUI) {
|
|
126
130
|
ctx.ui.notify("/hashline-config requires interactive mode", "error");
|
|
@@ -131,9 +135,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
131
135
|
tui,
|
|
132
136
|
theme,
|
|
133
137
|
done,
|
|
134
|
-
onToggle: async (key, delta) => {
|
|
138
|
+
onToggle: async (key, delta, value) => {
|
|
135
139
|
if (key === "autoRead") autoRead = await toggleAutoRead();
|
|
136
140
|
else if (key === "autoReadAll") { autoReadAll = await cycleAutoReadAllMode(); autoReadAllInjected = false; }
|
|
141
|
+
else if (key === "autoReadAllIgnore") autoReadAllIgnore = await setAutoReadAllIgnoreFromText(value ?? "");
|
|
137
142
|
else if (key === "diffContextLines") await adjustDiffContextLines(delta ?? 1);
|
|
138
143
|
else if (key === "anchorGrepEnabled") {
|
|
139
144
|
const enabled = await toggleAnchorGrep();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.3",
|
|
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",
|
|
@@ -1,3 +1,2 @@
|
|
|
1
1
|
- `anchor_grep`: hits and `context` rows come back as `lineNumber │ anchor│content` — target them with `replace`/`insert` directly; `lineNumber` enables jump-to-line.
|
|
2
|
-
- `anchor_grep`: `path` takes a file or folder (default cwd), `glob` like `*.ts` filters, `literal:true` for literal text, `context:N` for surrounding lines.
|
|
3
2
|
- `anchor_grep`: binary/image files are skipped silently.
|
package/prompts/grep.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Search text files with ripgrep. Hits and context lines come back as `lineNumber │ anchor│content` rows, editable with replace or insert without a new read; the `=== path ===` header and line numbers locate the match. Searches respect `.gitignore`, always skip `.git`, and skip binary and image files silently. A match over 500 bytes is shown as a `...` fragment around the hit, but its anchor still covers the whole line. When
|
|
1
|
+
Search text files with ripgrep. Hits and context lines come back as `lineNumber │ anchor│content` rows, editable with replace or insert without a new read; the `=== path ===` header and line numbers locate the match. Searches respect `.gitignore`, always skip `.git`, and skip binary and image files silently. A match over 500 bytes is shown as a `...` fragment around the hit, but its anchor still covers the whole line. When cut by match limit, raise `limit` (default 100); when cut by rows/bytes, refine `pattern` / narrow path.
|
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
- `insert`: a batch may pair one `before` and one `after` on the same anchor line; the pair composes into a single insertion. Any other same-line pair is an overlap.
|
|
1
|
+
|
package/prompts/insert.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Insert lines after or before one existing line in a text file, addressed by a bare anchor from
|
|
1
|
+
Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes, no embedded newlines. Lines are added literally, even when they duplicate neighbors. Multiple `replace`/`insert` calls on the same file in one message form one batch per file: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
|
|
@@ -1,3 +1,2 @@
|
|
|
1
|
-
- `read`:
|
|
2
|
-
- `read`:
|
|
3
|
-
- `read`: call again after an edit when you need anchors you lack — post-edit diff `+anchor│`/` anchor│` rows and any served `anchor│content` rows already carry fresh anchors for the changed range.
|
|
1
|
+
- `read`: call again after an edit when you need anchors you lack — post-edit diff `+anchor│`/` anchor│` rows and any served `anchor│content` rows already carry fresh anchors for the changed range.
|
|
2
|
+
- `read`: `E_AUTO_READ_ALL` on an attached file means its content is still exactly as it was when attached at the start of this session.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
- `replace`: edit with `replace`/`insert`, not `sed -i` or heredocs — anchor edits are verified against what was shown and undoable.
|
|
2
|
-
- `replace`: `replacement_lines`
|
|
3
|
-
- `replace`:
|
|
4
|
-
- `replace`:
|
|
5
|
-
- `replace`: if `replacement_lines` re-include the boundary line adjacent to the range, it is deduplicated automatically
|
|
2
|
+
- `replace`: `replacement_lines` `[""]` is one blank line; pasted `anchor│` prefixes are stripped automatically (single line: same anchor for `remove_from` and `remove_to`).
|
|
3
|
+
- `replace`: never anchor on `-anchor│` rows of the post-edit diff, those anchors were freed by the edit. Check each batch diff before the next turn's edits on that file.
|
|
4
|
+
- `replace`: same-file same-message batches must target disjoint ranges; an overlap or member failure aborts only that file's batch unwritten. Unresolvable calls stay solo and don't abort.
|
|
5
|
+
- `replace`: if `replacement_lines` re-include the boundary line adjacent to the range, it is deduplicated automatically (not editable, never use `dedup` as an anchor).
|
package/prompts/replace.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from
|
|
1
|
+
Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes and no embedded newlines; `[]` deletes the range. Anchor follow-up edits on the `+anchor│` and ` anchor│` rows of the post-edit diff instead of re-reading. Multiple `replace`/`insert` calls on the same file in one message form one batch per file: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const completeSnapshots = new Map<string, string>();
|
|
2
|
+
export function recordAutoReadAllComplete(absolutePath: string, snapshotId: string): void {
|
|
3
|
+
completeSnapshots.set(absolutePath, snapshotId);
|
|
4
|
+
}
|
|
5
|
+
export function getAutoReadAllSnapshot(absolutePath: string): string | undefined {
|
|
6
|
+
return completeSnapshots.get(absolutePath);
|
|
7
|
+
}
|
|
8
|
+
export function invalidateAutoReadAllComplete(absolutePath: string): void {
|
|
9
|
+
completeSnapshots.delete(absolutePath);
|
|
10
|
+
}
|
|
11
|
+
export function clearAutoReadAllComplete(): void {
|
|
12
|
+
completeSnapshots.clear();
|
|
13
|
+
}
|
package/src/auto-read-all.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { lstat, open, readdir } from "node:fs/promises";
|
|
3
3
|
import { join, relative, resolve, sep } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { formatSize } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import {
|
|
6
6
|
AUTO_READ_ALL_MAX_BUDGET_BYTES,
|
|
7
7
|
AUTO_READ_ALL_MAX_FILE_BYTES,
|
|
@@ -9,13 +9,14 @@ import {
|
|
|
9
9
|
AUTO_READ_ALL_MIN_BUDGET_BYTES,
|
|
10
10
|
SNIFF_BYTES,
|
|
11
11
|
} from "./constants";
|
|
12
|
-
import type
|
|
12
|
+
import { normalizeAutoReadAllIgnoreEntry, type AutoReadAllMode } from "./config";
|
|
13
13
|
import { serveRows } from "./served";
|
|
14
|
-
import { readNormFile } from "./file-reader";
|
|
14
|
+
import { readNormFile, safeSnapId } from "./file-reader";
|
|
15
15
|
import { resolveRgPath } from "./grep";
|
|
16
16
|
import { MAX_HASH_LINES } from "./hashline";
|
|
17
17
|
import { fmtReadPreview } from "./read";
|
|
18
18
|
import { splitLines } from "./utils";
|
|
19
|
+
import { recordAutoReadAllComplete } from "./auto-read-all-state";
|
|
19
20
|
|
|
20
21
|
const EXEC_TIMEOUT_MS = 20_000;
|
|
21
22
|
const EXEC_MAX_BYTES = 64 * 1024 * 1024;
|
|
@@ -25,7 +26,7 @@ const MAX_REPORTED_OMISSIONS = 50;
|
|
|
25
26
|
export const AUTO_READ_ALL_CHUNK_BYTES = 48 * 1024;
|
|
26
27
|
|
|
27
28
|
const HEADER =
|
|
28
|
-
"[hashline auto-read-all]
|
|
29
|
+
"[hashline auto-read-all] Every non-ignored project file is attached below with live hashline anchors. Do not call read for files in [files complete: ...]. Edit directly from the attachment with replace and insert.";
|
|
29
30
|
|
|
30
31
|
const IMAGE_EXTENSIONS = new Set([
|
|
31
32
|
".avif",
|
|
@@ -104,6 +105,30 @@ function isExcludedByPattern(baseLower: string): boolean {
|
|
|
104
105
|
if (baseLower === "coreui.css") return true;
|
|
105
106
|
return false;
|
|
106
107
|
}
|
|
108
|
+
export function normalizeAutoReadAllIgnoreList(entries: readonly string[] | undefined): string[] {
|
|
109
|
+
const seen = new Set<string>();
|
|
110
|
+
const out: string[] = [];
|
|
111
|
+
for (const entry of entries ?? []) {
|
|
112
|
+
if (typeof entry !== "string") continue;
|
|
113
|
+
const cleaned = normalizeAutoReadAllIgnoreEntry(entry).toLowerCase();
|
|
114
|
+
if (cleaned.length === 0 || seen.has(cleaned)) continue;
|
|
115
|
+
seen.add(cleaned);
|
|
116
|
+
out.push(cleaned);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
export function isExcludedByCustomIgnore(path: string, customIgnore: readonly string[]): boolean {
|
|
121
|
+
if (customIgnore.length === 0) return false;
|
|
122
|
+
const lower = path.toLowerCase();
|
|
123
|
+
const segments = lower.split("/");
|
|
124
|
+
for (const entry of customIgnore) {
|
|
125
|
+
if (entry.length === 0) continue;
|
|
126
|
+
if (!entry.includes("/")) {
|
|
127
|
+
if (segments.includes(entry)) return true;
|
|
128
|
+
} else if (lower === entry || lower.startsWith(entry + "/") || lower.includes("/" + entry + "/") || lower.endsWith("/" + entry)) return true;
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
107
132
|
|
|
108
133
|
const WALK_IGNORED_DIRS = new Set([
|
|
109
134
|
".git",
|
|
@@ -140,10 +165,8 @@ export interface AutoReadAllDiscovery {
|
|
|
140
165
|
export interface AutoReadAllSection {
|
|
141
166
|
file: string;
|
|
142
167
|
text: string;
|
|
143
|
-
complete: boolean;
|
|
144
168
|
totalLines: number;
|
|
145
|
-
|
|
146
|
-
nextOffset?: number;
|
|
169
|
+
absolutePath: string;
|
|
147
170
|
}
|
|
148
171
|
export interface AutoReadAllInjection {
|
|
149
172
|
text: string;
|
|
@@ -152,7 +175,6 @@ export interface AutoReadAllInjection {
|
|
|
152
175
|
omitted: string[];
|
|
153
176
|
chunks: string[];
|
|
154
177
|
completeFiles: number;
|
|
155
|
-
truncatedFiles: number;
|
|
156
178
|
}
|
|
157
179
|
|
|
158
180
|
function runCommand(command: string, args: string[], cwd: string): Promise<{ stdout: string; code: number }> {
|
|
@@ -187,7 +209,7 @@ async function listFromRg(cwd: string): Promise<string[] | undefined> {
|
|
|
187
209
|
return result.stdout.split("\0").filter((entry) => entry.length > 0);
|
|
188
210
|
}
|
|
189
211
|
|
|
190
|
-
async function walkDir(dir: string, base: string, out: string[]): Promise<void> {
|
|
212
|
+
async function walkDir(dir: string, base: string, out: string[], customIgnore: readonly string[] = []): Promise<void> {
|
|
191
213
|
let entries;
|
|
192
214
|
try {
|
|
193
215
|
entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -199,7 +221,8 @@ async function walkDir(dir: string, base: string, out: string[]): Promise<void>
|
|
|
199
221
|
const full = join(dir, entry.name);
|
|
200
222
|
if (entry.isDirectory()) {
|
|
201
223
|
if (WALK_IGNORED_DIRS.has(entry.name) || EXCLUDED_SEGMENT_SET.has(entry.name.toLowerCase())) continue;
|
|
202
|
-
|
|
224
|
+
if (customIgnore.length > 0 && isExcludedByCustomIgnore(toPosix(relative(base, full)), customIgnore)) continue;
|
|
225
|
+
await walkDir(full, base, out, customIgnore);
|
|
203
226
|
} else if (entry.isFile()) {
|
|
204
227
|
out.push(toPosix(relative(base, full)));
|
|
205
228
|
}
|
|
@@ -250,7 +273,8 @@ async function forEachLimit<T>(items: T[], limit: number, work: (item: T) => Pro
|
|
|
250
273
|
await Promise.all(workers);
|
|
251
274
|
}
|
|
252
275
|
|
|
253
|
-
export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMode = "on"): Promise<AutoReadAllDiscovery> {
|
|
276
|
+
export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMode = "on", ignoreDirs: readonly string[] = []): Promise<AutoReadAllDiscovery> {
|
|
277
|
+
const customIgnore = normalizeAutoReadAllIgnoreList(ignoreDirs);
|
|
254
278
|
let source: AutoReadAllSource = "git";
|
|
255
279
|
let candidates = await listFromGit(cwd);
|
|
256
280
|
if (candidates === undefined) {
|
|
@@ -263,7 +287,7 @@ export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMod
|
|
|
263
287
|
if (candidates === undefined) {
|
|
264
288
|
source = "walk";
|
|
265
289
|
const walked: string[] = [];
|
|
266
|
-
await walkDir(cwd, cwd, walked);
|
|
290
|
+
await walkDir(cwd, cwd, walked, customIgnore);
|
|
267
291
|
candidates = walked;
|
|
268
292
|
}
|
|
269
293
|
|
|
@@ -272,7 +296,7 @@ export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMod
|
|
|
272
296
|
let skippedByName = 0;
|
|
273
297
|
for (const file of unique) {
|
|
274
298
|
const baseLower = baseNameOf(file).toLowerCase();
|
|
275
|
-
if (EXCLUDED_NAME_SET.has(baseLower) || isExcludedBySegment(file) || isExcludedByPattern(baseLower)) skippedByName += 1;
|
|
299
|
+
if (EXCLUDED_NAME_SET.has(baseLower) || isExcludedBySegment(file) || isExcludedByPattern(baseLower) || isExcludedByCustomIgnore(file, customIgnore)) skippedByName += 1;
|
|
276
300
|
else includable.push(file);
|
|
277
301
|
}
|
|
278
302
|
const scanWindow = includable.slice(0, AUTO_READ_ALL_MAX_FILES * SCAN_LIMIT_MULTIPLIER);
|
|
@@ -337,14 +361,10 @@ export function chunkAutoReadAllSections(sections: string[], maxBytes: number =
|
|
|
337
361
|
async function renderFile(file: string, cwd: string): Promise<AutoReadAllSection | undefined> {
|
|
338
362
|
try {
|
|
339
363
|
const { normalized, fileHashes, absolutePath } = await readNormFile(file, cwd, { maxLines: MAX_HASH_LINES });
|
|
340
|
-
const preview = await fmtReadPreview(normalized, {}, fileHashes, absolutePath,
|
|
364
|
+
const preview = await fmtReadPreview(normalized, {}, fileHashes, absolutePath, AUTO_READ_ALL_MAX_BUDGET_BYTES, MAX_HASH_LINES);
|
|
341
365
|
serveRows(absolutePath, fileHashes, splitLines(normalized), preview.servedHashes);
|
|
342
366
|
const totalLines = fileHashes.length;
|
|
343
|
-
|
|
344
|
-
const complete = preview.truncation === undefined && preview.nextOffset === undefined && shownLines >= totalLines;
|
|
345
|
-
const status = complete ? `[complete, ${totalLines} lines; do NOT re-read]` : `[truncated, showing ${shownLines} of ${totalLines} lines; use read with offset=${preview.nextOffset ?? shownLines + 1} to continue]`;
|
|
346
|
-
const nextOffset = preview.nextOffset;
|
|
347
|
-
return { file, text: `=== ${file} ===\n${status}\n${preview.text}`, complete, totalLines, shownLines, ...(nextOffset !== undefined ? { nextOffset } : {}) };
|
|
367
|
+
return { file, text: `=== ${file} ===\n${preview.text}`, totalLines, absolutePath };
|
|
348
368
|
} catch (error) {
|
|
349
369
|
console.error(`Auto-read all: skipped ${file}:`, error);
|
|
350
370
|
return undefined;
|
|
@@ -369,14 +389,13 @@ function buildFooter(attached: number, discovery: AutoReadAllDiscovery, omitted:
|
|
|
369
389
|
return `[hashline auto-read-all: ${attached} file(s) attached from ${discovery.source}; ${summary}${omissionNote}]`;
|
|
370
390
|
}
|
|
371
391
|
|
|
372
|
-
export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number, mode: AutoReadAllMode = "on"): Promise<AutoReadAllInjection | undefined> {
|
|
373
|
-
const discovery = await discoverAutoReadAllFiles(cwd, mode);
|
|
392
|
+
export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number, mode: AutoReadAllMode = "on", ignoreDirs: readonly string[] = []): Promise<AutoReadAllInjection | undefined> {
|
|
393
|
+
const discovery = await discoverAutoReadAllFiles(cwd, mode, ignoreDirs);
|
|
374
394
|
if (discovery.files.length === 0) return undefined;
|
|
375
395
|
const sections: AutoReadAllSection[] = [];
|
|
376
396
|
const omitted: string[] = [];
|
|
377
397
|
let bytes = 0;
|
|
378
398
|
let completeFiles = 0;
|
|
379
|
-
let truncatedFiles = 0;
|
|
380
399
|
for (const file of discovery.files) {
|
|
381
400
|
const section = await renderFile(file, cwd);
|
|
382
401
|
if (section === undefined) {
|
|
@@ -389,16 +408,19 @@ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number
|
|
|
389
408
|
continue;
|
|
390
409
|
}
|
|
391
410
|
sections.push(section);
|
|
411
|
+
const snapshotId = await safeSnapId(section.absolutePath, "auto-read-all");
|
|
412
|
+
if (snapshotId !== undefined) recordAutoReadAllComplete(section.absolutePath, snapshotId);
|
|
392
413
|
bytes += sectionBytes;
|
|
393
|
-
|
|
394
|
-
else truncatedFiles += 1;
|
|
414
|
+
completeFiles += 1;
|
|
395
415
|
}
|
|
396
416
|
if (sections.length === 0) return undefined;
|
|
397
417
|
const sectionTexts = sections.map((section) => section.text);
|
|
398
418
|
const chunks = chunkAutoReadAllSections(sectionTexts);
|
|
399
|
-
const coverage = `[coverage: ${completeFiles} complete
|
|
400
|
-
const
|
|
401
|
-
|
|
419
|
+
const coverage = `[coverage: ${completeFiles} complete]`;
|
|
420
|
+
const completeNames = sections.map((section) => section.file);
|
|
421
|
+
const machineList = `[files complete: ${JSON.stringify(completeNames)} omitted: ${JSON.stringify(omitted)}]`;
|
|
422
|
+
const text = `${HEADER}\n\n${coverage}\n${machineList}\n\n${sectionTexts.join("\n\n")}\n\n${buildFooter(sections.length, discovery, omitted)}`;
|
|
423
|
+
return { text, files: sections.length, bytes, omitted, chunks, completeFiles };
|
|
402
424
|
}
|
|
403
425
|
|
|
404
426
|
export function autoReadAllBudget(model: { contextWindow?: number } | undefined): number {
|
package/src/batch.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { toDisplayPath } from "./paths";
|
|
4
4
|
import { readConfig, getDiffContextLines } from "./config";
|
|
5
5
|
import { throwIfStrictInput, tryResolveEditTarget } from "./edit-common";
|
|
6
6
|
import { readNormFile, safeSnapId } from "./file-reader";
|
|
@@ -178,39 +178,17 @@ function normalizeEditArgs(args: unknown): NormalizedEditArgs | undefined {
|
|
|
178
178
|
}
|
|
179
179
|
return undefined;
|
|
180
180
|
}
|
|
181
|
-
function anchorTargetFor(args: unknown): string | undefined {
|
|
182
|
-
const normalized = normalizeEditArgs(args);
|
|
183
|
-
if (!normalized) return undefined;
|
|
184
|
-
if (normalized.kind === "replace") return tryResolveEditTarget(normalized.removeFrom, normalized.removeTo);
|
|
185
|
-
return tryResolveEditTarget(normalized.anchor);
|
|
186
|
-
}
|
|
187
|
-
async function inferredTargetFor(args: unknown, cwd: string, requirePath: boolean): Promise<string | undefined> {
|
|
188
|
-
const normalized = normalizeEditArgs(args);
|
|
189
|
-
if (!normalized) return undefined;
|
|
190
|
-
if (requirePath && normalized.path) {
|
|
191
|
-
try {
|
|
192
|
-
return (await resolveInCwd(normalized.path, cwd)).resolved;
|
|
193
|
-
} catch {
|
|
194
|
-
return undefined;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
if (normalized.kind === "replace") {
|
|
198
|
-
return tryResolveEditTarget(normalized.removeFrom) ?? (normalized.removeTo ? tryResolveEditTarget(normalized.removeTo) : undefined);
|
|
199
|
-
}
|
|
200
|
-
return undefined;
|
|
201
|
-
}
|
|
202
181
|
|
|
203
182
|
async function verifyPaths(
|
|
204
|
-
group: Array<{ id: string; target: string; kind: BatchKind; args: unknown }>,
|
|
183
|
+
group: Array<{ id: string; target: string; kind: BatchKind; args: unknown; path?: string }>,
|
|
205
184
|
cwd: string,
|
|
206
|
-
): Promise<Array<{ id: string; target: string; kind: BatchKind; args: unknown }>> {
|
|
207
|
-
const verified: Array<{ id: string; target: string; kind: BatchKind; args: unknown }> = [];
|
|
185
|
+
): Promise<Array<{ id: string; target: string; kind: BatchKind; args: unknown; path?: string }>> {
|
|
186
|
+
const verified: Array<{ id: string; target: string; kind: BatchKind; args: unknown; path?: string }> = [];
|
|
208
187
|
for (const item of group) {
|
|
209
|
-
|
|
210
|
-
if (!normalized || !normalized.path) continue;
|
|
188
|
+
if (!item.path) continue;
|
|
211
189
|
let resolved: string | undefined;
|
|
212
190
|
try {
|
|
213
|
-
resolved = (await resolveInCwd(
|
|
191
|
+
resolved = (await resolveInCwd(item.path, cwd)).resolved;
|
|
214
192
|
} catch {
|
|
215
193
|
resolved = undefined;
|
|
216
194
|
}
|
|
@@ -241,11 +219,24 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
|
|
|
241
219
|
if (calls.length < 2) return;
|
|
242
220
|
const earlyConfig = await readConfig();
|
|
243
221
|
const requirePath = earlyConfig.requirePath === true;
|
|
244
|
-
interface ResolvedCall { id: string; target: string; kind: BatchKind; args: unknown }
|
|
222
|
+
interface ResolvedCall { id: string; target: string; kind: BatchKind; args: unknown; path?: string }
|
|
245
223
|
const resolved: ResolvedCall[] = [];
|
|
246
224
|
for (const call of calls) {
|
|
247
|
-
const
|
|
248
|
-
if (
|
|
225
|
+
const normalized = normalizeEditArgs(call.args);
|
|
226
|
+
if (!normalized) continue;
|
|
227
|
+
let target = normalized.kind === "replace" ? tryResolveEditTarget(normalized.removeFrom, normalized.removeTo) : tryResolveEditTarget(normalized.anchor);
|
|
228
|
+
if (!target) {
|
|
229
|
+
if (requirePath && normalized.path) {
|
|
230
|
+
try {
|
|
231
|
+
target = (await resolveInCwd(normalized.path, cwd)).resolved;
|
|
232
|
+
} catch {
|
|
233
|
+
target = undefined;
|
|
234
|
+
}
|
|
235
|
+
} else if (normalized.kind === "replace") {
|
|
236
|
+
target = tryResolveEditTarget(normalized.removeFrom) ?? (normalized.removeTo ? tryResolveEditTarget(normalized.removeTo) : undefined);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (target) resolved.push({ id: call.id, target, kind: call.name as BatchKind, args: call.args, ...(normalized.path ? { path: normalized.path } : {}) });
|
|
249
240
|
}
|
|
250
241
|
const groups = new Map<string, ResolvedCall[]>();
|
|
251
242
|
for (const item of resolved) {
|
|
@@ -447,7 +438,7 @@ export async function ensureBatchBase(input: {
|
|
|
447
438
|
runtime.paths = {
|
|
448
439
|
absolutePath: file.absolutePath,
|
|
449
440
|
mutationTargetPath: input.mutationTargetPath,
|
|
450
|
-
displayPath:
|
|
441
|
+
displayPath: toDisplayPath(input.cwd, file.absolutePath, input.targetPath),
|
|
451
442
|
};
|
|
452
443
|
return base;
|
|
453
444
|
}
|
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" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "boundaryDedupMode" | "diffContextLines";
|
|
5
|
+
export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "boundaryDedupMode" | "diffContextLines";
|
|
6
6
|
|
|
7
7
|
export interface ConfigRow {
|
|
8
8
|
key: ConfigToggleKey;
|
|
@@ -12,6 +12,7 @@ export interface ConfigRow {
|
|
|
12
12
|
mode?: string;
|
|
13
13
|
cycle?: string[];
|
|
14
14
|
value?: number;
|
|
15
|
+
folders?: string[];
|
|
15
16
|
disabled?: boolean;
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -19,6 +20,7 @@ export function configRows(config: Config): ConfigRow[] {
|
|
|
19
20
|
return [
|
|
20
21
|
{ key: "autoRead", label: "Auto-read", hint: "Anchors after write + post-edit diffs", enabled: config.autoRead !== false },
|
|
21
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", hint: "Extra folders skipped by auto-read all (comma-separated)", enabled: (config.autoReadAllIgnore ?? []).length > 0, folders: config.autoReadAllIgnore ?? [] },
|
|
22
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 },
|
|
23
25
|
{ key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
|
|
24
26
|
{ key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
|
|
@@ -42,12 +44,22 @@ function numberBox(theme: Theme, value: number, disabled?: boolean): string {
|
|
|
42
44
|
if (disabled) return theme.fg("dim", `[${value}]`);
|
|
43
45
|
return theme.fg("accent", `[${value}]`);
|
|
44
46
|
}
|
|
47
|
+
function ignoreBox(theme: Theme, folders: string[]): string {
|
|
48
|
+
return theme.fg("accent", `[${folders.length}]`);
|
|
49
|
+
}
|
|
50
|
+
function formatIgnoreFolders(folders: string[]): string {
|
|
51
|
+
if (folders.length === 0) return "(empty)";
|
|
52
|
+
const joined = folders.join(", ");
|
|
53
|
+
return joined.length > 40 ? `${joined.slice(0, 37)}...` : joined;
|
|
54
|
+
}
|
|
45
55
|
|
|
46
56
|
export class HashlineConfigOverlay {
|
|
47
57
|
private rows: ConfigRow[];
|
|
48
58
|
private selected = 0;
|
|
59
|
+
private editingIgnore = false;
|
|
60
|
+
private editBuffer = "";
|
|
49
61
|
|
|
50
|
-
constructor(private readonly opts: { tui: { requestRender(force?: boolean): void }; theme: Theme; done: () => void; onToggle: (key: ConfigToggleKey, delta?: number) => Promise<void> }) {
|
|
62
|
+
constructor(private readonly opts: { tui: { requestRender(force?: boolean): void }; theme: Theme; done: () => void; onToggle: (key: ConfigToggleKey, delta?: number, value?: string) => Promise<void> }) {
|
|
51
63
|
this.rows = [];
|
|
52
64
|
}
|
|
53
65
|
|
|
@@ -55,19 +67,43 @@ export class HashlineConfigOverlay {
|
|
|
55
67
|
this.rows = configRows(await readConfig());
|
|
56
68
|
}
|
|
57
69
|
|
|
58
|
-
private runToggle(row: ConfigRow, delta?: number): void {
|
|
70
|
+
private runToggle(row: ConfigRow, delta?: number, value?: string): void {
|
|
59
71
|
this.opts.tui.requestRender(true);
|
|
60
|
-
void this.opts.onToggle(row.key, delta).then(async () => {
|
|
72
|
+
void this.opts.onToggle(row.key, delta, value).then(async () => {
|
|
61
73
|
this.rows = configRows(await readConfig());
|
|
74
|
+
this.editingIgnore = false;
|
|
62
75
|
this.opts.tui.requestRender(true);
|
|
63
76
|
}).catch((error: unknown) => {
|
|
64
77
|
console.error("Failed to toggle hashline setting:", error);
|
|
65
78
|
});
|
|
66
79
|
}
|
|
80
|
+
private startIgnoreEdit(row: ConfigRow): void {
|
|
81
|
+
this.editingIgnore = true;
|
|
82
|
+
this.editBuffer = (row.folders ?? []).join(", ");
|
|
83
|
+
this.opts.tui.requestRender(true);
|
|
84
|
+
}
|
|
85
|
+
private commitIgnoreEdit(): void {
|
|
86
|
+
const row = this.rows[this.selected];
|
|
87
|
+
if (!row || row.key !== "autoReadAllIgnore") {
|
|
88
|
+
this.editingIgnore = false;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const value = this.editBuffer;
|
|
92
|
+
this.editingIgnore = false;
|
|
93
|
+
this.runToggle(row, undefined, value);
|
|
94
|
+
}
|
|
95
|
+
private cancelIgnoreEdit(): void {
|
|
96
|
+
this.editingIgnore = false;
|
|
97
|
+
this.opts.tui.requestRender(true);
|
|
98
|
+
}
|
|
67
99
|
|
|
68
100
|
private toggleSelected(): void {
|
|
69
101
|
const row = this.rows[this.selected];
|
|
70
102
|
if (!row || row.disabled) return;
|
|
103
|
+
if (row.key === "autoReadAllIgnore") {
|
|
104
|
+
this.startIgnoreEdit(row);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
71
107
|
if (row.value !== undefined) {
|
|
72
108
|
row.value += 1;
|
|
73
109
|
this.runToggle(row, 1);
|
|
@@ -92,6 +128,32 @@ export class HashlineConfigOverlay {
|
|
|
92
128
|
}
|
|
93
129
|
|
|
94
130
|
handleInput(data: string): void {
|
|
131
|
+
if (this.editingIgnore) {
|
|
132
|
+
if (matchesKey(data, Key.escape) || data === "\x1b") {
|
|
133
|
+
this.cancelIgnoreEdit();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (matchesKey(data, Key.enter) || data === "\r" || data === "\n") {
|
|
137
|
+
this.commitIgnoreEdit();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (data === "\x7f" || data === "\b" || data === "\x08") {
|
|
141
|
+
this.editBuffer = this.editBuffer.slice(0, -1);
|
|
142
|
+
this.opts.tui.requestRender(true);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (data === "\x15") {
|
|
146
|
+
this.editBuffer = "";
|
|
147
|
+
this.opts.tui.requestRender(true);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (data.length >= 1 && [...data].every((ch) => ch.charCodeAt(0) >= 32 && ch !== "\x7f")) {
|
|
151
|
+
this.editBuffer += data;
|
|
152
|
+
this.opts.tui.requestRender(true);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
95
157
|
if (matchesKey(data, Key.up) || data === "k") {
|
|
96
158
|
this.selected = (this.selected + this.rows.length - 1) % this.rows.length;
|
|
97
159
|
return;
|
|
@@ -112,6 +174,11 @@ export class HashlineConfigOverlay {
|
|
|
112
174
|
this.toggleSelected();
|
|
113
175
|
return;
|
|
114
176
|
}
|
|
177
|
+
const row = this.rows[this.selected];
|
|
178
|
+
if ((data === "e" || data === "E") && row && row.key === "autoReadAllIgnore" && !row.disabled) {
|
|
179
|
+
this.startIgnoreEdit(row);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
115
182
|
if (matchesKey(data, Key.escape) || data === "q") {
|
|
116
183
|
this.opts.done();
|
|
117
184
|
}
|
|
@@ -129,12 +196,17 @@ export class HashlineConfigOverlay {
|
|
|
129
196
|
lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
|
|
130
197
|
this.rows.forEach((row, index) => {
|
|
131
198
|
const cursor = index === this.selected ? theme.fg("accent", "> ") : " ";
|
|
132
|
-
const box = 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", "[ ]");
|
|
199
|
+
const box = row.folders !== undefined ? ignoreBox(theme, row.folders) : 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", "[ ]");
|
|
133
200
|
const label = row.disabled ? theme.fg("dim", row.label) : index === this.selected ? theme.fg("accent", theme.bold(row.label)) : row.label;
|
|
134
|
-
|
|
201
|
+
const suffix = row.folders !== undefined ? ` — ${row.hint}: ${formatIgnoreFolders(row.folders)}` : ` — ${row.hint}`;
|
|
202
|
+
lines.push(padRow(theme, innerWidth, `${cursor}${box} ${label} ${theme.fg("dim", suffix)}`));
|
|
203
|
+
if (row.key === "autoReadAllIgnore" && index === this.selected && this.editingIgnore) {
|
|
204
|
+
lines.push(padRow(theme, innerWidth, ` ${theme.fg("accent", "edit:")} ${this.editBuffer}█ ${theme.fg("dim", "(Enter save · Esc cancel · Ctrl-U clear)")}`));
|
|
205
|
+
}
|
|
135
206
|
});
|
|
136
207
|
lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
|
|
137
|
-
|
|
208
|
+
const footer = this.editingIgnore ? " type to edit · Enter save · Esc cancel" : " ↑↓ navigate · space toggle · ←/→ or -/+ adjust · e edit folders · q close";
|
|
209
|
+
lines.push(padRow(theme, innerWidth, theme.fg("dim", footer)));
|
|
138
210
|
lines.push(theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
|
|
139
211
|
return lines;
|
|
140
212
|
}
|