atom-agent 1.0.0 → 1.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/CHANGELOG.md +31 -2
- package/README.md +12 -12
- package/dist/App.js +297 -23
- package/dist/adapters.js +84 -6
- package/dist/agent/loop-guard.js +184 -0
- package/dist/agent/loop.js +609 -300
- package/dist/agent/normalize.js +144 -0
- package/dist/cli.js +1 -1
- package/dist/system.js +1 -0
- package/dist/telemetry-dashboard.js +19 -1
- package/dist/telemetry.js +55 -0
- package/dist/tools/dir-cache.js +207 -0
- package/dist/tools/filesystem.js +40 -1
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +79 -0
- package/dist/tools/search.js +66 -60
- package/dist/tools/shell.js +19 -0
- package/dist/tools/todo.js +1 -1
- package/dist/tools.js +2 -0
- package/dist/ui/diff-panel.js +55 -0
- package/dist/ui/diff-view.js +112 -0
- package/dist/ui/diff.js +422 -0
- package/dist/ui/highlight.js +120 -0
- package/dist/ui/live-tail.js +2 -2
- package/dist/ui/modals.js +22 -5
- package/dist/ui/palette.js +10 -2
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +20 -4
- package/dist/ui/theme.js +6 -0
- package/dist/ui/todo-panel.js +10 -2
- package/dist/ui/transcript.js +16 -4
- package/dist/zen.js +33 -9
- package/package.json +1 -1
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Read-through cache for file reads: repeated `read` calls for the same
|
|
2
|
+
// path+window skip disk I/O when the file hasn't changed. Correctness first:
|
|
3
|
+
//
|
|
4
|
+
// - Key: absolute path + offset + limit (different windows are different keys).
|
|
5
|
+
// - Validation: file mtimeMs + size checked on every hit (one stat, cheap).
|
|
6
|
+
// A mismatch → miss → re-read + re-store. External edits can never serve
|
|
7
|
+
// stale bytes beyond a stat race.
|
|
8
|
+
// - Invalidation: write/edit/delete paths call invalidatePath(abs) — the
|
|
9
|
+
// write/edit executors do this, so read→write→read chains never go stale.
|
|
10
|
+
// A global version bump (invalidateAll) covers tree-wide mutations.
|
|
11
|
+
// - Scope: successes only (errors never cache — a transient ENOENT must not
|
|
12
|
+
// poison later reads). Directory listings never cache (readdir is already
|
|
13
|
+
// cheap and highly mutable).
|
|
14
|
+
// - Bounds: LRU cap (default 100 entries) + TTL (default 30s). Disable with
|
|
15
|
+
// ATOM_READ_CACHE=0. All best-effort, never throws.
|
|
16
|
+
//
|
|
17
|
+
// Measurable win: explore loops re-read the same files (read after grep,
|
|
18
|
+
// re-read before edit, parallel batches reading shared context). Each hit
|
|
19
|
+
// saves a full readFile + split + 64KB-format pass.
|
|
20
|
+
//
|
|
21
|
+
// Stats (for loop instrumentation + tests): hits, misses, stores, invalidations.
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
const DEFAULT_MAX_ENTRIES = 100;
|
|
24
|
+
const DEFAULT_TTL_MS = 30_000;
|
|
25
|
+
const cache = new Map();
|
|
26
|
+
const stats = { hits: 0, misses: 0, stores: 0, invalidations: 0, size: 0 };
|
|
27
|
+
function cacheEnabled() {
|
|
28
|
+
const raw = process.env.ATOM_READ_CACHE;
|
|
29
|
+
if (raw === undefined)
|
|
30
|
+
return true;
|
|
31
|
+
const v = raw.trim().toLowerCase();
|
|
32
|
+
return !(v === "0" || v === "false" || v === "no" || v === "off");
|
|
33
|
+
}
|
|
34
|
+
function ttlMs() {
|
|
35
|
+
const raw = process.env.ATOM_READ_CACHE_TTL_MS;
|
|
36
|
+
if (raw !== undefined) {
|
|
37
|
+
const n = Number(raw.trim());
|
|
38
|
+
if (Number.isFinite(n) && n > 0)
|
|
39
|
+
return Math.min(Math.floor(n), 300_000);
|
|
40
|
+
}
|
|
41
|
+
return DEFAULT_TTL_MS;
|
|
42
|
+
}
|
|
43
|
+
function maxEntries() {
|
|
44
|
+
const raw = process.env.ATOM_READ_CACHE_MAX;
|
|
45
|
+
if (raw !== undefined) {
|
|
46
|
+
const n = Number(raw.trim());
|
|
47
|
+
if (Number.isFinite(n) && n > 0)
|
|
48
|
+
return Math.min(Math.floor(n), 1000);
|
|
49
|
+
}
|
|
50
|
+
return DEFAULT_MAX_ENTRIES;
|
|
51
|
+
}
|
|
52
|
+
export function readCacheKey(abs, offset, limit) {
|
|
53
|
+
return `${abs}\n${offset}\n${limit}`;
|
|
54
|
+
}
|
|
55
|
+
export function normalizeReadWindow(offset, limit) {
|
|
56
|
+
const o = typeof offset === "number" && Number.isFinite(offset) ? Math.max(1, Math.floor(offset)) : 1;
|
|
57
|
+
const l = typeof limit === "number" && Number.isFinite(limit)
|
|
58
|
+
? Math.max(1, Math.floor(limit))
|
|
59
|
+
: Number.MAX_SAFE_INTEGER;
|
|
60
|
+
return { offset: o, limit: l };
|
|
61
|
+
}
|
|
62
|
+
// Lookup by absolute path + window. `stat` must be the fresh
|
|
63
|
+
// {mtimeMs, size} of the file (the caller already stats before reading).
|
|
64
|
+
// Returns the cached {result, hash} on hit, null on miss. Never throws.
|
|
65
|
+
export function getCachedRead(abs, offset, limit, stat) {
|
|
66
|
+
if (!cacheEnabled()) {
|
|
67
|
+
stats.misses += 1;
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const key = readCacheKey(abs, offset, limit);
|
|
71
|
+
const entry = cache.get(key);
|
|
72
|
+
if (!entry) {
|
|
73
|
+
stats.misses += 1;
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (Date.now() - entry.storedAt > ttlMs()) {
|
|
77
|
+
cache.delete(key);
|
|
78
|
+
stats.size = cache.size;
|
|
79
|
+
stats.misses += 1;
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
if (entry.mtimeMs !== stat.mtimeMs || entry.size !== stat.size) {
|
|
83
|
+
cache.delete(key);
|
|
84
|
+
stats.size = cache.size;
|
|
85
|
+
stats.misses += 1;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
// LRU refresh: re-insert so the oldest key stays evictable first.
|
|
89
|
+
cache.delete(key);
|
|
90
|
+
cache.set(key, entry);
|
|
91
|
+
stats.hits += 1;
|
|
92
|
+
return { result: entry.result, hash: entry.hash };
|
|
93
|
+
}
|
|
94
|
+
// Store a successful file-read result. Never throws; evicts oldest first.
|
|
95
|
+
export function setCachedRead(abs, offset, limit, result, stat, hash) {
|
|
96
|
+
try {
|
|
97
|
+
if (!cacheEnabled() || typeof result !== "string")
|
|
98
|
+
return;
|
|
99
|
+
const key = readCacheKey(abs, offset, limit);
|
|
100
|
+
cache.delete(key);
|
|
101
|
+
while (cache.size >= maxEntries()) {
|
|
102
|
+
const oldest = cache.keys().next();
|
|
103
|
+
if (oldest.done)
|
|
104
|
+
break;
|
|
105
|
+
cache.delete(oldest.value);
|
|
106
|
+
}
|
|
107
|
+
cache.set(key, { result, hash, mtimeMs: stat.mtimeMs, size: stat.size, storedAt: Date.now() });
|
|
108
|
+
stats.size = cache.size;
|
|
109
|
+
stats.stores += 1;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// cache failures never break reads
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Drop every entry for one absolute path (all windows). Called by
|
|
116
|
+
// write/edit/delete paths. Never throws.
|
|
117
|
+
export function invalidatePath(abs) {
|
|
118
|
+
try {
|
|
119
|
+
const prefix = `${abs}\n`;
|
|
120
|
+
let dropped = 0;
|
|
121
|
+
for (const key of [...cache.keys()]) {
|
|
122
|
+
if (key === abs || key.startsWith(prefix)) {
|
|
123
|
+
cache.delete(key);
|
|
124
|
+
dropped += 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Also match callers that pass a relative display path: compare resolved
|
|
128
|
+
// basenames as a fallback so nothing obviously stale survives.
|
|
129
|
+
if (dropped === 0 && typeof abs === "string") {
|
|
130
|
+
const resolved = path.resolve(abs);
|
|
131
|
+
const resolvedPrefix = `${resolved}\n`;
|
|
132
|
+
for (const key of [...cache.keys()]) {
|
|
133
|
+
if (key === resolved || key.startsWith(resolvedPrefix)) {
|
|
134
|
+
cache.delete(key);
|
|
135
|
+
dropped += 1;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (dropped > 0)
|
|
140
|
+
stats.invalidations += dropped;
|
|
141
|
+
stats.size = cache.size;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// never throw across the tool boundary
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export function clearReadCache() {
|
|
148
|
+
cache.clear();
|
|
149
|
+
stats.size = 0;
|
|
150
|
+
}
|
|
151
|
+
export function getReadCacheStats() {
|
|
152
|
+
return { ...stats, size: cache.size };
|
|
153
|
+
}
|
|
154
|
+
export function resetReadCacheStats() {
|
|
155
|
+
stats.hits = 0;
|
|
156
|
+
stats.misses = 0;
|
|
157
|
+
stats.stores = 0;
|
|
158
|
+
stats.invalidations = 0;
|
|
159
|
+
stats.size = cache.size;
|
|
160
|
+
}
|
package/dist/tools/registry.js
CHANGED
|
@@ -375,6 +375,85 @@ export function describeToolCall(name, args) {
|
|
|
375
375
|
return `⚙ ${name}`;
|
|
376
376
|
}
|
|
377
377
|
}
|
|
378
|
+
// Shared byte cap for approve-time file reads (preview + BEFORE capture).
|
|
379
|
+
export const APPROVAL_PREVIEW_MAX_BYTES = 1_000_000;
|
|
380
|
+
// Extension → highlight family (see ui/highlight.ts). Conservative: only
|
|
381
|
+
// extensions we are confident about; everything else stays null (plain).
|
|
382
|
+
const PREVIEW_LANG_BY_EXT = {
|
|
383
|
+
ts: "c",
|
|
384
|
+
tsx: "c",
|
|
385
|
+
mts: "c",
|
|
386
|
+
cts: "c",
|
|
387
|
+
js: "c",
|
|
388
|
+
jsx: "c",
|
|
389
|
+
mjs: "c",
|
|
390
|
+
cjs: "c",
|
|
391
|
+
go: "c",
|
|
392
|
+
rs: "c",
|
|
393
|
+
java: "c",
|
|
394
|
+
c: "c",
|
|
395
|
+
h: "c",
|
|
396
|
+
hh: "c",
|
|
397
|
+
cc: "c",
|
|
398
|
+
cpp: "c",
|
|
399
|
+
hpp: "c",
|
|
400
|
+
cs: "c",
|
|
401
|
+
swift: "c",
|
|
402
|
+
kt: "c",
|
|
403
|
+
kts: "c",
|
|
404
|
+
php: "c",
|
|
405
|
+
py: "py",
|
|
406
|
+
pyi: "py",
|
|
407
|
+
rb: "py",
|
|
408
|
+
sh: "sh",
|
|
409
|
+
bash: "sh",
|
|
410
|
+
zsh: "sh",
|
|
411
|
+
json: "data",
|
|
412
|
+
jsonc: "data",
|
|
413
|
+
yaml: "data",
|
|
414
|
+
yml: "data",
|
|
415
|
+
toml: "data",
|
|
416
|
+
};
|
|
417
|
+
export function previewLangFromPath(p) {
|
|
418
|
+
const base = p.split(/[\\/]/).pop() ?? p;
|
|
419
|
+
const dot = base.lastIndexOf(".");
|
|
420
|
+
if (dot <= 0 || dot === base.length - 1)
|
|
421
|
+
return null;
|
|
422
|
+
return PREVIEW_LANG_BY_EXT[base.slice(dot + 1).toLowerCase()] ?? null;
|
|
423
|
+
}
|
|
424
|
+
export function previewDiffForApproval(name, args, cwd = process.cwd()) {
|
|
425
|
+
try {
|
|
426
|
+
if (name === "edit") {
|
|
427
|
+
const a = args;
|
|
428
|
+
if (typeof a.oldString !== "string" || typeof a.newString !== "string")
|
|
429
|
+
return null;
|
|
430
|
+
const p = typeof a.path === "string" ? a.path : null;
|
|
431
|
+
return { oldText: a.oldString, newText: a.newString, lang: p ? previewLangFromPath(p) : null, path: p };
|
|
432
|
+
}
|
|
433
|
+
if (name === "write") {
|
|
434
|
+
const a = args;
|
|
435
|
+
if (typeof a.content !== "string" || typeof a.path !== "string" || a.path.length === 0) {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
let oldText = null;
|
|
439
|
+
try {
|
|
440
|
+
const abs = path.resolve(cwd, a.path);
|
|
441
|
+
const st = fs.statSync(abs);
|
|
442
|
+
if (st.isFile() && st.size <= APPROVAL_PREVIEW_MAX_BYTES) {
|
|
443
|
+
oldText = fs.readFileSync(abs, "utf8");
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
oldText = null;
|
|
448
|
+
}
|
|
449
|
+
return { oldText, newText: a.content, lang: previewLangFromPath(a.path), path: a.path };
|
|
450
|
+
}
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
378
457
|
// OpenAI-style function schemas sent as `tools` on the chat POST.
|
|
379
458
|
export const TOOL_DEFINITIONS = [
|
|
380
459
|
{
|
package/dist/tools/search.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// Read-only over the repo; node_modules/.git skipped by the walker.
|
|
3
3
|
import { promises as fsp } from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { listFiles } from "./dir-cache.js";
|
|
6
|
+
import { err, GLOB_MATCH_CAP, GREP_MATCH_CAP, invalidCall, resolveSandbox } from "./shared.js";
|
|
6
7
|
// Minimal glob matcher: supports **, **/, *, ?. Used for grep `include`
|
|
7
8
|
// and the glob tool. Patterns without a slash match the basename.
|
|
8
9
|
function globToRegExp(glob) {
|
|
@@ -46,25 +47,33 @@ function matchesGlob(pattern, relPosix) {
|
|
|
46
47
|
}
|
|
47
48
|
return globToRegExp(norm).test(relPosix);
|
|
48
49
|
}
|
|
49
|
-
async function walkFiles(absDir, cwd, out) {
|
|
50
|
-
const entries = await fsp.readdir(absDir, { withFileTypes: true });
|
|
51
|
-
for (const e of entries) {
|
|
52
|
-
if (SKIP_DIRS.has(e.name))
|
|
53
|
-
continue;
|
|
54
|
-
const full = path.join(absDir, e.name);
|
|
55
|
-
if (e.isDirectory()) {
|
|
56
|
-
await walkFiles(full, cwd, out);
|
|
57
|
-
}
|
|
58
|
-
else if (e.isFile()) {
|
|
59
|
-
out.push(path.relative(cwd, full).split(path.sep).join("/"));
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
50
|
export const GREP_OUTPUT_MODES = new Set([
|
|
64
51
|
"content",
|
|
65
52
|
"files_with_matches",
|
|
66
53
|
"count",
|
|
67
54
|
]);
|
|
55
|
+
// File-read fan-out for scans: bounded parallelism (32 in flight) over an
|
|
56
|
+
// ordered list, results in input order. Disk I/O overlaps instead of
|
|
57
|
+
// serializing one open/read/close at a time; the regex pass stays sequential
|
|
58
|
+
// afterwards so outputs and first-failure errors keep exact alpha order.
|
|
59
|
+
// 32 concurrent opens is far below EMFILE on every supported platform.
|
|
60
|
+
const SCAN_CONCURRENCY = 32;
|
|
61
|
+
async function mapLimit(items, limit, fn) {
|
|
62
|
+
const out = new Array(items.length);
|
|
63
|
+
let next = 0;
|
|
64
|
+
const workers = new Array(Math.min(Math.max(limit, 1), Math.max(items.length, 1)))
|
|
65
|
+
.fill(0)
|
|
66
|
+
.map(async () => {
|
|
67
|
+
for (;;) {
|
|
68
|
+
const i = next++;
|
|
69
|
+
if (i >= items.length)
|
|
70
|
+
return;
|
|
71
|
+
out[i] = await fn(items[i], i);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
await Promise.all(workers);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
68
77
|
// Best-effort mtime (ms) for recency sorting; 0 when the file cannot be
|
|
69
78
|
// stat'ed (keeps such entries last instead of failing the search).
|
|
70
79
|
async function mtimeMs(abs) {
|
|
@@ -110,37 +119,60 @@ export async function grepTool(args, cwd = process.cwd()) {
|
|
|
110
119
|
}
|
|
111
120
|
if (!st.isDirectory())
|
|
112
121
|
return err(`not a directory: ${dir}`);
|
|
113
|
-
const files =
|
|
114
|
-
await walkFiles(r.abs, cwd, files);
|
|
122
|
+
const files = await listFiles(r.abs, cwd);
|
|
115
123
|
const include = typeof args.include === "string" && args.include.length > 0 ? args.include : null;
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
124
|
+
// Single scan for all modes: ONE read per file (the old code read every
|
|
125
|
+
// file twice — counts pass, then content pass). Counts are matching LINES
|
|
126
|
+
// per file (ripgrep --count semantics); content hits accumulate inline in
|
|
127
|
+
// the same alpha order the two-pass scan produced, so outputs are
|
|
128
|
+
// byte-identical with half the I/O.
|
|
129
|
+
const collectHits = mode === "content";
|
|
130
|
+
const sorted = files.sort();
|
|
131
|
+
// Bulk read (bounded parallelism), then regex sequentially: I/O overlaps
|
|
132
|
+
// while outputs and first-failure errors keep exact alpha order.
|
|
133
|
+
const bodies = await mapLimit(sorted, SCAN_CONCURRENCY, async (rel) => {
|
|
120
134
|
if (include && !matchesGlob(include, rel))
|
|
121
|
-
|
|
122
|
-
let text;
|
|
135
|
+
return null;
|
|
123
136
|
try {
|
|
124
|
-
text = await fsp.readFile(path.resolve(cwd, rel), "utf8");
|
|
137
|
+
const text = await fsp.readFile(path.resolve(cwd, rel), "utf8");
|
|
138
|
+
if (text.includes("\0"))
|
|
139
|
+
return null; // binary — skip
|
|
140
|
+
return { rel, text };
|
|
125
141
|
}
|
|
126
142
|
catch {
|
|
127
|
-
|
|
143
|
+
return null; // unreadable — skip
|
|
128
144
|
}
|
|
129
|
-
|
|
130
|
-
|
|
145
|
+
});
|
|
146
|
+
const counts = [];
|
|
147
|
+
const hits = [];
|
|
148
|
+
let hitsCapped = false;
|
|
149
|
+
for (const body of bodies) {
|
|
150
|
+
if (body === null)
|
|
151
|
+
continue;
|
|
152
|
+
if (collectHits && hitsCapped)
|
|
153
|
+
break;
|
|
154
|
+
const { rel, text } = body;
|
|
131
155
|
const lines = text.split("\n");
|
|
132
156
|
let n = 0;
|
|
133
157
|
for (let i = 0; i < lines.length; i++) {
|
|
158
|
+
let matched;
|
|
134
159
|
try {
|
|
135
|
-
|
|
136
|
-
continue;
|
|
137
|
-
n += 1;
|
|
160
|
+
matched = re.test(lines[i]);
|
|
138
161
|
}
|
|
139
162
|
catch {
|
|
140
163
|
return err(`regex failed on input: ${args.pattern}`);
|
|
141
164
|
}
|
|
142
165
|
// Reset lastIndex in case the pattern is global/sticky.
|
|
143
166
|
re.lastIndex = 0;
|
|
167
|
+
if (!matched)
|
|
168
|
+
continue;
|
|
169
|
+
n += 1;
|
|
170
|
+
if (collectHits && !hitsCapped) {
|
|
171
|
+
const line = lines[i];
|
|
172
|
+
hits.push(`${rel}:${i + 1}: ${line.length > 200 ? line.slice(0, 200) + "…" : line}`);
|
|
173
|
+
if (hits.length >= GREP_MATCH_CAP)
|
|
174
|
+
hitsCapped = true;
|
|
175
|
+
}
|
|
144
176
|
}
|
|
145
177
|
if (n > 0)
|
|
146
178
|
counts.push({ rel, n });
|
|
@@ -165,34 +197,9 @@ export async function grepTool(args, cwd = process.cwd()) {
|
|
|
165
197
|
out += "\n[truncated: more than 100 matching files]";
|
|
166
198
|
return out;
|
|
167
199
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
try {
|
|
172
|
-
text = await fsp.readFile(path.resolve(cwd, c.rel), "utf8");
|
|
173
|
-
}
|
|
174
|
-
catch {
|
|
175
|
-
continue; // vanished mid-search — skip
|
|
176
|
-
}
|
|
177
|
-
const lines = text.split("\n");
|
|
178
|
-
for (let i = 0; i < lines.length; i++) {
|
|
179
|
-
let line;
|
|
180
|
-
try {
|
|
181
|
-
if (!re.test(lines[i]))
|
|
182
|
-
continue;
|
|
183
|
-
line = lines[i];
|
|
184
|
-
}
|
|
185
|
-
catch {
|
|
186
|
-
return err(`regex failed on input: ${args.pattern}`);
|
|
187
|
-
}
|
|
188
|
-
// Reset lastIndex in case the pattern is global/sticky.
|
|
189
|
-
re.lastIndex = 0;
|
|
190
|
-
hits.push(`${c.rel}:${i + 1}: ${line.length > 200 ? line.slice(0, 200) + "…" : line}`);
|
|
191
|
-
if (hits.length >= GREP_MATCH_CAP) {
|
|
192
|
-
return hits.join("\n") + "\n[truncated: more than 100 matches]";
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
200
|
+
// Content hits accumulated inline above (same order, same cap).
|
|
201
|
+
if (hitsCapped)
|
|
202
|
+
return hits.join("\n") + "\n[truncated: more than 100 matches]";
|
|
196
203
|
return hits.length > 0 ? hits.join("\n") : "No matches.";
|
|
197
204
|
}
|
|
198
205
|
catch (e) {
|
|
@@ -219,8 +226,7 @@ export async function globTool(args, cwd = process.cwd()) {
|
|
|
219
226
|
}
|
|
220
227
|
if (!st.isDirectory())
|
|
221
228
|
return err(`not a directory: ${dir}`);
|
|
222
|
-
const files =
|
|
223
|
-
await walkFiles(r.abs, cwd, files);
|
|
229
|
+
const files = await listFiles(r.abs, cwd);
|
|
224
230
|
const matched = files.filter((rel) => matchesGlob(args.pattern, rel));
|
|
225
231
|
const withTime = await Promise.all(matched.map(async (rel) => ({ rel, t: await mtimeMs(path.resolve(cwd, rel)) })));
|
|
226
232
|
withTime.sort((a, b) => b.t - a.t || (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
package/dist/tools/shell.js
CHANGED
|
@@ -9,6 +9,7 @@ import * as os from "node:os";
|
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import { scrubSecrets } from "../policy.js";
|
|
11
11
|
import { PROVIDERS } from "../providers.js";
|
|
12
|
+
import { clearDirListingCache } from "./dir-cache.js";
|
|
12
13
|
import { appendOverflow } from "./overflow.js";
|
|
13
14
|
import { err, OUTPUT_CAP } from "./shared.js";
|
|
14
15
|
// ---- Background bash tasks (Claude-Code-style run_in_background) ----
|
|
@@ -120,6 +121,16 @@ async function startBackgroundBash(command, cwd) {
|
|
|
120
121
|
rec.exitCode = typeof code === "number" ? code : 1;
|
|
121
122
|
});
|
|
122
123
|
child.unref();
|
|
124
|
+
// A spawned command can touch anything (files, trees, checkouts): the
|
|
125
|
+
// directory-listing cache cannot know what changed, so drop it all. Cheap
|
|
126
|
+
// (next search rescans once) and exactly correct for tool-driven flows;
|
|
127
|
+
// only out-of-process edits stay TTL-bound.
|
|
128
|
+
try {
|
|
129
|
+
clearDirListingCache();
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// never break the tool
|
|
133
|
+
}
|
|
123
134
|
return JSON.stringify({ backgroundTaskId: id, status: "running", hint: "use bash_output to poll" });
|
|
124
135
|
}
|
|
125
136
|
catch (e) {
|
|
@@ -213,6 +224,14 @@ export function bashTool(args, cwd = process.cwd()) {
|
|
|
213
224
|
const timeoutMs = Math.min(Math.max(Math.floor(args.timeoutMs ?? 60000), 1), 120000);
|
|
214
225
|
return new Promise((resolve) => {
|
|
215
226
|
exec(args.command, { cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
227
|
+
// The command ran (whatever its exit): it may have mutated the tree,
|
|
228
|
+
// so the listing cache is dropped (see background path above).
|
|
229
|
+
try {
|
|
230
|
+
clearDirListingCache();
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// never break the tool
|
|
234
|
+
}
|
|
216
235
|
try {
|
|
217
236
|
const e = error;
|
|
218
237
|
const exitCode = e ? (typeof e.code === "number" ? e.code : 1) : 0;
|
package/dist/tools/todo.js
CHANGED
|
@@ -20,7 +20,7 @@ export function clearTodos() {
|
|
|
20
20
|
function renderTodos(items) {
|
|
21
21
|
if (items.length === 0)
|
|
22
22
|
return "Todo list is empty.";
|
|
23
|
-
const mark = (s) => s === "completed" ? "✅" : s === "in_progress" ? "🔧" : "
|
|
23
|
+
const mark = (s) => s === "completed" ? "✅" : s === "in_progress" ? "🔧" : "○";
|
|
24
24
|
return (`Todo list (${items.length}):\n` +
|
|
25
25
|
items
|
|
26
26
|
.map((t, i) => `${i + 1}. ${mark(t.status)} [${t.status}] ${t.content}${t.priority ? ` (${t.priority})` : ""}`)
|
package/dist/tools.js
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
// `"../src/tools.js"` import keeps working untouched.
|
|
11
11
|
export * from "./tools/filesystem.js";
|
|
12
12
|
export * from "./tools/fingerprints.js";
|
|
13
|
+
export * from "./tools/dir-cache.js";
|
|
13
14
|
export * from "./tools/overflow.js";
|
|
15
|
+
export * from "./tools/read-cache.js";
|
|
14
16
|
export * from "./tools/registry.js";
|
|
15
17
|
export * from "./tools/search.js";
|
|
16
18
|
export * from "./tools/shared.js";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { computeDiff } from "./diff.js";
|
|
4
|
+
import { SideBySideDiffView } from "./side-by-side.js";
|
|
5
|
+
import { theme } from "./theme.js";
|
|
6
|
+
import { LIST_WINDOW, windowedList } from "./tool-inspector.js";
|
|
7
|
+
// Cap for the panel detail (hunk headers excluded): full-file writes can
|
|
8
|
+
// be large — the head reviews, the trailer names the remainder, the file
|
|
9
|
+
// on disk is the whole truth.
|
|
10
|
+
export const DIFF_PANEL_MAX_LINES = 200;
|
|
11
|
+
// Group committed session previews by file: latest preview per path
|
|
12
|
+
// wins (a later edit supersedes the earlier view of the same file),
|
|
13
|
+
// first-seen path order kept. Previews without a path are ungroupable
|
|
14
|
+
// (they still render inline in the transcript) and skipped here.
|
|
15
|
+
// Pure — shared by the App opener and unit tests.
|
|
16
|
+
export function groupSessionDiffs(turns) {
|
|
17
|
+
const byPath = new Map();
|
|
18
|
+
for (const t of turns) {
|
|
19
|
+
const d = t.diff;
|
|
20
|
+
if (!d || !d.path)
|
|
21
|
+
continue;
|
|
22
|
+
byPath.set(d.path, d);
|
|
23
|
+
}
|
|
24
|
+
const files = [];
|
|
25
|
+
for (const [p, d] of byPath) {
|
|
26
|
+
let adds = 0;
|
|
27
|
+
let dels = 0;
|
|
28
|
+
try {
|
|
29
|
+
const r = computeDiff(d.oldText, d.newText);
|
|
30
|
+
adds = r.adds;
|
|
31
|
+
dels = r.dels;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// A preview that fails to diff still lists (counts stay 0) —
|
|
35
|
+
// review must never crash on data it already rendered inline.
|
|
36
|
+
}
|
|
37
|
+
files.push({ path: p, oldText: d.oldText, newText: d.newText, lang: d.lang, adds, dels });
|
|
38
|
+
}
|
|
39
|
+
return files;
|
|
40
|
+
}
|
|
41
|
+
export function DiffPanel({ files, index, expanded }) {
|
|
42
|
+
const sel = Math.max(0, Math.min(index, files.length - 1));
|
|
43
|
+
const rec = files[sel];
|
|
44
|
+
if (!rec)
|
|
45
|
+
return null;
|
|
46
|
+
if (!expanded) {
|
|
47
|
+
const win = windowedList(files, sel, LIST_WINDOW);
|
|
48
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Session changes \u2014 select a file to review (Enter expands, Esc closes):" }), win.above > 0 ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " ", win.above, " more"] })) : null, win.slice.map((r, k) => {
|
|
49
|
+
const i = win.start + k;
|
|
50
|
+
const hi = i === sel;
|
|
51
|
+
return (_jsxs(Text, { color: hi ? theme.color.selection : undefined, children: [hi ? `${theme.symbol.select} ` : theme.spacing.rowIndent, "+", r.adds, " \u2212", r.dels, " ", r.path] }, r.path));
|
|
52
|
+
}), win.below > 0 ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreBelow, " ", win.below, " more"] })) : null, _jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, "/", theme.symbol.moreBelow, " move \u00B7 Enter expands \u00B7 Esc closes"] })] }));
|
|
53
|
+
}
|
|
54
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [rec.path, _jsxs(Text, { dimColor: true, children: [" ", theme.symbol.separator, " +", rec.adds, " \u2212", rec.dels] })] }), _jsx(Text, { dimColor: true, children: theme.symbol.rule.repeat(32) }), _jsx(SideBySideDiffView, { oldText: rec.oldText, newText: rec.newText, lang: rec.lang, maxRows: DIFF_PANEL_MAX_LINES }), _jsx(Text, { dimColor: true, children: theme.symbol.rule.repeat(32) }), _jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, "/", theme.symbol.moreBelow, " prev/next file \u00B7 Enter collapses \u00B7 Esc closes"] })] }));
|
|
55
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Approval-time + transcript diff view: the unified, word-highlighted,
|
|
3
|
+
// syntax-colored preview for write/edit calls.
|
|
4
|
+
//
|
|
5
|
+
// Claude-Code parity, ATOM-ified: hunk headers + `-`/`+` lines +
|
|
6
|
+
// changed-word backgrounds + syntax foregrounds (ui/highlight, zero-dep),
|
|
7
|
+
// one <Text> per run (no per-character nodes), word diff + highlighting
|
|
8
|
+
// memoized/cached so the 1s busy tick never recomputes. Long lines are
|
|
9
|
+
// never truncated (Ink wraps; copy/paste stays intact) — only hunk COUNT
|
|
10
|
+
// is capped via maxLines so the modal stays compact. All paint comes from
|
|
11
|
+
// ui/theme tokens (Ink supports color + backgroundColor on Text — verified
|
|
12
|
+
// against the installed Ink 7 typings).
|
|
13
|
+
import React from "react";
|
|
14
|
+
import { Box, Text } from "ink";
|
|
15
|
+
import { computeDiff } from "./diff.js";
|
|
16
|
+
import { highlightLine } from "./highlight.js";
|
|
17
|
+
import { theme } from "./theme.js";
|
|
18
|
+
function syntaxColor(kind) {
|
|
19
|
+
if (kind === "keyword")
|
|
20
|
+
return theme.color.synKeyword;
|
|
21
|
+
if (kind === "string")
|
|
22
|
+
return theme.color.synString;
|
|
23
|
+
if (kind === "number")
|
|
24
|
+
return theme.color.synNumber;
|
|
25
|
+
return undefined; // comment → dim, plain → line paint
|
|
26
|
+
}
|
|
27
|
+
// One diff line body: word-diff runs sub-split by syntax spans. Both
|
|
28
|
+
// segmentations tile the same line text, so walking them together with
|
|
29
|
+
// an offset cursor paints every char exactly once (text integrity is
|
|
30
|
+
// pinned by tests — highlighting must never alter content). Exported:
|
|
31
|
+
// the side-by-side view (ui/side-by-side) reuses it per pane cell.
|
|
32
|
+
export function LineBody({ lineText, runs, base, lang, }) {
|
|
33
|
+
const baseColor = base === "add" ? theme.color.success : theme.color.toolError;
|
|
34
|
+
const hlBg = base === "add" ? "green" : "red";
|
|
35
|
+
const langKnown = lang === "c" || lang === "py" || lang === "sh" || lang === "data";
|
|
36
|
+
const syn = langKnown ? highlightLine(lineText, lang) : [];
|
|
37
|
+
const nodes = [];
|
|
38
|
+
let offset = 0;
|
|
39
|
+
let synIdx = 0;
|
|
40
|
+
runs.forEach((r, k) => {
|
|
41
|
+
const runStart = offset;
|
|
42
|
+
const runEnd = offset + r.text.length;
|
|
43
|
+
offset = runEnd;
|
|
44
|
+
if (r.changed) {
|
|
45
|
+
// Changed words keep the high-contrast background treatment —
|
|
46
|
+
// syntax hues would muddy the signal.
|
|
47
|
+
nodes.push(_jsx(Text, { backgroundColor: hlBg, color: "black", bold: true, children: r.text }, k));
|
|
48
|
+
// Advance the syntax cursor past this run so later runs align.
|
|
49
|
+
while (synIdx < syn.length && syn[synIdx].end <= runEnd)
|
|
50
|
+
synIdx += 1;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// Unchanged text: paint syntax spans; plain spans inherit the line
|
|
54
|
+
// paint (base tint for unknown files, terminal default when the
|
|
55
|
+
// language is known — the +/- prefix carries line identity there).
|
|
56
|
+
// Skip syntax runs that end before this run starts (can happen only
|
|
57
|
+
// if segmentations disagree — defensive, never expected).
|
|
58
|
+
while (synIdx < syn.length && syn[synIdx].end <= runStart)
|
|
59
|
+
synIdx += 1;
|
|
60
|
+
let si = synIdx;
|
|
61
|
+
const parts = [];
|
|
62
|
+
let pk = 0;
|
|
63
|
+
while (si < syn.length && syn[si].start < runEnd) {
|
|
64
|
+
const s = syn[si];
|
|
65
|
+
const a = Math.max(s.start, runStart);
|
|
66
|
+
const b = Math.min(s.end, runEnd);
|
|
67
|
+
if (b > a) {
|
|
68
|
+
const piece = r.text.slice(a - runStart, b - runStart);
|
|
69
|
+
const fg = syntaxColor(s.kind) ?? (langKnown ? undefined : baseColor);
|
|
70
|
+
parts.push(s.kind === "comment" ? (_jsx(Text, { dimColor: true, children: piece }, pk++)) : (_jsx(Text, { color: fg, children: piece }, pk++)));
|
|
71
|
+
}
|
|
72
|
+
if (s.end <= runEnd)
|
|
73
|
+
si += 1;
|
|
74
|
+
else
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
if (parts.length === 0) {
|
|
78
|
+
parts.push(_jsx(Text, { color: langKnown ? undefined : baseColor, children: r.text }, pk++));
|
|
79
|
+
}
|
|
80
|
+
nodes.push(_jsx(Text, { children: parts }, k));
|
|
81
|
+
});
|
|
82
|
+
return _jsx(Text, { color: langKnown ? undefined : baseColor, children: nodes });
|
|
83
|
+
}
|
|
84
|
+
function DiffViewInner({ oldText, newText, lang = null, maxLines = Infinity }) {
|
|
85
|
+
const diff = React.useMemo(() => computeDiff(oldText, newText), [oldText, newText]);
|
|
86
|
+
if (diff.skipped) {
|
|
87
|
+
return _jsx(Text, { dimColor: true, children: diff.skipped });
|
|
88
|
+
}
|
|
89
|
+
if (diff.hunks.length === 0) {
|
|
90
|
+
return _jsx(Text, { dimColor: true, children: "(no visible changes)" });
|
|
91
|
+
}
|
|
92
|
+
let bodyLines = 0;
|
|
93
|
+
const overflow = (() => {
|
|
94
|
+
let total = 0;
|
|
95
|
+
for (const h of diff.hunks)
|
|
96
|
+
total += h.lines.length;
|
|
97
|
+
return Math.max(0, total - maxLines);
|
|
98
|
+
})();
|
|
99
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [diff.isNewFile ? "new file " : "", "+", diff.adds, " \u2212", diff.dels] }), diff.hunks.map((h, hi) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["@@ -", h.oldStart, ",", h.oldLines, " +", h.newStart, ",", h.newLines, " @@"] }), h.lines.map((ln, k) => {
|
|
100
|
+
bodyLines += 1;
|
|
101
|
+
if (bodyLines > maxLines)
|
|
102
|
+
return null;
|
|
103
|
+
if (ln.kind === "context") {
|
|
104
|
+
return (_jsxs(Text, { dimColor: true, children: [" ", ln.text.length > 0 ? ln.text : " "] }, k));
|
|
105
|
+
}
|
|
106
|
+
if (ln.kind === "del") {
|
|
107
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.color.toolError, children: "- " }), _jsx(LineBody, { lineText: ln.text, runs: ln.runs, base: "del", lang: lang }), ln.text.length === 0 ? " " : null] }, k));
|
|
108
|
+
}
|
|
109
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.color.success, children: "+ " }), _jsx(LineBody, { lineText: ln.text, runs: ln.runs, base: "add", lang: lang }), ln.text.length === 0 ? " " : null] }, k));
|
|
110
|
+
})] }, hi))), overflow > 0 ? _jsxs(Text, { dimColor: true, children: ["\u2026 ", overflow, " more line", overflow === 1 ? "" : "s"] }) : null, diff.truncated ? _jsx(Text, { dimColor: true, children: "(diff truncated at 400 changed lines)" }) : null] }));
|
|
111
|
+
}
|
|
112
|
+
export const DiffView = React.memo(DiffViewInner);
|