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
package/dist/ui/diff.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// Mirrors Claude's StructuredDiffFallback threshold: when the share of
|
|
2
|
+
// changed words in a paired line exceeds this, word highlighting would
|
|
3
|
+
// be noise — render the whole line flat instead.
|
|
4
|
+
export const CHANGE_THRESHOLD = 0.4;
|
|
5
|
+
// Mirrors Claude's DiffDetailView guards.
|
|
6
|
+
export const MAX_FILE_BYTES = 1_000_000;
|
|
7
|
+
export const MAX_CHANGED_LINES = 400;
|
|
8
|
+
export const DIFF_CONTEXT = 3;
|
|
9
|
+
// Worst-case guards for the O(ND) Myers pass + O(w1*w2) word pass.
|
|
10
|
+
const MAX_MYERS_LINES = 1000;
|
|
11
|
+
const MAX_WORD_TOKENS = 200;
|
|
12
|
+
function splitLines(s) {
|
|
13
|
+
if (s === "")
|
|
14
|
+
return [];
|
|
15
|
+
return s.replace(/\r\n?/g, "\n").split("\n");
|
|
16
|
+
}
|
|
17
|
+
function isBinary(s) {
|
|
18
|
+
return s.includes("\0");
|
|
19
|
+
}
|
|
20
|
+
// Tokenize into words + whitespace runs (whitespace kept so runs
|
|
21
|
+
// rejoin byte-identical to the source line).
|
|
22
|
+
function tokenize(line) {
|
|
23
|
+
const out = line.split(/(\s+)/g).filter((t) => t.length > 0);
|
|
24
|
+
return out.length > 0 ? out : [line];
|
|
25
|
+
}
|
|
26
|
+
function isWord(tok) {
|
|
27
|
+
return /\S/.test(tok);
|
|
28
|
+
}
|
|
29
|
+
// LCS table for small sequences (lines or words). Returns the matched
|
|
30
|
+
// index pairs in order.
|
|
31
|
+
function lcsPairs(a, b, eq) {
|
|
32
|
+
const n = a.length;
|
|
33
|
+
const m = b.length;
|
|
34
|
+
if (n === 0 || m === 0)
|
|
35
|
+
return [];
|
|
36
|
+
// Uint16 caps at 65535 — sequences here are small (words) or
|
|
37
|
+
// Myers-capped (lines); guard anyway.
|
|
38
|
+
const use32 = n > 6000 || m > 6000;
|
|
39
|
+
const w = m + 1;
|
|
40
|
+
const dp = use32 ? new Uint32Array((n + 1) * w) : new Uint16Array((n + 1) * w);
|
|
41
|
+
for (let i = 1; i <= n; i++) {
|
|
42
|
+
for (let j = 1; j <= m; j++) {
|
|
43
|
+
const v = eq(a[i - 1], b[j - 1])
|
|
44
|
+
? dp[(i - 1) * w + (j - 1)] + 1
|
|
45
|
+
: Math.max(dp[(i - 1) * w + j], dp[i * w + (j - 1)]);
|
|
46
|
+
dp[i * w + j] = v > 65535 && !use32 ? 65535 : v;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const pairs = [];
|
|
50
|
+
let i = n;
|
|
51
|
+
let j = m;
|
|
52
|
+
while (i > 0 && j > 0) {
|
|
53
|
+
if (eq(a[i - 1], b[j - 1])) {
|
|
54
|
+
pairs.push([i - 1, j - 1]);
|
|
55
|
+
i -= 1;
|
|
56
|
+
j -= 1;
|
|
57
|
+
}
|
|
58
|
+
else if (dp[(i - 1) * w + j] >= dp[i * w + (j - 1)]) {
|
|
59
|
+
i -= 1;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
j -= 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
pairs.reverse();
|
|
66
|
+
return pairs;
|
|
67
|
+
}
|
|
68
|
+
// Myers O(ND) line diff with a hard cell budget — falls back to a
|
|
69
|
+
// single del/add block when the files are too big to trace exactly.
|
|
70
|
+
// Returns ops over old lines (a) and new lines (b).
|
|
71
|
+
function myersOps(a, b) {
|
|
72
|
+
const n = a.length;
|
|
73
|
+
const m = b.length;
|
|
74
|
+
if (n === 0)
|
|
75
|
+
return b.map((_, j) => ({ kind: "add", b: j }));
|
|
76
|
+
if (m === 0)
|
|
77
|
+
return a.map((_, i) => ({ kind: "del", a: i }));
|
|
78
|
+
if (n + m > MAX_MYERS_LINES) {
|
|
79
|
+
// Linear fallback: common prefix/suffix, middle is one block.
|
|
80
|
+
let pre = 0;
|
|
81
|
+
while (pre < n && pre < m && a[pre] === b[pre])
|
|
82
|
+
pre += 1;
|
|
83
|
+
let suf = 0;
|
|
84
|
+
while (suf < n - pre && suf < m - pre && a[n - 1 - suf] === b[m - 1 - suf])
|
|
85
|
+
suf += 1;
|
|
86
|
+
const ops = [];
|
|
87
|
+
for (let i = 0; i < pre; i++)
|
|
88
|
+
ops.push({ kind: "same", a: i, b: i });
|
|
89
|
+
for (let i = pre; i < n - suf; i++)
|
|
90
|
+
ops.push({ kind: "del", a: i });
|
|
91
|
+
for (let j = pre; j < m - suf; j++)
|
|
92
|
+
ops.push({ kind: "add", b: j });
|
|
93
|
+
for (let k = 0; k < suf; k++)
|
|
94
|
+
ops.push({ kind: "same", a: n - suf + k, b: m - suf + k });
|
|
95
|
+
return ops;
|
|
96
|
+
}
|
|
97
|
+
// Exact Myers when small: LCS pairs, then expand to ops.
|
|
98
|
+
const pairs = lcsPairs(a, b, (x, y) => x === y);
|
|
99
|
+
const ops = [];
|
|
100
|
+
let i = 0;
|
|
101
|
+
let j = 0;
|
|
102
|
+
for (const [pi, pj] of pairs) {
|
|
103
|
+
while (i < pi)
|
|
104
|
+
ops.push({ kind: "del", a: i++ });
|
|
105
|
+
while (j < pj)
|
|
106
|
+
ops.push({ kind: "add", b: j++ });
|
|
107
|
+
ops.push({ kind: "same", a: i++, b: j++ });
|
|
108
|
+
}
|
|
109
|
+
while (i < n)
|
|
110
|
+
ops.push({ kind: "del", a: i++ });
|
|
111
|
+
while (j < m)
|
|
112
|
+
ops.push({ kind: "add", b: j++ });
|
|
113
|
+
return ops;
|
|
114
|
+
}
|
|
115
|
+
function flatRuns(text) {
|
|
116
|
+
return text === "" ? [] : [{ text, changed: false }];
|
|
117
|
+
}
|
|
118
|
+
// Word-level pairing for one del/add line pair. Returns runs for both
|
|
119
|
+
// sides; falls back to flat (line-level) when the pair is too big or
|
|
120
|
+
// too changed (CHANGE_THRESHOLD). Exported: the side-by-side builder
|
|
121
|
+
// below and the cell renderer (ui/diff-view) share it.
|
|
122
|
+
export function wordRuns(delText, addText) {
|
|
123
|
+
const dt = tokenize(delText);
|
|
124
|
+
const at = tokenize(addText);
|
|
125
|
+
if (dt.length > MAX_WORD_TOKENS || at.length > MAX_WORD_TOKENS) {
|
|
126
|
+
return { del: flatRuns(delText), add: flatRuns(addText) };
|
|
127
|
+
}
|
|
128
|
+
const pairs = lcsPairs(dt, at, (x, y) => x === y);
|
|
129
|
+
const delKeep = new Set(pairs.map(([i]) => i));
|
|
130
|
+
const addKeep = new Set(pairs.map(([, j]) => j));
|
|
131
|
+
const dw = dt.filter((t, i) => !delKeep.has(i) && isWord(t)).length;
|
|
132
|
+
const aw = at.filter((t, j) => !addKeep.has(j) && isWord(t)).length;
|
|
133
|
+
// Share of changed words over ALL tokens (whitespace included — it
|
|
134
|
+
// almost always matches, so a single changed word in a normal line
|
|
135
|
+
// stays well under the threshold while near-total rewrites exceed
|
|
136
|
+
// it and fall back to line-level).
|
|
137
|
+
const denom = Math.max(dt.length, at.length, 1);
|
|
138
|
+
if ((dw + aw) / denom > CHANGE_THRESHOLD) {
|
|
139
|
+
return { del: flatRuns(delText), add: flatRuns(addText) };
|
|
140
|
+
}
|
|
141
|
+
const build = (toks, keep) => {
|
|
142
|
+
const runs = [];
|
|
143
|
+
for (let k = 0; k < toks.length; k++) {
|
|
144
|
+
const changed = !keep.has(k) && isWord(toks[k]);
|
|
145
|
+
const prev = runs[runs.length - 1];
|
|
146
|
+
if (prev && prev.changed === changed)
|
|
147
|
+
prev.text += toks[k];
|
|
148
|
+
else
|
|
149
|
+
runs.push({ text: toks[k], changed });
|
|
150
|
+
}
|
|
151
|
+
return runs;
|
|
152
|
+
};
|
|
153
|
+
return { del: build(dt, delKeep), add: build(at, addKeep) };
|
|
154
|
+
}
|
|
155
|
+
export function computeDiff(oldText, newText) {
|
|
156
|
+
const empty = {
|
|
157
|
+
hunks: [],
|
|
158
|
+
adds: 0,
|
|
159
|
+
dels: 0,
|
|
160
|
+
truncated: false,
|
|
161
|
+
skipped: null,
|
|
162
|
+
isNewFile: oldText === null,
|
|
163
|
+
};
|
|
164
|
+
if (isBinary(newText) || (oldText !== null && isBinary(oldText))) {
|
|
165
|
+
return { ...empty, skipped: "binary file — diff skipped" };
|
|
166
|
+
}
|
|
167
|
+
if (newText.length > MAX_FILE_BYTES || (oldText !== null && oldText.length > MAX_FILE_BYTES)) {
|
|
168
|
+
return { ...empty, skipped: "file over 1MB — diff skipped" };
|
|
169
|
+
}
|
|
170
|
+
const oldLines = oldText === null ? [] : splitLines(oldText);
|
|
171
|
+
const newLines = splitLines(newText);
|
|
172
|
+
if (oldText !== null && oldText === newText)
|
|
173
|
+
return empty;
|
|
174
|
+
const ops = myersOps(oldLines, newLines);
|
|
175
|
+
const raw = ops.map((op) => op.kind === "same"
|
|
176
|
+
? { kind: "same", text: oldLines[op.a] }
|
|
177
|
+
: op.kind === "del"
|
|
178
|
+
? { kind: "del", text: oldLines[op.a] }
|
|
179
|
+
: { kind: "add", text: newLines[op.b] });
|
|
180
|
+
const changeIdx = raw.map((r, i) => (r.kind === "same" ? -1 : i)).filter((i) => i >= 0);
|
|
181
|
+
if (changeIdx.length === 0)
|
|
182
|
+
return empty;
|
|
183
|
+
const hunks = [];
|
|
184
|
+
let adds = 0;
|
|
185
|
+
let dels = 0;
|
|
186
|
+
let truncated = false;
|
|
187
|
+
let hunkStart = Math.max(0, changeIdx[0] - DIFF_CONTEXT);
|
|
188
|
+
let hunkEnd = Math.min(raw.length, changeIdx[0] + DIFF_CONTEXT + 1);
|
|
189
|
+
const flush = (s, e) => {
|
|
190
|
+
const slice = raw.slice(s, e);
|
|
191
|
+
// Old/new line numbers at hunk start.
|
|
192
|
+
let o = 0;
|
|
193
|
+
let nn = 0;
|
|
194
|
+
for (let k = 0; k < s; k++) {
|
|
195
|
+
if (raw[k].kind !== "add")
|
|
196
|
+
o += 1;
|
|
197
|
+
if (raw[k].kind !== "del")
|
|
198
|
+
nn += 1;
|
|
199
|
+
}
|
|
200
|
+
const oldStart = o + 1;
|
|
201
|
+
const newStart = nn + 1;
|
|
202
|
+
let oCount = 0;
|
|
203
|
+
let nCount = 0;
|
|
204
|
+
// Pair consecutive del/add runs for word highlighting.
|
|
205
|
+
const lines = [];
|
|
206
|
+
let k = 0;
|
|
207
|
+
while (k < slice.length) {
|
|
208
|
+
const r = slice[k];
|
|
209
|
+
if (r.kind === "same") {
|
|
210
|
+
lines.push({ kind: "context", text: r.text });
|
|
211
|
+
oCount += 1;
|
|
212
|
+
nCount += 1;
|
|
213
|
+
k += 1;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const delRun = [];
|
|
217
|
+
const addRun = [];
|
|
218
|
+
while (k < slice.length && slice[k].kind === "del") {
|
|
219
|
+
delRun.push(slice[k].text);
|
|
220
|
+
k += 1;
|
|
221
|
+
}
|
|
222
|
+
while (k < slice.length && slice[k].kind === "add") {
|
|
223
|
+
addRun.push(slice[k].text);
|
|
224
|
+
k += 1;
|
|
225
|
+
}
|
|
226
|
+
const paired = Math.min(delRun.length, addRun.length);
|
|
227
|
+
for (let p = 0; p < paired; p++) {
|
|
228
|
+
const { del, add } = wordRuns(delRun[p], addRun[p]);
|
|
229
|
+
lines.push({ kind: "del", text: delRun[p], runs: del });
|
|
230
|
+
lines.push({ kind: "add", text: addRun[p], runs: add });
|
|
231
|
+
}
|
|
232
|
+
for (let p = paired; p < delRun.length; p++) {
|
|
233
|
+
lines.push({ kind: "del", text: delRun[p], runs: flatRuns(delRun[p]) });
|
|
234
|
+
}
|
|
235
|
+
for (let p = paired; p < addRun.length; p++) {
|
|
236
|
+
lines.push({ kind: "add", text: addRun[p], runs: flatRuns(addRun[p]) });
|
|
237
|
+
}
|
|
238
|
+
dels += delRun.length;
|
|
239
|
+
adds += addRun.length;
|
|
240
|
+
oCount += delRun.length;
|
|
241
|
+
nCount += addRun.length;
|
|
242
|
+
}
|
|
243
|
+
hunks.push({ oldStart, oldLines: oCount, newStart, newLines: nCount, lines });
|
|
244
|
+
};
|
|
245
|
+
let changed = 0;
|
|
246
|
+
for (let c = 1; c < changeIdx.length; c++) {
|
|
247
|
+
const prev = changeIdx[c - 1];
|
|
248
|
+
const cur = changeIdx[c];
|
|
249
|
+
if (cur - prev <= DIFF_CONTEXT * 2 + 1) {
|
|
250
|
+
hunkEnd = Math.min(raw.length, cur + DIFF_CONTEXT + 1);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
flush(hunkStart, hunkEnd);
|
|
254
|
+
changed = hunks.reduce((t, h) => t + h.lines.filter((l) => l.kind !== "context").length, 0);
|
|
255
|
+
if (changed >= MAX_CHANGED_LINES) {
|
|
256
|
+
truncated = true;
|
|
257
|
+
return { hunks, adds, dels, truncated, skipped: null, isNewFile: oldText === null };
|
|
258
|
+
}
|
|
259
|
+
hunkStart = Math.max(0, cur - DIFF_CONTEXT);
|
|
260
|
+
hunkEnd = Math.min(raw.length, cur + DIFF_CONTEXT + 1);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
flush(hunkStart, hunkEnd);
|
|
264
|
+
changed = hunks.reduce((t, h) => t + h.lines.filter((l) => l.kind !== "context").length, 0);
|
|
265
|
+
if (changed > MAX_CHANGED_LINES) {
|
|
266
|
+
// Trim trailing hunks past the budget (keep the head — the user
|
|
267
|
+
// reviews top-down; the notice names the remainder).
|
|
268
|
+
let kept = 0;
|
|
269
|
+
const out = [];
|
|
270
|
+
for (const h of hunks) {
|
|
271
|
+
const n = h.lines.filter((l) => l.kind !== "context").length;
|
|
272
|
+
if (kept + n > MAX_CHANGED_LINES)
|
|
273
|
+
break;
|
|
274
|
+
out.push(h);
|
|
275
|
+
kept += n;
|
|
276
|
+
}
|
|
277
|
+
truncated = true;
|
|
278
|
+
return { hunks: out, adds, dels, truncated, skipped: null, isNewFile: oldText === null };
|
|
279
|
+
}
|
|
280
|
+
return { hunks, adds, dels, truncated, skipped: null, isNewFile: oldText === null };
|
|
281
|
+
}
|
|
282
|
+
export function computeSideBySide(oldText, newText) {
|
|
283
|
+
if (isBinary(newText) || (oldText !== null && isBinary(oldText))) {
|
|
284
|
+
return { kind: "binary" };
|
|
285
|
+
}
|
|
286
|
+
if (newText.length > MAX_FILE_BYTES || (oldText !== null && oldText.length > MAX_FILE_BYTES)) {
|
|
287
|
+
return { kind: "skipped", reason: "file over 1MB — diff skipped" };
|
|
288
|
+
}
|
|
289
|
+
if (oldText !== null && oldText === newText)
|
|
290
|
+
return { kind: "same" };
|
|
291
|
+
const oldLines = oldText === null ? [] : splitLines(oldText);
|
|
292
|
+
const newLines = splitLines(newText);
|
|
293
|
+
const ops = myersOps(oldLines, newLines);
|
|
294
|
+
// Numbered raw lines (1-based per side).
|
|
295
|
+
const raw = [];
|
|
296
|
+
let o = 0;
|
|
297
|
+
let nn = 0;
|
|
298
|
+
for (const op of ops) {
|
|
299
|
+
if (op.kind === "same") {
|
|
300
|
+
o += 1;
|
|
301
|
+
nn += 1;
|
|
302
|
+
raw.push({ kind: "same", text: oldLines[op.a], oldNo: o, newNo: nn });
|
|
303
|
+
}
|
|
304
|
+
else if (op.kind === "del") {
|
|
305
|
+
o += 1;
|
|
306
|
+
raw.push({ kind: "del", text: oldLines[op.a], oldNo: o, newNo: null });
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
nn += 1;
|
|
310
|
+
raw.push({ kind: "add", text: newLines[op.b], oldNo: null, newNo: nn });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const changeIdx = raw.map((r, i) => (r.kind === "same" ? -1 : i)).filter((i) => i >= 0);
|
|
314
|
+
if (changeIdx.length === 0)
|
|
315
|
+
return { kind: "same" };
|
|
316
|
+
const rows = [];
|
|
317
|
+
let adds = 0;
|
|
318
|
+
let dels = 0;
|
|
319
|
+
let truncated = false;
|
|
320
|
+
const changedSoFar = () => dels + adds;
|
|
321
|
+
// Returns false when the change budget is exhausted (stop windowing).
|
|
322
|
+
const flushSlice = (s, e) => {
|
|
323
|
+
const slice = raw.slice(s, e);
|
|
324
|
+
let k = 0;
|
|
325
|
+
while (k < slice.length) {
|
|
326
|
+
const r = slice[k];
|
|
327
|
+
if (r.kind === "same") {
|
|
328
|
+
rows.push({ kind: "context", oldNo: r.oldNo, newNo: r.newNo, text: r.text });
|
|
329
|
+
k += 1;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const delRun = [];
|
|
333
|
+
const addRun = [];
|
|
334
|
+
while (k < slice.length && slice[k].kind === "del") {
|
|
335
|
+
delRun.push({ text: slice[k].text, no: slice[k].oldNo });
|
|
336
|
+
k += 1;
|
|
337
|
+
}
|
|
338
|
+
while (k < slice.length && slice[k].kind === "add") {
|
|
339
|
+
addRun.push({ text: slice[k].text, no: slice[k].newNo });
|
|
340
|
+
k += 1;
|
|
341
|
+
}
|
|
342
|
+
const paired = Math.min(delRun.length, addRun.length);
|
|
343
|
+
for (let p = 0; p < paired; p++) {
|
|
344
|
+
const { del, add } = wordRuns(delRun[p].text, addRun[p].text);
|
|
345
|
+
rows.push({
|
|
346
|
+
kind: "change",
|
|
347
|
+
oldNo: delRun[p].no,
|
|
348
|
+
oldText: delRun[p].text,
|
|
349
|
+
oldRuns: del,
|
|
350
|
+
newNo: addRun[p].no,
|
|
351
|
+
newText: addRun[p].text,
|
|
352
|
+
newRuns: add,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
for (let p = paired; p < delRun.length; p++) {
|
|
356
|
+
rows.push({
|
|
357
|
+
kind: "change",
|
|
358
|
+
oldNo: delRun[p].no,
|
|
359
|
+
oldText: delRun[p].text,
|
|
360
|
+
oldRuns: flatRuns(delRun[p].text),
|
|
361
|
+
newNo: null,
|
|
362
|
+
newText: null,
|
|
363
|
+
newRuns: [],
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
for (let p = paired; p < addRun.length; p++) {
|
|
367
|
+
rows.push({
|
|
368
|
+
kind: "change",
|
|
369
|
+
oldNo: null,
|
|
370
|
+
oldText: null,
|
|
371
|
+
oldRuns: [],
|
|
372
|
+
newNo: addRun[p].no,
|
|
373
|
+
newText: addRun[p].text,
|
|
374
|
+
newRuns: flatRuns(addRun[p].text),
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
dels += delRun.length;
|
|
378
|
+
adds += addRun.length;
|
|
379
|
+
if (changedSoFar() >= MAX_CHANGED_LINES) {
|
|
380
|
+
truncated = true;
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return true;
|
|
385
|
+
};
|
|
386
|
+
let hunkStart = Math.max(0, changeIdx[0] - DIFF_CONTEXT);
|
|
387
|
+
let hunkEnd = Math.min(raw.length, changeIdx[0] + DIFF_CONTEXT + 1);
|
|
388
|
+
for (let c = 1; c < changeIdx.length; c++) {
|
|
389
|
+
const prev = changeIdx[c - 1];
|
|
390
|
+
const cur = changeIdx[c];
|
|
391
|
+
if (cur - prev <= DIFF_CONTEXT * 2 + 1) {
|
|
392
|
+
hunkEnd = Math.min(raw.length, cur + DIFF_CONTEXT + 1);
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
if (!flushSlice(hunkStart, hunkEnd)) {
|
|
396
|
+
return { kind: "diff", rows, adds, dels, truncated, isNewFile: oldText === null };
|
|
397
|
+
}
|
|
398
|
+
hunkStart = Math.max(0, cur - DIFF_CONTEXT);
|
|
399
|
+
hunkEnd = Math.min(raw.length, cur + DIFF_CONTEXT + 1);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
flushSlice(hunkStart, hunkEnd);
|
|
403
|
+
if (dels + adds > MAX_CHANGED_LINES) {
|
|
404
|
+
// Single-hunk overflow: the slice already pushed past the budget —
|
|
405
|
+
// trim trailing rows past 400 changes (keep the head; the notice
|
|
406
|
+
// names the remainder). Mirrors computeDiff's trailing-hunk trim.
|
|
407
|
+
let kept = 0;
|
|
408
|
+
let cut = rows.length;
|
|
409
|
+
for (let i = 0; i < rows.length; i++) {
|
|
410
|
+
if (rows[i].kind === "change") {
|
|
411
|
+
kept += 1;
|
|
412
|
+
if (kept > MAX_CHANGED_LINES) {
|
|
413
|
+
cut = i;
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
rows.length = cut;
|
|
419
|
+
truncated = true;
|
|
420
|
+
}
|
|
421
|
+
return { kind: "diff", rows, adds, dels, truncated, isNewFile: oldText === null };
|
|
422
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const C_KEYWORDS = new Set("break case catch class const continue debugger default delete do else enum export extends finally for function if implements import interface let new return static super switch this throw try typeof var void while with yield async await".split(" "));
|
|
2
|
+
const PY_KEYWORDS = new Set("def class return if elif else for while in is not and or import from as try except finally with lambda pass break continue raise True False None async await".split(" "));
|
|
3
|
+
const SH_KEYWORDS = new Set("if then else elif fi for while do done case esac function return exit echo local export readonly".split(" "));
|
|
4
|
+
function keywordsFor(lang) {
|
|
5
|
+
if (lang === "c")
|
|
6
|
+
return C_KEYWORDS;
|
|
7
|
+
if (lang === "py")
|
|
8
|
+
return PY_KEYWORDS;
|
|
9
|
+
if (lang === "sh")
|
|
10
|
+
return SH_KEYWORDS;
|
|
11
|
+
return null; // "data": no keywords
|
|
12
|
+
}
|
|
13
|
+
function commentStyle(lang) {
|
|
14
|
+
if (lang === "c")
|
|
15
|
+
return "slash";
|
|
16
|
+
if (lang === "data")
|
|
17
|
+
return "hash";
|
|
18
|
+
return "hash"; // py + sh
|
|
19
|
+
}
|
|
20
|
+
// Master token pattern over the code part of a line: strings (with
|
|
21
|
+
// escapes, incl. unterminated tails so streaming/odd lines still paint),
|
|
22
|
+
// numbers, words, and single fallback chars.
|
|
23
|
+
const TOKEN_RE = /'(?:[^'\\\n]|\\.)*(?:'|$)|"(?:[^"\\\n]|\\.)*(?:"|$)|`(?:[^`\\]|\\.)*(?:`|$)|[0-9][0-9_]*(?:\.[0-9_]+)?\b|[A-Za-z_$][A-Za-z0-9_$]*|\s+|./g;
|
|
24
|
+
const NUMBER_RE = /^[0-9][0-9_]*(?:\.[0-9_]+)?\b$/;
|
|
25
|
+
const WORD_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
26
|
+
// Split off a trailing line comment, honoring string spans: `//` inside
|
|
27
|
+
// a string is code, and (for hash style) `#` inside a string is code.
|
|
28
|
+
// Single-line `/* … */` pairs are treated as comments when both halves
|
|
29
|
+
// sit on this line; an unterminated opener is left as code (multi-line
|
|
30
|
+
// state is the documented non-goal).
|
|
31
|
+
function splitComment(line, lang) {
|
|
32
|
+
const style = commentStyle(lang);
|
|
33
|
+
let inStr = null;
|
|
34
|
+
let escaped = false;
|
|
35
|
+
for (let i = 0; i < line.length; i++) {
|
|
36
|
+
const c = line[i];
|
|
37
|
+
if (inStr !== null) {
|
|
38
|
+
if (escaped)
|
|
39
|
+
escaped = false;
|
|
40
|
+
else if (c === "\\")
|
|
41
|
+
escaped = true;
|
|
42
|
+
else if (c === inStr)
|
|
43
|
+
inStr = null;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
47
|
+
inStr = c;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (style === "slash" && c === "/" && line[i + 1] === "/") {
|
|
51
|
+
return { code: line.slice(0, i), comment: line.slice(i) };
|
|
52
|
+
}
|
|
53
|
+
if (style === "hash" && c === "#") {
|
|
54
|
+
// Shebang or comment to end of line.
|
|
55
|
+
return { code: line.slice(0, i), comment: line.slice(i) };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (style === "slash") {
|
|
59
|
+
const open = line.indexOf("/*");
|
|
60
|
+
const close = open >= 0 ? line.indexOf("*/", open + 2) : -1;
|
|
61
|
+
if (open >= 0 && close > open) {
|
|
62
|
+
// Keep it simple: trailing block comment paints as comment; an
|
|
63
|
+
// embedded one splits code around it via the token pass below
|
|
64
|
+
// (rare — paint the whole tail as comment only when the opener
|
|
65
|
+
// starts after code we already keep plain).
|
|
66
|
+
return { code: line.slice(0, open), comment: line.slice(open) };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { code: line, comment: "" };
|
|
70
|
+
}
|
|
71
|
+
function highlightCode(code, lang, base) {
|
|
72
|
+
const runs = [];
|
|
73
|
+
const keywords = keywordsFor(lang);
|
|
74
|
+
TOKEN_RE.lastIndex = 0;
|
|
75
|
+
let m;
|
|
76
|
+
while ((m = TOKEN_RE.exec(code)) !== null) {
|
|
77
|
+
const text = m[0];
|
|
78
|
+
const start = base + (m.index ?? 0);
|
|
79
|
+
let kind = "plain";
|
|
80
|
+
const first = text[0];
|
|
81
|
+
if (first === "'" || first === '"' || first === "`")
|
|
82
|
+
kind = "string";
|
|
83
|
+
else if (NUMBER_RE.test(text))
|
|
84
|
+
kind = "number";
|
|
85
|
+
else if (keywords !== null && WORD_RE.test(text) && keywords.has(text))
|
|
86
|
+
kind = "keyword";
|
|
87
|
+
runs.push({ text, kind, start, end: start + text.length });
|
|
88
|
+
}
|
|
89
|
+
return runs;
|
|
90
|
+
}
|
|
91
|
+
// Bounded highlight cache: diff hunks re-render on busy ticks and the
|
|
92
|
+
// same lines repeat across hunks/turns — tokenize once per unique line.
|
|
93
|
+
const HIGHLIGHT_CACHE_CAP = 2000;
|
|
94
|
+
const highlightCache = new Map();
|
|
95
|
+
export function highlightLine(line, lang) {
|
|
96
|
+
if (lang !== "c" && lang !== "py" && lang !== "sh" && lang !== "data") {
|
|
97
|
+
return line === "" ? [] : [{ text: line, kind: "plain", start: 0, end: line.length }];
|
|
98
|
+
}
|
|
99
|
+
const key = `${lang} ${line}`;
|
|
100
|
+
const hit = highlightCache.get(key);
|
|
101
|
+
if (hit)
|
|
102
|
+
return hit;
|
|
103
|
+
const { code, comment } = splitComment(line, lang);
|
|
104
|
+
const runs = highlightCode(code, lang, 0);
|
|
105
|
+
if (comment) {
|
|
106
|
+
runs.push({ text: comment, kind: "comment", start: code.length, end: line.length });
|
|
107
|
+
}
|
|
108
|
+
const out = runs.length > 0 ? runs : [];
|
|
109
|
+
highlightCache.set(key, out);
|
|
110
|
+
if (highlightCache.size > HIGHLIGHT_CACHE_CAP) {
|
|
111
|
+
const oldest = highlightCache.keys().next();
|
|
112
|
+
if (!oldest.done)
|
|
113
|
+
highlightCache.delete(oldest.value);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
// Test seam: current cache size (eviction behavior).
|
|
118
|
+
export function highlightCacheSize() {
|
|
119
|
+
return highlightCache.size;
|
|
120
|
+
}
|
package/dist/ui/live-tail.js
CHANGED
|
@@ -3,7 +3,7 @@ import { Box, Text } from "ink";
|
|
|
3
3
|
import { activityText } from "./activity.js";
|
|
4
4
|
import { MarkdownStream } from "./markdown.js";
|
|
5
5
|
import { theme } from "./theme.js";
|
|
6
|
-
export function LiveTail({ isEmpty, sessionHint, draft, thinking, busy, held, toolHint, toolElapsedSecs, elapsedSecs }) {
|
|
6
|
+
export function LiveTail({ isEmpty, sessionHint, draft, thinking, busy, held, toolHint, toolElapsedSecs, elapsedSecs, showThinking = true }) {
|
|
7
7
|
// Held view (user scrolled up mid-turn): the growing draft/thinking blocks
|
|
8
8
|
// are replaced by one static line so the frame stops gaining terminal
|
|
9
9
|
// lines — the terminal stops yanking and scrollback stays readable. The
|
|
@@ -11,5 +11,5 @@ export function LiveTail({ isEmpty, sessionHint, draft, thinking, busy, held, to
|
|
|
11
11
|
// status (tool hint, thinking tick) keeps updating in place: same line,
|
|
12
12
|
// no growth, no yank.
|
|
13
13
|
const freezeLive = held === true && busy;
|
|
14
|
-
return (_jsxs(Box, { flexDirection: "column", marginY: theme.spacing.liveTailMarginY, children: [isEmpty ? (_jsx(Text, { dimColor: true, children: "Say hi to Atom \u2014 or type / for commands, /provider to pick a provider + key, /model to switch models." })) : null, sessionHint && isEmpty ? (_jsx(Text, { dimColor: true, children: "(last session available \u2014 /resume to restore)" })) : null, freezeLive ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " held \u2014 turn running \u00B7 End to follow"] })) : null, !freezeLive && draft ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: _jsxs(Text, { color: theme.color.assistant, bold: true, children: [theme.symbol.speakerAssistant, " "] }) }), _jsx(MarkdownStream, { text: draft })] })) : null, !freezeLive && thinking ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.thinking, " ", thinking, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBar })] })) : null, busy && toolHint ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.workTool, " ", activityText(toolHint), toolElapsedSecs !== null && toolElapsedSecs >= 2 ? (_jsxs(_Fragment, { children: [" ", theme.symbol.separator, " ", toolElapsedSecs, "s"] })) : (theme.symbol.ellipsis)] })) : null, !freezeLive && busy && !draft && !thinking && !toolHint ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.workThinking, " Thinking", theme.symbol.ellipsis, " ", theme.symbol.separator, " ", elapsedSecs, "s"] })) : null] }));
|
|
14
|
+
return (_jsxs(Box, { flexDirection: "column", marginY: theme.spacing.liveTailMarginY, children: [isEmpty ? (_jsx(Text, { dimColor: true, children: "Say hi to Atom \u2014 or type / for commands, /provider to pick a provider + key, /model to switch models." })) : null, sessionHint && isEmpty ? (_jsx(Text, { dimColor: true, children: "(last session available \u2014 /resume to restore)" })) : null, freezeLive ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " held \u2014 turn running \u00B7 End to follow"] })) : null, !freezeLive && draft ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: _jsxs(Text, { color: theme.color.assistant, bold: true, children: [theme.symbol.speakerAssistant, " "] }) }), _jsx(MarkdownStream, { text: draft })] })) : null, !freezeLive && thinking && showThinking ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.thinking, " ", thinking, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBar })] })) : null, busy && toolHint ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.workTool, " ", activityText(toolHint), toolElapsedSecs !== null && toolElapsedSecs >= 2 ? (_jsxs(_Fragment, { children: [" ", theme.symbol.separator, " ", toolElapsedSecs, "s"] })) : (theme.symbol.ellipsis)] })) : null, !freezeLive && busy && !draft && !thinking && !toolHint ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.workThinking, " Thinking", theme.symbol.ellipsis, " ", theme.symbol.separator, " ", elapsedSecs, "s"] })) : null] }));
|
|
15
15
|
}
|
package/dist/ui/modals.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Modal leaves: the tool-approval and ask_question dialogs. Prop-driven.
|
|
3
|
+
// All paint comes from ui/theme tokens. The approval tool-call description
|
|
4
|
+
// arrives pre-formatted (describeToolCall stays in App) so this module
|
|
5
|
+
// couples to no tool internals — the approval-redesign chunk owns it.
|
|
6
|
+
import React from "react";
|
|
2
7
|
import { Box, Text } from "ink";
|
|
8
|
+
import { SideBySideDiffView } from "./side-by-side.js";
|
|
3
9
|
import { theme } from "./theme.js";
|
|
10
|
+
// Max diff body lines inside the approval modal (hunk headers excluded;
|
|
11
|
+
// the trailer names the remainder). Keeps the modal scannable while the
|
|
12
|
+
// 1s busy tick repaints around it.
|
|
13
|
+
export const APPROVAL_DIFF_MAX_LINES = 40;
|
|
4
14
|
export const APPROVAL_OPTIONS = ["once", "always", "trustAll", "no"];
|
|
5
15
|
// Command/file preview: the audit description minus its `⚙ name` prefix
|
|
6
16
|
// (the tool name already headlines above). Falls back to the full text
|
|
@@ -14,7 +24,13 @@ export function approvalPreview(toolName, description) {
|
|
|
14
24
|
export function approvalTitle(toolName) {
|
|
15
25
|
return toolName.length > 0 ? toolName[0].toUpperCase() + toolName.slice(1) : toolName;
|
|
16
26
|
}
|
|
17
|
-
|
|
27
|
+
// Render-count probes for the flicker tests: the 1s busy tick and unrelated
|
|
28
|
+
// parent churn must skip both modals (only changed props repaint — nav
|
|
29
|
+
// selection still paints exactly once per keypress).
|
|
30
|
+
export const approvalRenderProbe = { count: 0 };
|
|
31
|
+
export const questionRenderProbe = { count: 0 };
|
|
32
|
+
export const ApprovalBox = React.memo(function ApprovalBox({ toolName, description, selected, diff }) {
|
|
33
|
+
approvalRenderProbe.count += 1;
|
|
18
34
|
const rows = [
|
|
19
35
|
// Labels keep the historical [y]/[a]/[t]/[n] shortcuts (pinned by tests
|
|
20
36
|
// + muscle memory): arrows are additive, shortcuts never move.
|
|
@@ -23,8 +39,9 @@ export function ApprovalBox({ toolName, description, selected }) {
|
|
|
23
39
|
{ label: "[t]rust all write/edit/bash this session", option: "trustAll" },
|
|
24
40
|
{ label: "[n]o — deny this call", option: "no" },
|
|
25
41
|
];
|
|
26
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.approval, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { bold: true, color: theme.color.warning, children: [theme.symbol.warningMark, " Atom permission \u2014 allow this tool?"] }), _jsx(Text, { bold: true, children: approvalTitle(toolName) }), _jsx(Text, { color: theme.color.code, children: approvalPreview(toolName, description) }), rows.map((r, i) => (_jsxs(Text, { color: i === selected ? theme.color.selection : undefined, children: [i === selected ? `${theme.symbol.select} ` : theme.spacing.rowIndent, r.label] }, r.option))), _jsx(Text, { dimColor: true, children: "\u2191/\u2193 + Enter selects \u00B7 y/a/t/n shortcuts \u00B7 Esc denies" })] }));
|
|
27
|
-
}
|
|
28
|
-
export function QuestionBox({ question, options, allowCustom, askCustom, askSelIndex }) {
|
|
42
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.approval, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { bold: true, color: theme.color.warning, children: [theme.symbol.warningMark, " Atom permission \u2014 allow this tool?"] }), _jsx(Text, { bold: true, children: approvalTitle(toolName) }), _jsx(Text, { color: theme.color.code, children: approvalPreview(toolName, description) }), diff ? _jsx(SideBySideDiffView, { oldText: diff.oldText, newText: diff.newText, lang: diff.lang, maxRows: APPROVAL_DIFF_MAX_LINES }) : null, rows.map((r, i) => (_jsxs(Text, { color: i === selected ? theme.color.selection : undefined, children: [i === selected ? `${theme.symbol.select} ` : theme.spacing.rowIndent, r.label] }, r.option))), _jsx(Text, { dimColor: true, children: "\u2191/\u2193 + Enter selects \u00B7 y/a/t/n shortcuts \u00B7 Esc denies" })] }));
|
|
43
|
+
});
|
|
44
|
+
export const QuestionBox = React.memo(function QuestionBox({ question, options, allowCustom, askCustom, askSelIndex }) {
|
|
45
|
+
questionRenderProbe.count += 1;
|
|
29
46
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.question, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { bold: true, children: ["Atom question \u2014 ", question] }), options.map((o, i) => (_jsxs(Text, { color: i === askSelIndex ? theme.color.questionSelection : undefined, children: [i === askSelIndex ? `${theme.symbol.select} ` : theme.spacing.rowIndent, o] }, `${o}-${i}`))), allowCustom ? (_jsxs(Text, { dimColor: true, children: ["Type a custom answer + Enter to send it", askCustom ? `: ${askCustom}` : "", " \u00B7 \u2191/\u2193 + Enter picks \u00B7 Esc cancels"] })) : (_jsx(Text, { dimColor: true, children: "\u2191/\u2193 + Enter to pick \u00B7 Esc cancels" }))] }));
|
|
30
|
-
}
|
|
47
|
+
});
|
package/dist/ui/palette.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Command-palette panel (Ctrl+P): grouped, searchable, keyboard-driven.
|
|
3
|
+
// Owns its display types (categories, hints); App builds the entries from
|
|
4
|
+
// the SLASH_COMMANDS registry (single command system) and owns the run
|
|
5
|
+
// gating. Windowed like every other popup; headers render for groups
|
|
6
|
+
// present in the window (plus the open group when sliced mid-way).
|
|
7
|
+
import React from "react";
|
|
2
8
|
import { Box, Text } from "ink";
|
|
3
9
|
import { PickerMoreAbove, PickerMoreBelow, pickerWindow } from "./pickers.js";
|
|
4
10
|
import { theme } from "./theme.js";
|
|
@@ -26,6 +32,8 @@ const PALETTE_CATEGORIES = {
|
|
|
26
32
|
"/skill": "Skills",
|
|
27
33
|
"/queue": "Flow",
|
|
28
34
|
"/steer": "Flow",
|
|
35
|
+
"/autoscroll": "Flow",
|
|
36
|
+
"/thinking": "Flow",
|
|
29
37
|
"/help": "Help",
|
|
30
38
|
"/exit": "Help",
|
|
31
39
|
"/quit": "Help",
|
|
@@ -39,7 +47,7 @@ export const PALETTE_HINTS = {
|
|
|
39
47
|
"/exit": "Ctrl+C",
|
|
40
48
|
"/quit": "Ctrl+C",
|
|
41
49
|
};
|
|
42
|
-
export function PalettePanel({ entries, index, filter }) {
|
|
50
|
+
export const PalettePanel = React.memo(function PalettePanel({ entries, index, filter }) {
|
|
43
51
|
const hi = entries.length === 0 ? 0 : Math.max(0, Math.min(index, entries.length - 1));
|
|
44
52
|
const win = pickerWindow(entries.length, hi, PALETTE_WINDOW);
|
|
45
53
|
const slice = entries.slice(win.start, win.end);
|
|
@@ -59,4 +67,4 @@ export function PalettePanel({ entries, index, filter }) {
|
|
|
59
67
|
rows.push(_jsxs(Text, { color: i === hi ? theme.color.menuSelection : undefined, children: [i === hi ? `${theme.symbol.select} ` : theme.spacing.rowIndent, e.name, e.description ? ` ${theme.symbol.descSeparator} ${e.description}` : "", e.hint ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", e.hint] }) : null] }, `${e.name}-${i}`));
|
|
60
68
|
});
|
|
61
69
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.menu, paddingX: theme.spacing.pickerPadX, children: [_jsx(Text, { bold: true, children: "Search commands \u2014 type to filter (\u2191/\u2193 + Enter to run, Esc closes):" }), _jsxs(Text, { children: [_jsxs(Text, { color: theme.color.inputPrompt, bold: true, children: [theme.symbol.inputPrompt, " "] }), filter, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), _jsx(PickerMoreAbove, { count: win.start }), rows, _jsx(PickerMoreBelow, { count: entries.length - win.end }), entries.length === 0 ? _jsx(Text, { dimColor: true, children: "No commands match \u2014 backspace to widen." }) : null] }));
|
|
62
|
-
}
|
|
70
|
+
});
|