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.
@@ -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
+ }
@@ -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
  {
@@ -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 { err, GLOB_MATCH_CAP, GREP_MATCH_CAP, invalidCall, resolveSandbox, SKIP_DIRS } from "./shared.js";
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
- // Per-file match counts in alpha order (single scan for all modes).
117
- // Counts are matching LINES per file (ripgrep --count semantics).
118
- const counts = [];
119
- for (const rel of files.sort()) {
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
- continue;
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
- continue; // unreadable/binary — skip
143
+ return null; // unreadable — skip
128
144
  }
129
- if (text.includes("\0"))
130
- continue; // binary — skip
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
- if (!re.test(lines[i]))
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
- const hits = [];
169
- for (const c of counts) {
170
- let text;
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));
@@ -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;
@@ -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);