atom-agent 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +5 -4
- package/dist/App.js +738 -79
- package/dist/adapters.js +30 -8
- package/dist/agent/gates.js +14 -1
- package/dist/agent/loop-guard.js +11 -13
- package/dist/agent/loop.js +212 -69
- package/dist/agent/normalize.js +9 -2
- package/dist/cli.js +16 -2
- package/dist/compact.js +128 -2
- package/dist/env-block.js +43 -5
- package/dist/scheduler.js +101 -21
- package/dist/sessions.js +524 -0
- package/dist/system.js +89 -13
- package/dist/tools/dir-cache.js +7 -0
- package/dist/tools/filesystem.js +3 -2
- package/dist/tools/registry.js +1 -0
- package/dist/tools/ripgrep.js +256 -0
- package/dist/tools/search.js +119 -58
- package/dist/tools/shared.js +39 -0
- package/dist/tools/shell.js +7 -5
- package/dist/tools/web.js +6 -6
- package/dist/tools.js +1 -0
- package/dist/ui/diff-view.js +7 -2
- package/dist/ui/live-host.js +18 -0
- package/dist/ui/live-tail.js +9 -3
- package/dist/ui/markdown.js +26 -2
- package/dist/ui/palette.js +2 -0
- package/dist/ui/side-by-side.js +2 -2
- package/dist/ui/status-host.js +22 -0
- package/dist/ui/stream-store.js +48 -0
- package/dist/ui/tool-inspector.js +7 -1
- package/dist/ui/transcript.js +92 -38
- package/dist/zen.js +66 -13
- package/package.json +1 -1
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// ripgrep-backed content search: matches from the `rg` binary when it is
|
|
2
|
+
// the faster choice, with the walker as the always-correct fallback.
|
|
3
|
+
//
|
|
4
|
+
// Routing (see grepTool): the caller already enumerated the file set, so the
|
|
5
|
+
// threshold decides on COUNT alone — enumeration cost is sunk. Below the
|
|
6
|
+
// threshold the walker wins (no ~80ms Windows spawn tax); above it rg's
|
|
7
|
+
// parallel scanner wins by multiples. `ATOM_RG_MIN_FILES` overrides the
|
|
8
|
+
// default; `ATOM_RG=0` forces the walker (degraded envs, tests).
|
|
9
|
+
//
|
|
10
|
+
// Exactness contract (parity-tested, never assumed):
|
|
11
|
+
// - File COVERAGE is the enumerated set, always: rg output is intersected
|
|
12
|
+
// with it, so git-ignored-untracked files, dotfiles, and SKIP_DIRS behave
|
|
13
|
+
// exactly like the walker path in both git and non-git trees.
|
|
14
|
+
// - `include` globs filter in JS (shared matcher), never via `-g`: our
|
|
15
|
+
// minimal glob dialect and gitignore semantics could otherwise drift.
|
|
16
|
+
// - Case sensitivity, binary skipping (rg skips NUL files like the walker),
|
|
17
|
+
// and CRLF handling (one trailing \r\n stripped, mirroring split("\n"))
|
|
18
|
+
// all match the walker.
|
|
19
|
+
// - Modes map 1:1: content via `--json --max-count` merged in alpha order
|
|
20
|
+
// with the same 100-hit cap + note; files_with_matches via
|
|
21
|
+
// `--files-with-matches` into the existing recency pipeline; count via
|
|
22
|
+
// `--count` into the existing totals pipeline.
|
|
23
|
+
// - ANY rg failure (missing binary, bad exit, unparseable output, invalid
|
|
24
|
+
// regex for Rust's engine such as lookahead) returns null and the caller
|
|
25
|
+
// runs the walker — search never fails when the walker could answer.
|
|
26
|
+
// - No timeout: the walker path is equally unbounded, and a timeout would
|
|
27
|
+
// invent a failure mode neither path had.
|
|
28
|
+
//
|
|
29
|
+
// Known untested edges (documented, not solved): symlink handling (no
|
|
30
|
+
// privilege to create them in this env — both sides skip them by
|
|
31
|
+
// construction: walker via isFile/isDirectory checks, rg via no-follow
|
|
32
|
+
// default), lone-\r line endings, non-ASCII case/boundary semantics.
|
|
33
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
34
|
+
import { rgRelToCwdRel } from "./dir-cache.js";
|
|
35
|
+
// Routing threshold (measured, Windows, 3000-file fixture): below it the
|
|
36
|
+
// walker wins outright (no ~100ms spawn tax); above it rg wins clearly
|
|
37
|
+
// (643ms vs 872ms end-to-end at 3000 files, and the gap widens with size
|
|
38
|
+
// since the walker pays per-file reads while rg does not). Conservative on
|
|
39
|
+
// purpose: small/medium scopes stay on the exact legacy path, and
|
|
40
|
+
// `ATOM_RG_MIN_FILES` overrides per environment.
|
|
41
|
+
export const RG_MIN_FILES_DEFAULT = 1000;
|
|
42
|
+
const RG_MAX_BUFFER = 64 * 1024 * 1024;
|
|
43
|
+
//rg --max-count per file for content mode: bounds output while keeping the
|
|
44
|
+
// merged take-100 exact. Rationale: take-100-alpha needs every file's FULL
|
|
45
|
+
// matching-line list UNLESS a file is capped — so any file hitting the cap
|
|
46
|
+
// forces a walker fallback (rare pathological case: 1000+ matches in one
|
|
47
|
+
// file). Without the cap a minified bundle could dump megabytes of JSON.
|
|
48
|
+
const RG_CONTENT_MAX_COUNT = 1000;
|
|
49
|
+
const stats = { uses: 0, fallbacks: 0 };
|
|
50
|
+
let availability = null;
|
|
51
|
+
export function rgEnabled() {
|
|
52
|
+
const raw = process.env.ATOM_RG;
|
|
53
|
+
if (raw === undefined)
|
|
54
|
+
return true;
|
|
55
|
+
const v = raw.trim().toLowerCase();
|
|
56
|
+
return !(v === "0" || v === "false" || v === "no" || v === "off");
|
|
57
|
+
}
|
|
58
|
+
export function rgMinFiles() {
|
|
59
|
+
const raw = process.env.ATOM_RG_MIN_FILES;
|
|
60
|
+
if (raw !== undefined) {
|
|
61
|
+
const n = Number(raw.trim());
|
|
62
|
+
if (Number.isFinite(n) && n > 0)
|
|
63
|
+
return Math.floor(n);
|
|
64
|
+
}
|
|
65
|
+
return RG_MIN_FILES_DEFAULT;
|
|
66
|
+
}
|
|
67
|
+
// One-time `rg --version` probe (lazy so small searches never pay the spawn).
|
|
68
|
+
// Never throws; false on any failure.
|
|
69
|
+
export function rgAvailable() {
|
|
70
|
+
if (!rgEnabled())
|
|
71
|
+
return false;
|
|
72
|
+
if (availability !== null)
|
|
73
|
+
return availability;
|
|
74
|
+
try {
|
|
75
|
+
const out = execFileSync("rg", ["--version"], {
|
|
76
|
+
timeout: 10000,
|
|
77
|
+
encoding: "utf8",
|
|
78
|
+
windowsHide: true,
|
|
79
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
80
|
+
});
|
|
81
|
+
availability = typeof out === "string" && out.includes("ripgrep");
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
availability = false;
|
|
85
|
+
}
|
|
86
|
+
return availability;
|
|
87
|
+
}
|
|
88
|
+
// Test seam: reset the cached probe (production never calls this).
|
|
89
|
+
export function resetRgAvailable() {
|
|
90
|
+
availability = null;
|
|
91
|
+
}
|
|
92
|
+
export function getRipgrepStats() {
|
|
93
|
+
return { ...stats, available: rgAvailable() };
|
|
94
|
+
}
|
|
95
|
+
export function resetRipgrepStats() {
|
|
96
|
+
stats.uses = 0;
|
|
97
|
+
stats.fallbacks = 0;
|
|
98
|
+
}
|
|
99
|
+
function runRg(absDir, args) {
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
execFile("rg", args, { cwd: absDir, timeout: 0, maxBuffer: RG_MAX_BUFFER, windowsHide: true }, (err, stdout, stderr) => {
|
|
102
|
+
const code = err == null ? 0 : typeof err.code === "number" ? err.code : 2;
|
|
103
|
+
resolve({
|
|
104
|
+
code,
|
|
105
|
+
stdout: typeof stdout === "string" ? stdout : String(stdout ?? ""),
|
|
106
|
+
stderr: typeof stderr === "string" ? stderr : String(stderr ?? ""),
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function isRegexError(stderr) {
|
|
112
|
+
return /regex parse error|unrecognized|error parsing regex/i.test(stderr);
|
|
113
|
+
}
|
|
114
|
+
// Shared runner: exit 0 with output wins; exit 1 + empty means no matches
|
|
115
|
+
// (caller renders the mode's empty shape); anything else is a fallback
|
|
116
|
+
// signal — with regex-parse errors being the EXPECTED fallback trigger
|
|
117
|
+
// (lookahead and friends are valid JS, invalid Rust).
|
|
118
|
+
async function runRgSearch(absDir, baseArgs, pattern) {
|
|
119
|
+
let res;
|
|
120
|
+
try {
|
|
121
|
+
res = await runRg(absDir, [...baseArgs, "-e", pattern, "--", "."]);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return { kind: "fallback" };
|
|
125
|
+
}
|
|
126
|
+
if (res.code === 0)
|
|
127
|
+
return { kind: "ok", stdout: res.stdout };
|
|
128
|
+
if (res.code === 1)
|
|
129
|
+
return { kind: "empty" };
|
|
130
|
+
if (isRegexError(res.stderr))
|
|
131
|
+
return { kind: "fallback" };
|
|
132
|
+
return { kind: "fallback" };
|
|
133
|
+
}
|
|
134
|
+
// Strip the leading "./" rg emits for cwd-relative paths + normalize to the
|
|
135
|
+
// posix rels the enumerated set uses. Returns null when unusable.
|
|
136
|
+
function toRel(rawPath) {
|
|
137
|
+
if (typeof rawPath !== "string" || rawPath.length === 0)
|
|
138
|
+
return null;
|
|
139
|
+
let p = rawPath.replace(/\\/g, "/");
|
|
140
|
+
if (p.startsWith("./"))
|
|
141
|
+
p = p.slice(2);
|
|
142
|
+
if (p.length === 0 || p === ".")
|
|
143
|
+
return null;
|
|
144
|
+
return p;
|
|
145
|
+
}
|
|
146
|
+
// Mirror of split("\n") line semantics: strip exactly one trailing \n and
|
|
147
|
+
// NOTHING else. In particular a CRLF line keeps its \r, exactly like the
|
|
148
|
+
// walker (parity-tested) — invisible on screen, byte-identical in output.
|
|
149
|
+
function splitLine(text) {
|
|
150
|
+
if (text.endsWith("\n"))
|
|
151
|
+
return text.slice(0, -1);
|
|
152
|
+
return text;
|
|
153
|
+
}
|
|
154
|
+
const BASE_ARGS = ["--hidden", "--no-ignore", "--glob", "!node_modules/**", "--glob", "!.git/**"];
|
|
155
|
+
function parseJsonEvents(stdout) {
|
|
156
|
+
const out = [];
|
|
157
|
+
for (const line of stdout.split("\n")) {
|
|
158
|
+
const t = line.trim();
|
|
159
|
+
if (!t.startsWith("{"))
|
|
160
|
+
continue;
|
|
161
|
+
try {
|
|
162
|
+
const v = JSON.parse(t);
|
|
163
|
+
if (typeof v === "object" && v !== null)
|
|
164
|
+
out.push(v);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// malformed event line: skip, never crash (caller falls back on empty)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
// Content scan: --json matches merged per file (line order), files in alpha
|
|
173
|
+
// order. Hits carry raw line text; the caller applies the shared 200-char
|
|
174
|
+
// trim + 100-hit cap + note (same code as the walker path would — see
|
|
175
|
+
// grepTool; this module only supplies ordered raw material).
|
|
176
|
+
export async function rgContentHits(absDir, cwd, pattern, allowed) {
|
|
177
|
+
const run = await runRgSearch(absDir, [...BASE_ARGS, "--json", "--max-count", String(RG_CONTENT_MAX_COUNT)], pattern);
|
|
178
|
+
if (run.kind !== "ok")
|
|
179
|
+
return run.kind === "empty" ? { counts: [], hits: [], cappedFile: false } : null;
|
|
180
|
+
const perFile = new Map();
|
|
181
|
+
let cappedFile = false;
|
|
182
|
+
for (const evt of parseJsonEvents(run.stdout)) {
|
|
183
|
+
if (evt["type"] !== "match")
|
|
184
|
+
continue;
|
|
185
|
+
const data = evt["data"];
|
|
186
|
+
if (typeof data !== "object" || data === null)
|
|
187
|
+
continue;
|
|
188
|
+
const rawPath = data["path"]?.text;
|
|
189
|
+
const dirRel = typeof rawPath === "string" ? toRel(rawPath) : null;
|
|
190
|
+
const rel = dirRel === null ? null : rgRelToCwdRel(absDir, cwd, dirRel);
|
|
191
|
+
if (!rel || !allowed.has(rel))
|
|
192
|
+
continue;
|
|
193
|
+
const lineNo = data["line_number"];
|
|
194
|
+
const lines = data["lines"];
|
|
195
|
+
if (typeof lineNo !== "number" || typeof lines?.text !== "string")
|
|
196
|
+
continue;
|
|
197
|
+
const list = perFile.get(rel) ?? [];
|
|
198
|
+
list.push({ line: Math.floor(lineNo), text: splitLine(lines.text) });
|
|
199
|
+
perFile.set(rel, list);
|
|
200
|
+
}
|
|
201
|
+
const counts = [];
|
|
202
|
+
const hits = [];
|
|
203
|
+
for (const rel of [...perFile.keys()].sort()) {
|
|
204
|
+
const list = perFile.get(rel);
|
|
205
|
+
// De-dupe multiple submatches on one line (walker counts LINES).
|
|
206
|
+
const seen = new Set();
|
|
207
|
+
let n = 0;
|
|
208
|
+
for (const h of list) {
|
|
209
|
+
if (seen.has(h.line))
|
|
210
|
+
continue;
|
|
211
|
+
seen.add(h.line);
|
|
212
|
+
n += 1;
|
|
213
|
+
hits.push({ rel, line: h.line, text: h.text });
|
|
214
|
+
}
|
|
215
|
+
// max-count caps MATCHES per file: a full batch means that file may hold
|
|
216
|
+
// more (the walker's exactly-100 ambiguity behaves the same way).
|
|
217
|
+
if (list.length >= RG_CONTENT_MAX_COUNT)
|
|
218
|
+
cappedFile = true;
|
|
219
|
+
if (n > 0)
|
|
220
|
+
counts.push({ rel, n });
|
|
221
|
+
}
|
|
222
|
+
stats.uses += 1;
|
|
223
|
+
return { counts, hits, cappedFile };
|
|
224
|
+
}
|
|
225
|
+
// files_with_matches + count scan: per-file matching-LINE counts via
|
|
226
|
+
// `rg --count` (ripgrep --count semantics already match the walker's).
|
|
227
|
+
// files_with_matches derives its file set from these counts, so one spawn
|
|
228
|
+
// serves both modes. Caller applies totals/caps/recency.
|
|
229
|
+
export async function rgFileCounts(absDir, cwd, pattern, allowed) {
|
|
230
|
+
const run = await runRgSearch(absDir, [...BASE_ARGS, "--count"], pattern);
|
|
231
|
+
if (run.kind !== "ok")
|
|
232
|
+
return run.kind === "empty" ? { counts: [] } : null;
|
|
233
|
+
const counts = [];
|
|
234
|
+
for (const rawLine of run.stdout.split("\n")) {
|
|
235
|
+
const line = rawLine.trim();
|
|
236
|
+
if (!line)
|
|
237
|
+
continue;
|
|
238
|
+
// Right-split: Windows drive letters contain colons.
|
|
239
|
+
const idx = line.lastIndexOf(":");
|
|
240
|
+
if (idx <= 0)
|
|
241
|
+
continue;
|
|
242
|
+
const dirRel = toRel(line.slice(0, idx));
|
|
243
|
+
const rel = dirRel === null ? null : rgRelToCwdRel(absDir, cwd, dirRel);
|
|
244
|
+
const n = Number(line.slice(idx + 1));
|
|
245
|
+
if (!rel || !allowed.has(rel) || !Number.isFinite(n) || n <= 0)
|
|
246
|
+
continue;
|
|
247
|
+
counts.push({ rel, n: Math.floor(n) });
|
|
248
|
+
}
|
|
249
|
+
counts.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
250
|
+
stats.uses += 1;
|
|
251
|
+
return { counts };
|
|
252
|
+
}
|
|
253
|
+
// Record a fallback (rg answered nothing usable; walker takes over).
|
|
254
|
+
export function noteRgFallback() {
|
|
255
|
+
stats.fallbacks += 1;
|
|
256
|
+
}
|
package/dist/tools/search.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import { promises as fsp } from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { listFiles } from "./dir-cache.js";
|
|
6
|
-
import {
|
|
6
|
+
import { appendOverflow } from "./overflow.js";
|
|
7
|
+
import { noteRgFallback, rgAvailable, rgContentHits, rgFileCounts, rgMinFiles, } from "./ripgrep.js";
|
|
8
|
+
import { err, GLOB_MATCH_CAP, GREP_MATCH_CAP, invalidCall, READ_CHAR_CAP, resolveSandbox, truncateHead } from "./shared.js";
|
|
7
9
|
// Minimal glob matcher: supports **, **/, *, ?. Used for grep `include`
|
|
8
10
|
// and the glob tool. Patterns without a slash match the basename.
|
|
9
11
|
function globToRegExp(glob) {
|
|
@@ -74,6 +76,42 @@ async function mapLimit(items, limit, fn) {
|
|
|
74
76
|
await Promise.all(workers);
|
|
75
77
|
return out;
|
|
76
78
|
}
|
|
79
|
+
// One hit line, shared by the walker and ripgrep scans so both paths stay
|
|
80
|
+
// byte-identical by construction (1-based line, 200-char trim).
|
|
81
|
+
export function formatGrepHit(rel, lineNo, line) {
|
|
82
|
+
return `${rel}:${lineNo}: ${line.length > 200 ? line.slice(0, 200) + "…" : line}`;
|
|
83
|
+
}
|
|
84
|
+
// ripgrep scan for one grep call: fills the same {counts, hits, hitsCapped}
|
|
85
|
+
// shape as the walker scan below (or null = run the walker). Content hits
|
|
86
|
+
// arrive merged in (file alpha, line) order; the 100-hit cap + note apply
|
|
87
|
+
// exactly like the walker path (including its take-100-blindly quirk).
|
|
88
|
+
async function scanWithRipgrep(absDir, cwd, pattern, mode, allowed) {
|
|
89
|
+
if (mode === "content") {
|
|
90
|
+
const r = await rgContentHits(absDir, cwd, pattern, allowed);
|
|
91
|
+
if (r === null)
|
|
92
|
+
return null;
|
|
93
|
+
if (r.cappedFile)
|
|
94
|
+
return null; // pathological volume: walker stays exact
|
|
95
|
+
const hits = r.hits.slice(0, GREP_MATCH_CAP).map((h) => formatGrepHit(h.rel, h.line, h.text));
|
|
96
|
+
return { counts: r.counts, hits, hitsCapped: r.hits.length >= GREP_MATCH_CAP };
|
|
97
|
+
}
|
|
98
|
+
const r = await rgFileCounts(absDir, cwd, pattern, allowed);
|
|
99
|
+
if (r === null)
|
|
100
|
+
return null;
|
|
101
|
+
return { counts: r.counts, hits: [], hitsCapped: false };
|
|
102
|
+
}
|
|
103
|
+
// Issue 04: every tool output passes the shared head-truncation contract.
|
|
104
|
+
// Search hits are already line-capped (100/200) far below the byte cap, so
|
|
105
|
+
// this is a byte-identical safety net that never fires on reachable outputs
|
|
106
|
+
// — grep/glob shape, ordering, modes, count notes, and the ripgrep/walker
|
|
107
|
+
// selection (issue 01) stay exactly as settled. If it ever fires, the head
|
|
108
|
+
// is line-aligned with total-vs-emitted counts plus the overflow pointer.
|
|
109
|
+
function capSearchOutput(out, label) {
|
|
110
|
+
if (out.length <= READ_CHAR_CAP)
|
|
111
|
+
return out;
|
|
112
|
+
const t = truncateHead(out, READ_CHAR_CAP, `\n[truncated: ${label} exceeded 64KB]`);
|
|
113
|
+
return appendOverflow(t.head, t.note, label, out);
|
|
114
|
+
}
|
|
77
115
|
// Best-effort mtime (ms) for recency sorting; 0 when the file cannot be
|
|
78
116
|
// stat'ed (keeps such entries last instead of failing the search).
|
|
79
117
|
async function mtimeMs(abs) {
|
|
@@ -84,6 +122,56 @@ async function mtimeMs(abs) {
|
|
|
84
122
|
return 0;
|
|
85
123
|
}
|
|
86
124
|
}
|
|
125
|
+
async function scanWithWalker(cwd, re, pattern, sorted, include, mode) {
|
|
126
|
+
const collectHits = mode === "content";
|
|
127
|
+
const bodies = await mapLimit(sorted, SCAN_CONCURRENCY, async (rel) => {
|
|
128
|
+
if (include && !matchesGlob(include, rel))
|
|
129
|
+
return null;
|
|
130
|
+
try {
|
|
131
|
+
const text = await fsp.readFile(path.resolve(cwd, rel), "utf8");
|
|
132
|
+
if (text.includes("\0"))
|
|
133
|
+
return null; // binary — skip
|
|
134
|
+
return { rel, text };
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null; // unreadable — skip
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
const counts = [];
|
|
141
|
+
const hits = [];
|
|
142
|
+
let hitsCapped = false;
|
|
143
|
+
for (const body of bodies) {
|
|
144
|
+
if (body === null)
|
|
145
|
+
continue;
|
|
146
|
+
if (collectHits && hitsCapped)
|
|
147
|
+
break;
|
|
148
|
+
const { rel, text } = body;
|
|
149
|
+
const lines = text.split("\n");
|
|
150
|
+
let n = 0;
|
|
151
|
+
for (let i = 0; i < lines.length; i++) {
|
|
152
|
+
let matched;
|
|
153
|
+
try {
|
|
154
|
+
matched = re.test(lines[i]);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return { error: err(`regex failed on input: ${pattern}`) };
|
|
158
|
+
}
|
|
159
|
+
// Reset lastIndex in case the pattern is global/sticky.
|
|
160
|
+
re.lastIndex = 0;
|
|
161
|
+
if (!matched)
|
|
162
|
+
continue;
|
|
163
|
+
n += 1;
|
|
164
|
+
if (collectHits && !hitsCapped) {
|
|
165
|
+
hits.push(formatGrepHit(rel, i + 1, lines[i]));
|
|
166
|
+
if (hits.length >= GREP_MATCH_CAP)
|
|
167
|
+
hitsCapped = true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (n > 0)
|
|
171
|
+
counts.push({ rel, n });
|
|
172
|
+
}
|
|
173
|
+
return { counts, hits, hitsCapped };
|
|
174
|
+
}
|
|
87
175
|
// Line-regex search under dir (default "."). `include` is a glob like
|
|
88
176
|
// "*.ts". `outputMode` selects the shape (Claude-Code-style):
|
|
89
177
|
// - "content" (default): "file:line: text" lines, capped at 100 matches.
|
|
@@ -121,61 +209,34 @@ export async function grepTool(args, cwd = process.cwd()) {
|
|
|
121
209
|
return err(`not a directory: ${dir}`);
|
|
122
210
|
const files = await listFiles(r.abs, cwd);
|
|
123
211
|
const include = typeof args.include === "string" && args.include.length > 0 ? args.include : null;
|
|
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
212
|
const sorted = files.sort();
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
213
|
+
// Allowed set shared by both scan paths (include filtering is identical
|
|
214
|
+
// either way, so ripgrep coverage matches the walker exactly).
|
|
215
|
+
const allowed = new Set(sorted.filter((rel) => !include || matchesGlob(include, rel)));
|
|
216
|
+
// ripgrep fast path: large scopes only (process-spawn cost), silent
|
|
217
|
+
// walker fallback on anything unusable (missing binary, bad exit,
|
|
218
|
+
// unparseable output, JS-only regex). See src/tools/ripgrep.ts.
|
|
219
|
+
let counts;
|
|
220
|
+
let hits;
|
|
221
|
+
let hitsCapped;
|
|
222
|
+
if (rgAvailable() && sorted.length >= rgMinFiles()) {
|
|
223
|
+
const fast = await scanWithRipgrep(r.abs, cwd, args.pattern, mode, allowed);
|
|
224
|
+
if (fast !== null) {
|
|
225
|
+
({ counts, hits, hitsCapped } = fast);
|
|
144
226
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
continue;
|
|
152
|
-
if (collectHits && hitsCapped)
|
|
153
|
-
break;
|
|
154
|
-
const { rel, text } = body;
|
|
155
|
-
const lines = text.split("\n");
|
|
156
|
-
let n = 0;
|
|
157
|
-
for (let i = 0; i < lines.length; i++) {
|
|
158
|
-
let matched;
|
|
159
|
-
try {
|
|
160
|
-
matched = re.test(lines[i]);
|
|
161
|
-
}
|
|
162
|
-
catch {
|
|
163
|
-
return err(`regex failed on input: ${args.pattern}`);
|
|
164
|
-
}
|
|
165
|
-
// Reset lastIndex in case the pattern is global/sticky.
|
|
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
|
-
}
|
|
227
|
+
else {
|
|
228
|
+
noteRgFallback();
|
|
229
|
+
const walked = await scanWithWalker(cwd, re, args.pattern, sorted, include, mode);
|
|
230
|
+
if ("error" in walked)
|
|
231
|
+
return walked.error;
|
|
232
|
+
({ counts, hits, hitsCapped } = walked);
|
|
176
233
|
}
|
|
177
|
-
|
|
178
|
-
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
const walked = await scanWithWalker(cwd, re, args.pattern, sorted, include, mode);
|
|
237
|
+
if ("error" in walked)
|
|
238
|
+
return walked.error;
|
|
239
|
+
({ counts, hits, hitsCapped } = walked);
|
|
179
240
|
}
|
|
180
241
|
if (counts.length === 0)
|
|
181
242
|
return "No matches.";
|
|
@@ -186,7 +247,7 @@ export async function grepTool(args, cwd = process.cwd()) {
|
|
|
186
247
|
let out = `Found ${withTime.length} file(s)\n${listed.join("\n")}`;
|
|
187
248
|
if (withTime.length > GREP_MATCH_CAP)
|
|
188
249
|
out += "\n[truncated: more than 100 matching files]";
|
|
189
|
-
return out;
|
|
250
|
+
return capSearchOutput(out, "grep results");
|
|
190
251
|
}
|
|
191
252
|
if (mode === "count") {
|
|
192
253
|
const listed = counts.slice(0, GREP_MATCH_CAP);
|
|
@@ -195,12 +256,12 @@ export async function grepTool(args, cwd = process.cwd()) {
|
|
|
195
256
|
`\nFound ${total} total match(es) across ${counts.length} file(s).`;
|
|
196
257
|
if (counts.length > GREP_MATCH_CAP)
|
|
197
258
|
out += "\n[truncated: more than 100 matching files]";
|
|
198
|
-
return out;
|
|
259
|
+
return capSearchOutput(out, "grep results");
|
|
199
260
|
}
|
|
200
261
|
// Content hits accumulated inline above (same order, same cap).
|
|
201
262
|
if (hitsCapped)
|
|
202
|
-
return hits.join("\n") + "\n[truncated: more than 100 matches]";
|
|
203
|
-
return hits.length > 0 ? hits.join("\n") : "No matches.";
|
|
263
|
+
return capSearchOutput(hits.join("\n") + "\n[truncated: more than 100 matches]", "grep results");
|
|
264
|
+
return hits.length > 0 ? capSearchOutput(hits.join("\n"), "grep results") : "No matches.";
|
|
204
265
|
}
|
|
205
266
|
catch (e) {
|
|
206
267
|
return err(e instanceof Error ? e.message : String(e));
|
|
@@ -234,7 +295,7 @@ export async function globTool(args, cwd = process.cwd()) {
|
|
|
234
295
|
let out = capped.length > 0 ? capped.join("\n") : "No matches.";
|
|
235
296
|
if (withTime.length > GLOB_MATCH_CAP)
|
|
236
297
|
out += "\n[truncated: more than 200 matches]";
|
|
237
|
-
return out;
|
|
298
|
+
return capSearchOutput(out, "glob results");
|
|
238
299
|
}
|
|
239
300
|
catch (e) {
|
|
240
301
|
return err(e instanceof Error ? e.message : String(e));
|
package/dist/tools/shared.js
CHANGED
|
@@ -29,3 +29,42 @@ export function resolveSandbox(p, cwd = process.cwd()) {
|
|
|
29
29
|
return { error: "Error: invalid path" };
|
|
30
30
|
return { abs: path.resolve(cwd, p) };
|
|
31
31
|
}
|
|
32
|
+
export function truncateHead(full, maxChars, truncNote, maxLines = Number.MAX_SAFE_INTEGER) {
|
|
33
|
+
const text = typeof full === "string" ? full : "";
|
|
34
|
+
const cap = Math.max(1, Math.floor(maxChars));
|
|
35
|
+
const lineCap = Math.max(1, Math.floor(maxLines));
|
|
36
|
+
const totalChars = text.length;
|
|
37
|
+
const totalLines = text.length === 0 ? 0 : text.split("\n").length;
|
|
38
|
+
if (totalChars <= cap && totalLines <= lineCap) {
|
|
39
|
+
return {
|
|
40
|
+
head: text,
|
|
41
|
+
truncated: false,
|
|
42
|
+
totalChars,
|
|
43
|
+
emittedChars: totalChars,
|
|
44
|
+
totalLines,
|
|
45
|
+
emittedLines: totalLines,
|
|
46
|
+
note: "",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
let head = text;
|
|
50
|
+
if (totalChars > cap) {
|
|
51
|
+
const candidate = text.slice(0, cap);
|
|
52
|
+
const nl = candidate.lastIndexOf("\n");
|
|
53
|
+
// nl <= 0 means no usable boundary (single giant line, or the text
|
|
54
|
+
// starts with "\n"): keep the hard cut — the documented tail edge case.
|
|
55
|
+
head = nl > 0 ? candidate.slice(0, nl) : candidate;
|
|
56
|
+
}
|
|
57
|
+
if (totalLines > lineCap) {
|
|
58
|
+
const lineHead = text.split("\n").slice(0, lineCap).join("\n");
|
|
59
|
+
if (lineHead.length < head.length)
|
|
60
|
+
head = lineHead;
|
|
61
|
+
}
|
|
62
|
+
const emittedChars = head.length;
|
|
63
|
+
const emittedLines = head.length === 0 ? 0 : head.split("\n").length;
|
|
64
|
+
const detail = `showing ${emittedChars} of ${totalChars} chars ` +
|
|
65
|
+
`(${emittedLines} of ${totalLines} lines)`;
|
|
66
|
+
const note = truncNote.endsWith("]")
|
|
67
|
+
? `${truncNote.slice(0, -1)}; ${detail}]`
|
|
68
|
+
: `${truncNote} [${detail}]`;
|
|
69
|
+
return { head, truncated: true, totalChars, emittedChars, totalLines, emittedLines, note };
|
|
70
|
+
}
|
package/dist/tools/shell.js
CHANGED
|
@@ -11,7 +11,7 @@ import { scrubSecrets } from "../policy.js";
|
|
|
11
11
|
import { PROVIDERS } from "../providers.js";
|
|
12
12
|
import { clearDirListingCache } from "./dir-cache.js";
|
|
13
13
|
import { appendOverflow } from "./overflow.js";
|
|
14
|
-
import { err, OUTPUT_CAP } from "./shared.js";
|
|
14
|
+
import { err, OUTPUT_CAP, truncateHead } from "./shared.js";
|
|
15
15
|
// ---- Background bash tasks (Claude-Code-style run_in_background) ----
|
|
16
16
|
const BG_TASK_CAP = 20;
|
|
17
17
|
const BG_POLL_MS = 100;
|
|
@@ -162,8 +162,8 @@ function capBgStream(s, which) {
|
|
|
162
162
|
// bash_output passes through here.
|
|
163
163
|
const clean = scrubSecrets(s, providerSecrets());
|
|
164
164
|
if (clean.length > OUTPUT_CAP) {
|
|
165
|
-
const
|
|
166
|
-
return appendOverflow(
|
|
165
|
+
const t = truncateHead(clean, OUTPUT_CAP, `\n[truncated: ${which} exceeded 8KB]`);
|
|
166
|
+
return appendOverflow(t.head, t.note, `background ${which}`, clean);
|
|
167
167
|
}
|
|
168
168
|
return clean;
|
|
169
169
|
}
|
|
@@ -248,12 +248,14 @@ export function bashTool(args, cwd = process.cwd()) {
|
|
|
248
248
|
let stderrTruncated = false;
|
|
249
249
|
if (out.length > OUTPUT_CAP) {
|
|
250
250
|
const full = out;
|
|
251
|
-
|
|
251
|
+
const t = truncateHead(full, OUTPUT_CAP, "\n[truncated: stdout exceeded 8KB]");
|
|
252
|
+
out = appendOverflow(t.head, t.note, "command stdout", full);
|
|
252
253
|
stdoutTruncated = true;
|
|
253
254
|
}
|
|
254
255
|
if (errText.length > OUTPUT_CAP) {
|
|
255
256
|
const full = errText;
|
|
256
|
-
|
|
257
|
+
const t = truncateHead(full, OUTPUT_CAP, "\n[truncated: stderr exceeded 8KB]");
|
|
258
|
+
errText = appendOverflow(t.head, t.note, "command stderr", full);
|
|
257
259
|
stderrTruncated = true;
|
|
258
260
|
}
|
|
259
261
|
resolve(JSON.stringify({
|
package/dist/tools/web.js
CHANGED
|
@@ -5,7 +5,7 @@ import * as net from "node:net";
|
|
|
5
5
|
import { loadAtomConfig } from "../config.js";
|
|
6
6
|
import { classifyIp, defaultNetworkPolicy, isRedirectStatus, zoneAllows, zoneForAddresses, } from "../policy.js";
|
|
7
7
|
import { appendOverflow } from "./overflow.js";
|
|
8
|
-
import { err, READ_CHAR_CAP } from "./shared.js";
|
|
8
|
+
import { err, READ_CHAR_CAP, truncateHead } from "./shared.js";
|
|
9
9
|
// ---- Web tools (webfetch retrieval / websearch discovery) ----
|
|
10
10
|
const WEB_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
|
|
11
11
|
const WEBFETCH_DOWNLOAD_CAP = 1024 * 1024; // ~1MB download cap
|
|
@@ -318,12 +318,12 @@ export async function webfetchTool(args, opts) {
|
|
|
318
318
|
notes.push("[truncated: download exceeded ~1MB]");
|
|
319
319
|
if (text.length > READ_CHAR_CAP) {
|
|
320
320
|
const full = text;
|
|
321
|
-
const
|
|
322
|
-
text = head;
|
|
323
|
-
notes.push(
|
|
321
|
+
const t = truncateHead(full, READ_CHAR_CAP, "\n[truncated: output exceeded 64KB]");
|
|
322
|
+
text = t.head;
|
|
323
|
+
notes.push(t.note.replace(/^\n/, ""));
|
|
324
324
|
// Single spill: recover the pointer line from the composed tail.
|
|
325
|
-
const tailed = appendOverflow(head,
|
|
326
|
-
const overflowLine = tailed.slice((head +
|
|
325
|
+
const tailed = appendOverflow(t.head, t.note, "converted page text", full);
|
|
326
|
+
const overflowLine = tailed.slice((t.head + t.note + "\n").length);
|
|
327
327
|
if (overflowLine.startsWith("[overflow:"))
|
|
328
328
|
notes.push(overflowLine);
|
|
329
329
|
}
|
package/dist/tools.js
CHANGED
|
@@ -14,6 +14,7 @@ export * from "./tools/dir-cache.js";
|
|
|
14
14
|
export * from "./tools/overflow.js";
|
|
15
15
|
export * from "./tools/read-cache.js";
|
|
16
16
|
export * from "./tools/registry.js";
|
|
17
|
+
export * from "./tools/ripgrep.js";
|
|
17
18
|
export * from "./tools/search.js";
|
|
18
19
|
export * from "./tools/shared.js";
|
|
19
20
|
export * from "./tools/shell.js";
|
package/dist/ui/diff-view.js
CHANGED
|
@@ -29,7 +29,12 @@ function syntaxColor(kind) {
|
|
|
29
29
|
// an offset cursor paints every char exactly once (text integrity is
|
|
30
30
|
// pinned by tests — highlighting must never alter content). Exported:
|
|
31
31
|
// the side-by-side view (ui/side-by-side) reuses it per pane cell.
|
|
32
|
-
|
|
32
|
+
//
|
|
33
|
+
// Memoized: props are (lineText, runs, base, lang) — `runs` keeps identity
|
|
34
|
+
// from the parent's useMemo'd diff, and highlightLine is itself line-cached,
|
|
35
|
+
// so unrelated parent renders (ticks, keystrokes, appends elsewhere) skip
|
|
36
|
+
// both the walk and the tokenize lookup.
|
|
37
|
+
export const LineBody = React.memo(function LineBody({ lineText, runs, base, lang, }) {
|
|
33
38
|
const baseColor = base === "add" ? theme.color.success : theme.color.toolError;
|
|
34
39
|
const hlBg = base === "add" ? "green" : "red";
|
|
35
40
|
const langKnown = lang === "c" || lang === "py" || lang === "sh" || lang === "data";
|
|
@@ -80,7 +85,7 @@ export function LineBody({ lineText, runs, base, lang, }) {
|
|
|
80
85
|
nodes.push(_jsx(Text, { children: parts }, k));
|
|
81
86
|
});
|
|
82
87
|
return _jsx(Text, { color: langKnown ? undefined : baseColor, children: nodes });
|
|
83
|
-
}
|
|
88
|
+
});
|
|
84
89
|
function DiffViewInner({ oldText, newText, lang = null, maxLines = Infinity }) {
|
|
85
90
|
const diff = React.useMemo(() => computeDiff(oldText, newText), [oldText, newText]);
|
|
86
91
|
if (diff.skipped) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Live-tail host: the subscription boundary between App and the streaming UI.
|
|
3
|
+
//
|
|
4
|
+
// App renders this host with LOW-frequency props only (busy/held/empty flags,
|
|
5
|
+
// elapsed seconds, tool hint). The HIGH-frequency streaming text (draft +
|
|
6
|
+
// thinking, up to ~15 paints/sec via DRAFT_THROTTLE_MS) flows through the
|
|
7
|
+
// StreamStore instead: this host subscribes via useSyncExternalStore, so a
|
|
8
|
+
// token paint re-renders this host + LiveTail alone — App's body,
|
|
9
|
+
// reconciliation of every other leaf, and their prop assembly never run.
|
|
10
|
+
//
|
|
11
|
+
// LiveTail itself is untouched (same props API, same paint), so all existing
|
|
12
|
+
// LiveTail tests keep passing; only the delivery path changed.
|
|
13
|
+
import React, { useSyncExternalStore } from "react";
|
|
14
|
+
import { LiveTail } from "./live-tail.js";
|
|
15
|
+
export const LiveTailHost = React.memo(function LiveTailHost({ store, isEmpty, sessionHint, emptySessionTitle, busy, held, toolHint, toolElapsedSecs, elapsedSecs, showThinking, }) {
|
|
16
|
+
const snap = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
17
|
+
return (_jsx(LiveTail, { isEmpty: isEmpty, sessionHint: sessionHint, emptySessionTitle: emptySessionTitle, draft: snap.draft, thinking: snap.thinking, busy: busy, held: held, toolHint: toolHint, toolElapsedSecs: toolElapsedSecs, elapsedSecs: elapsedSecs, showThinking: showThinking }));
|
|
18
|
+
});
|