atom-agent 1.3.0 → 1.5.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +220 -224
  3. package/dist/App.js +922 -341
  4. package/dist/adapters.js +127 -14
  5. package/dist/agent/goal-evaluator.js +3 -0
  6. package/dist/agent/loop.js +211 -430
  7. package/dist/agent/tool-pipeline.js +398 -0
  8. package/dist/agent/turn-events.js +12 -0
  9. package/dist/cli.js +57 -8
  10. package/dist/compact.js +72 -8
  11. package/dist/config.js +19 -0
  12. package/dist/context-manager.js +6 -2
  13. package/dist/extensions.js +6 -0
  14. package/dist/file-diffs.js +108 -0
  15. package/dist/kilo.js +1 -1
  16. package/dist/local-discovery.js +2 -2
  17. package/dist/media.js +276 -0
  18. package/dist/overflow.js +140 -0
  19. package/dist/policy.js +8 -0
  20. package/dist/scheduler.js +38 -9
  21. package/dist/session-revert.js +125 -0
  22. package/dist/sessions.js +101 -0
  23. package/dist/snapshots.js +69 -0
  24. package/dist/system.js +2 -89
  25. package/dist/telemetry.js +26 -1
  26. package/dist/todos.js +241 -0
  27. package/dist/tools/filesystem.js +102 -22
  28. package/dist/tools/registry.js +184 -45
  29. package/dist/tools/ripgrep.js +7 -6
  30. package/dist/tools/search.js +172 -17
  31. package/dist/tools/shared.js +6 -0
  32. package/dist/tools.js +7 -39
  33. package/dist/ui/diff-panel.js +5 -5
  34. package/dist/ui/diff-view.js +16 -7
  35. package/dist/ui/diff.js +73 -51
  36. package/dist/ui/errors.js +20 -6
  37. package/dist/ui/input.js +24 -20
  38. package/dist/ui/live-tail.js +36 -1
  39. package/dist/ui/markdown.js +9 -4
  40. package/dist/ui/modals.js +6 -4
  41. package/dist/ui/paint-scheduler.js +120 -0
  42. package/dist/ui/palette.js +4 -2
  43. package/dist/ui/pickers.js +4 -1
  44. package/dist/ui/side-by-side.js +88 -27
  45. package/dist/ui/status-bar.js +63 -8
  46. package/dist/ui/stream-store.js +7 -0
  47. package/dist/ui/theme.js +23 -1
  48. package/dist/ui/todo-panel.js +5 -2
  49. package/dist/ui/tool-inspector.js +33 -4
  50. package/dist/ui/transcript.js +9 -6
  51. package/dist/web/events.js +93 -0
  52. package/dist/web/runtime.js +790 -0
  53. package/dist/web/server.js +570 -0
  54. package/dist/web/ui/app.js +1925 -0
  55. package/dist/web/ui/index.html +135 -0
  56. package/dist/web/ui/styles.css +515 -0
  57. package/dist/zen.js +115 -4
  58. package/documentation/cli.md +5 -5
  59. package/documentation/configuration.md +11 -6
  60. package/documentation/development.md +4 -3
  61. package/documentation/extensions.md +1 -1
  62. package/documentation/goals.md +1 -1
  63. package/documentation/index.md +4 -4
  64. package/documentation/providers.md +2 -3
  65. package/documentation/skills.md +3 -3
  66. package/documentation/tools.md +8 -3
  67. package/documentation/troubleshooting.md +1 -1
  68. package/examples/extensions/01-audit-gate.js +2 -2
  69. package/examples/extensions/02-notes-tool.js +2 -2
  70. package/examples/extensions/03-custom-command.js +2 -2
  71. package/package.json +3 -2
package/dist/tools.js CHANGED
@@ -25,42 +25,10 @@ export * from "./tools/shared.js";
25
25
  export * from "./tools/shell.js";
26
26
  export * from "./tools/todo.js";
27
27
  export * from "./tools/web.js";
28
- export const UPDATE_GOAL_TOOL_DEFINITION = {
29
- type: "function",
30
- function: {
31
- name: "update_goal",
32
- description: "Report this goal turn's outcome (goal-scoped: only available during an active goal turn). " +
33
- "WHEN to use: at the end of each goal turn status \"continue\" with the next action, " +
34
- "or \"complete\"/\"blocked\" with a reason. " +
35
- "A \"complete\" lands only on genuinely finished work: verified checks and resolved todos. " +
36
- "Checks you could not run go in \"unverified\" (recorded openly in the closing summary, never a gate). " +
37
- "WHEN NOT to use: never outside a goal turn (it records nothing there); " +
38
- "a turn with no report continues the goal.",
39
- parameters: {
40
- type: "object",
41
- properties: {
42
- status: {
43
- type: "string",
44
- enum: ["continue", "complete", "blocked"],
45
- description: "Turn outcome: \"continue\" (keep working), \"complete\" (goal done), \"blocked\" (cannot proceed).",
46
- },
47
- next: {
48
- type: "string",
49
- description: "Next action (only with status \"continue\"; omit otherwise).",
50
- },
51
- reason: {
52
- type: "string",
53
- description: "Why the goal is done or stuck (required with \"complete\"/\"blocked\"; omit otherwise).",
54
- },
55
- unverified: {
56
- type: "array",
57
- items: { type: "string" },
58
- description: "Checks that could not be run (only with status \"complete\"; omit otherwise). " +
59
- "Recorded openly in the closing summary; at most 10 non-empty items of 200 characters each.",
60
- },
61
- },
62
- required: ["status"],
63
- additionalProperties: false,
64
- },
65
- },
66
- };
28
+ // UPDATE_GOAL_TOOL_DEFINITION lives in src/tools/registry.ts beside its
29
+ // validator and executor (ticket 06: intercepted first-class registry
30
+ // entries) and re-exports through the `export *` barrel above, so every
31
+ // existing `from "./tools.js"` importer keeps working untouched. The loop
32
+ // dispatches it through the registry runner (never by name), and chat
33
+ // payloads carry it via allToolDefinitionsone list for model visibility
34
+ // and executability.
@@ -4,10 +4,10 @@ import { computeDiff } from "./diff.js";
4
4
  import { SideBySideDiffView } from "./side-by-side.js";
5
5
  import { theme } from "./theme.js";
6
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;
7
+ // Retained for compatibility (no longer applied the panel renders the
8
+ // full preview; smoothness comes from the per-mount memo + word fallbacks,
9
+ // not from a row cap).
10
+ export const DIFF_PANEL_MAX_LINES = Infinity;
11
11
  // Group committed session previews by file: latest preview per path
12
12
  // wins (a later edit supersedes the earlier view of the same file),
13
13
  // first-seen path order kept. Previews without a path are ungroupable
@@ -51,5 +51,5 @@ export function DiffPanel({ files, index, expanded }) {
51
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
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
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"] })] }));
54
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(SideBySideDiffView, { oldText: rec.oldText, newText: rec.newText, lang: rec.lang, path: rec.path }), _jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, "/", theme.symbol.moreBelow, " prev/next file \u00B7 Enter collapses \u00B7 Esc closes"] })] }));
55
55
  }
@@ -6,13 +6,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  // changed-word backgrounds + syntax foregrounds (ui/highlight, zero-dep),
7
7
  // one <Text> per run (no per-character nodes), word diff + highlighting
8
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
9
+ // never truncated (Ink wraps; copy/paste stays intact); the full hunk list
10
+ // renders by default (an explicit maxLines windows it when a caller passes
11
+ // one). All paint comes from
11
12
  // ui/theme tokens (Ink supports color + backgroundColor on Text — verified
12
13
  // against the installed Ink 7 typings).
13
14
  import React from "react";
14
15
  import { Box, Text } from "ink";
15
- import { computeDiff } from "./diff.js";
16
+ import { computeDiff, hunksRange, rangeLabel } from "./diff.js";
16
17
  import { highlightLine } from "./highlight.js";
17
18
  import { theme } from "./theme.js";
18
19
  function syntaxColor(kind) {
@@ -36,7 +37,7 @@ function syntaxColor(kind) {
36
37
  // both the walk and the tokenize lookup.
37
38
  export const LineBody = React.memo(function LineBody({ lineText, runs, base, lang, }) {
38
39
  const baseColor = base === "add" ? theme.color.success : theme.color.toolError;
39
- const hlBg = base === "add" ? "green" : "red";
40
+ const hlBg = base === "add" ? theme.color.diffAddBg : theme.color.diffDelBg;
40
41
  const langKnown = lang === "c" || lang === "py" || lang === "sh" || lang === "data";
41
42
  const syn = langKnown ? highlightLine(lineText, lang) : [];
42
43
  const nodes = [];
@@ -49,7 +50,7 @@ export const LineBody = React.memo(function LineBody({ lineText, runs, base, lan
49
50
  if (r.changed) {
50
51
  // Changed words keep the high-contrast background treatment —
51
52
  // syntax hues would muddy the signal.
52
- nodes.push(_jsx(Text, { backgroundColor: hlBg, color: "black", bold: true, children: r.text }, k));
53
+ nodes.push(_jsx(Text, { backgroundColor: hlBg, color: theme.color.diffChangedFg, bold: true, children: r.text }, k));
53
54
  // Advance the syntax cursor past this run so later runs align.
54
55
  while (synIdx < syn.length && syn[synIdx].end <= runEnd)
55
56
  synIdx += 1;
@@ -86,7 +87,15 @@ export const LineBody = React.memo(function LineBody({ lineText, runs, base, lan
86
87
  });
87
88
  return _jsx(Text, { color: langKnown ? undefined : baseColor, children: nodes });
88
89
  });
89
- function DiffViewInner({ oldText, newText, lang = null, maxLines = Infinity }) {
90
+ // Ticket 02 signature header: ONE quiet dim line change counts, path,
91
+ // line range — shared verbatim by the unified view, the side-by-side view
92
+ // (ui/side-by-side), the approval modal, the committed transcript, and the
93
+ // /diff review. Changed rows carry +/- color + word backgrounds; context
94
+ // rows stay dim so unchanged text reads subordinate.
95
+ export function DiffSummary({ adds, dels, isNewFile, path, range, }) {
96
+ return (_jsxs(Text, { dimColor: true, children: [isNewFile ? "new file " : "", "+", adds, " \u2212", dels, path ? ` ${theme.symbol.separator} ${path}` : "", range ? ` ${theme.symbol.separator} ${range}` : ""] }));
97
+ }
98
+ function DiffViewInner({ oldText, newText, lang = null, path = null, maxLines = Infinity }) {
90
99
  const diff = React.useMemo(() => computeDiff(oldText, newText), [oldText, newText]);
91
100
  if (diff.skipped) {
92
101
  return _jsx(Text, { dimColor: true, children: diff.skipped });
@@ -101,7 +110,7 @@ function DiffViewInner({ oldText, newText, lang = null, maxLines = Infinity }) {
101
110
  total += h.lines.length;
102
111
  return Math.max(0, total - maxLines);
103
112
  })();
104
- 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) => {
113
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(DiffSummary, { adds: diff.adds, dels: diff.dels, isNewFile: diff.isNewFile, path: path, range: diff.isNewFile ? null : rangeLabel(hunksRange(diff.hunks)) }), 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) => {
105
114
  bodyLines += 1;
106
115
  if (bodyLines > maxLines)
107
116
  return null;
package/dist/ui/diff.js CHANGED
@@ -4,6 +4,10 @@
4
4
  export const CHANGE_THRESHOLD = 0.4;
5
5
  // Mirrors Claude's DiffDetailView guards.
6
6
  export const MAX_FILE_BYTES = 1_000_000;
7
+ // Retained for compatibility (no longer applied — the engine returns the
8
+ // full hunk list; views render it whole). Safety caps that remain enforced:
9
+ // MAX_FILE_BYTES, binary detect, MAX_MYERS_LINES fallback, MAX_WORD_TOKENS
10
+ // fallback.
7
11
  export const MAX_CHANGED_LINES = 400;
8
12
  export const DIFF_CONTEXT = 3;
9
13
  // Worst-case guards for the O(ND) Myers pass + O(w1*w2) word pass.
@@ -183,7 +187,7 @@ export function computeDiff(oldText, newText) {
183
187
  const hunks = [];
184
188
  let adds = 0;
185
189
  let dels = 0;
186
- let truncated = false;
190
+ const truncated = false;
187
191
  let hunkStart = Math.max(0, changeIdx[0] - DIFF_CONTEXT);
188
192
  let hunkEnd = Math.min(raw.length, changeIdx[0] + DIFF_CONTEXT + 1);
189
193
  const flush = (s, e) => {
@@ -242,7 +246,6 @@ export function computeDiff(oldText, newText) {
242
246
  }
243
247
  hunks.push({ oldStart, oldLines: oCount, newStart, newLines: nCount, lines });
244
248
  };
245
- let changed = 0;
246
249
  for (let c = 1; c < changeIdx.length; c++) {
247
250
  const prev = changeIdx[c - 1];
248
251
  const cur = changeIdx[c];
@@ -251,33 +254,79 @@ export function computeDiff(oldText, newText) {
251
254
  }
252
255
  else {
253
256
  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
257
  hunkStart = Math.max(0, cur - DIFF_CONTEXT);
260
258
  hunkEnd = Math.min(raw.length, cur + DIFF_CONTEXT + 1);
261
259
  }
262
260
  }
263
261
  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;
262
+ return { hunks, adds, dels, truncated, skipped: null, isNewFile: oldText === null };
263
+ }
264
+ // Overall changed-line range for unified hunks (hunk context excluded
265
+ // the header names the span that changed, the @@ lines keep per-hunk detail).
266
+ export function hunksRange(hunks) {
267
+ let oldMin = null;
268
+ let oldMax = null;
269
+ let newMin = null;
270
+ let newMax = null;
271
+ for (const h of hunks) {
272
+ // Walk hunk lines counting only changed lines for the range.
273
+ let ho = h.oldStart;
274
+ let hn = h.newStart;
275
+ for (const ln of h.lines) {
276
+ if (ln.kind === "context") {
277
+ ho += 1;
278
+ hn += 1;
279
+ continue;
280
+ }
281
+ if (ln.kind === "del") {
282
+ oldMin = oldMin === null ? ho : Math.min(oldMin, ho);
283
+ oldMax = oldMax === null ? ho : Math.max(oldMax, ho);
284
+ ho += 1;
285
+ }
286
+ else {
287
+ newMin = newMin === null ? hn : Math.min(newMin, hn);
288
+ newMax = newMax === null ? hn : Math.max(newMax, hn);
289
+ hn += 1;
290
+ }
276
291
  }
277
- truncated = true;
278
- return { hunks: out, adds, dels, truncated, skipped: null, isNewFile: oldText === null };
279
292
  }
280
- return { hunks, adds, dels, truncated, skipped: null, isNewFile: oldText === null };
293
+ return { oldMin, oldMax, newMin, newMax };
294
+ }
295
+ // Overall line range for side-by-side rows (change rows only — context
296
+ // excluded, same rule as hunksRange so both views agree).
297
+ export function sbsRange(rows) {
298
+ let oldMin = null;
299
+ let oldMax = null;
300
+ let newMin = null;
301
+ let newMax = null;
302
+ for (const r of rows) {
303
+ if (r.kind === "context")
304
+ continue;
305
+ if (r.oldNo !== null) {
306
+ oldMin = oldMin === null ? r.oldNo : Math.min(oldMin, r.oldNo);
307
+ oldMax = oldMax === null ? r.oldNo : Math.max(oldMax, r.oldNo);
308
+ }
309
+ if (r.newNo !== null) {
310
+ newMin = newMin === null ? r.newNo : Math.min(newMin, r.newNo);
311
+ newMax = newMax === null ? r.newNo : Math.max(newMax, r.newNo);
312
+ }
313
+ }
314
+ return { oldMin, oldMax, newMin, newMax };
315
+ }
316
+ function span(min, max) {
317
+ if (min === null || max === null)
318
+ return null;
319
+ return min === max ? `L${min}` : `L${min}–${max}`;
320
+ }
321
+ // "L2 → L2", "L2–20 → L2–21", or the one-sided remainder for pure
322
+ // add/del blocks. Null when there is nothing to name (new files carry
323
+ // the `new file` marker instead of a range; callers skip null).
324
+ export function rangeLabel(r) {
325
+ const o = span(r.oldMin, r.oldMax);
326
+ const n = span(r.newMin, r.newMax);
327
+ if (o && n)
328
+ return o === n ? o : `${o} → ${n}`;
329
+ return o ?? n;
281
330
  }
282
331
  export function computeSideBySide(oldText, newText) {
283
332
  if (isBinary(newText) || (oldText !== null && isBinary(oldText))) {
@@ -316,9 +365,7 @@ export function computeSideBySide(oldText, newText) {
316
365
  const rows = [];
317
366
  let adds = 0;
318
367
  let dels = 0;
319
- let truncated = false;
320
- const changedSoFar = () => dels + adds;
321
- // Returns false when the change budget is exhausted (stop windowing).
368
+ const truncated = false;
322
369
  const flushSlice = (s, e) => {
323
370
  const slice = raw.slice(s, e);
324
371
  let k = 0;
@@ -376,12 +423,7 @@ export function computeSideBySide(oldText, newText) {
376
423
  }
377
424
  dels += delRun.length;
378
425
  adds += addRun.length;
379
- if (changedSoFar() >= MAX_CHANGED_LINES) {
380
- truncated = true;
381
- return false;
382
- }
383
426
  }
384
- return true;
385
427
  };
386
428
  let hunkStart = Math.max(0, changeIdx[0] - DIFF_CONTEXT);
387
429
  let hunkEnd = Math.min(raw.length, changeIdx[0] + DIFF_CONTEXT + 1);
@@ -392,31 +434,11 @@ export function computeSideBySide(oldText, newText) {
392
434
  hunkEnd = Math.min(raw.length, cur + DIFF_CONTEXT + 1);
393
435
  }
394
436
  else {
395
- if (!flushSlice(hunkStart, hunkEnd)) {
396
- return { kind: "diff", rows, adds, dels, truncated, isNewFile: oldText === null };
397
- }
437
+ flushSlice(hunkStart, hunkEnd);
398
438
  hunkStart = Math.max(0, cur - DIFF_CONTEXT);
399
439
  hunkEnd = Math.min(raw.length, cur + DIFF_CONTEXT + 1);
400
440
  }
401
441
  }
402
442
  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
443
  return { kind: "diff", rows, adds, dels, truncated, isNewFile: oldText === null };
422
444
  }
package/dist/ui/errors.js CHANGED
@@ -14,6 +14,20 @@ export function parseToolLabel(label) {
14
14
  export function titleCase(name) {
15
15
  return name.length > 0 ? name[0].toUpperCase() + name.slice(1) : name;
16
16
  }
17
+ // One-line guarantee (ticket 04): the card is a summary, never a wall. The
18
+ // loop already commits only the first error line, but the classifier must
19
+ // not trust its input — any multi-line turn committed directly (tests,
20
+ // future paths) collapses to its first line here, and pathological
21
+ // single-line output caps at MAX_DETAIL_CHARS. The full text always waits
22
+ // in the Ctrl+O inspector store (tool failures) or the model reply stream.
23
+ export const MAX_DETAIL_CHARS = 200;
24
+ export function summarizeDetail(raw) {
25
+ const first = raw
26
+ .replace(/^[↳\s]+/, "")
27
+ .split("\n", 1)[0]
28
+ .trim();
29
+ return first.length > MAX_DETAIL_CHARS ? `${first.slice(0, MAX_DETAIL_CHARS)}…` : first;
30
+ }
17
31
  const NETWORK_RE = /HTTP (429|500|502|503|504)|connection reset|fetch failed|network|timed? ?out|ENOTFOUND|ECONN|EAI_AGAIN|socket hang up/i;
18
32
  const MODEL_RE = /Empty reply|Truncated stream|malformed|unexpected payload|no .* usage|invalid response/i;
19
33
  const CONFIG_RE = /Missing API key|no API key|not configured|invalid baseURL|auth/i;
@@ -29,14 +43,14 @@ export function classifyToolError(turn, label) {
29
43
  return {
30
44
  kind: "config",
31
45
  title: "Setup needed",
32
- detail: content.trim(),
46
+ detail: summarizeDetail(content),
33
47
  hint: "Run /provider to paste a key, then resend.",
34
48
  inspectable: false,
35
49
  };
36
50
  }
37
51
  return null;
38
52
  }
39
- const detail = content.replace(/^[↳\s]+/, "");
53
+ const detail = summarizeDetail(content);
40
54
  if (/denied by user/i.test(detail)) {
41
55
  const tool = detail.split(":").pop()?.trim() ?? "";
42
56
  return {
@@ -105,10 +119,10 @@ export function classifyToolError(turn, label) {
105
119
  };
106
120
  }
107
121
  const KIND_GLYPH = {
108
- tool: "✕",
109
- denial: "⊘",
110
- network: "⚠",
111
- model: "✕",
122
+ tool: theme.symbol.toolFail,
123
+ denial: theme.symbol.toolDenied,
124
+ network: theme.symbol.warningMark,
125
+ model: theme.symbol.toolFail,
112
126
  cancelled: "",
113
127
  config: "→",
114
128
  internal: "‼",
package/dist/ui/input.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  // Input leaf: the boxed prompt surface. Prop-driven + memoized on
3
- // (input, cursor) so timer ticks never repaint it. Multiline aware: the
4
- // cursor rides line/col and long lines wrap via Ink. Deliberately bare —
5
- // no placeholder text, no hints; the box + cursor is the affordance.
3
+ // (input, cursor, busy) so timer ticks never repaint it. Multiline aware: the
4
+ // cursor rides line/col and long lines wrap via Ink.
5
+ // Footer-cluster states: idle shows just the box + cursor; busy dims the
6
+ // whole surface and carries its own interrupt hint, so the input never
7
+ // vanishes mid-turn — Enter while busy queues, esc stops.
6
8
  // Paint from ui/theme tokens — no literal colors or glyphs here.
7
9
  import React from "react";
8
10
  import { Box, Text } from "ink";
@@ -14,27 +16,29 @@ import { lineColOf, splitInputLines } from "./input-model.js";
14
16
  export const inputRenderProbe = { count: 0 };
15
17
  // The input is the one boxed, prominent surface: a quiet gray frame sets it
16
18
  // apart from the transcript above and the status line below. Memoized on
17
- // (input, cursor) so elapsed-timer ticks, token paints, and unrelated App
18
- // state churn never repaint it — keystrokes stay at exactly one paint each,
19
- // which is what makes navigation feel instant instead of choppy.
20
- export const InputBox = React.memo(function InputBox({ input, cursor }) {
19
+ // (input, cursor, busy) so elapsed-timer ticks, token paints, and unrelated
20
+ // App state churn never repaint it — keystrokes stay at exactly one paint
21
+ // each, which is what makes navigation feel instant instead of choppy.
22
+ // `busy` flips only at turn boundaries (never per tick/token), so the
23
+ // working state costs exactly one extra paint per turn edge.
24
+ export const InputBox = React.memo(function InputBox({ input, cursor, busy = false }) {
21
25
  inputRenderProbe.count += 1;
22
26
  // Defensive clamp: the ref is the source of truth mid-tick and always
23
27
  // stays in range, but state may lag it by one render.
24
28
  const safeCursor = Math.max(0, Math.min(cursor, input.length));
25
29
  const lines = splitInputLines(input);
26
30
  const { line: cline, col: ccol } = lineColOf(input, safeCursor);
27
- return (_jsxs(Box, { borderStyle: theme.border.style, borderColor: theme.border.input, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { color: theme.color.inputPrompt, bold: true, children: [theme.symbol.inputPrompt, " "] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: lines.map((ln, i) => {
28
- if (i !== cline)
29
- return _jsx(Text, { children: ln.length > 0 ? ln : " " }, i);
30
- // See-through cursor: the character under the cursor renders in
31
- // inverse video instead of inserting a block glyph beside it, so
32
- // letters never shift aside as the cursor moves (the old block
33
- // made the line wobble on every arrow-key step). At end of line
34
- // (or on an empty line) an inverse space holds the cell.
35
- const before = ln.slice(0, ccol);
36
- const at = ln.slice(ccol, ccol + 1);
37
- const after = ln.slice(ccol + 1);
38
- return (_jsxs(Text, { children: [before, _jsx(Text, { inverse: true, children: at.length > 0 ? at : " " }), after] }, i));
39
- }) })] }));
31
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { borderStyle: theme.border.style, borderColor: theme.border.input, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { color: theme.color.inputPrompt, bold: true, dimColor: busy, children: [theme.symbol.inputPrompt, " "] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: lines.map((ln, i) => {
32
+ if (i !== cline)
33
+ return (_jsx(Text, { dimColor: busy, children: ln.length > 0 ? ln : " " }, i));
34
+ // See-through cursor: the character under the cursor renders in
35
+ // inverse video instead of inserting a block glyph beside it, so
36
+ // letters never shift aside as the cursor moves (the old block
37
+ // made the line wobble on every arrow-key step). At end of line
38
+ // (or on an empty line) an inverse space holds the cell.
39
+ const before = ln.slice(0, ccol);
40
+ const at = ln.slice(ccol, ccol + 1);
41
+ const after = ln.slice(ccol + 1);
42
+ return (_jsxs(Text, { dimColor: busy, children: [before, _jsx(Text, { inverse: true, children: at.length > 0 ? at : " " }), after] }, i));
43
+ }) })] }), busy ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.workTool, " working ", theme.symbol.separator, " esc stops ", theme.symbol.separator, " Enter queues"] })) : null] }));
40
44
  });
@@ -9,6 +9,12 @@ import { Box, Text } from "ink";
9
9
  import { activityText } from "./activity.js";
10
10
  import { MarkdownStream } from "./markdown.js";
11
11
  import { theme } from "./theme.js";
12
+ // Live thinking window (render-stability): reasoning streams at token rate,
13
+ // and painting the full accumulated text every 64ms both churns frame
14
+ // height and re-lays-out an ever-growing block. The live view shows only
15
+ // the tail — the full text still commits to the transcript at the round
16
+ // boundary, so nothing is ever lost.
17
+ export const LIVE_THINKING_LINES = 8;
12
18
  export const LiveTail = React.memo(function LiveTail({ isEmpty, sessionHint, emptySessionTitle, draft, thinking, busy, held, toolHint, toolElapsedSecs, elapsedSecs, showThinking = true }) {
13
19
  // Held view (user scrolled up mid-turn): the growing draft/thinking blocks
14
20
  // are replaced by one static line so the frame stops gaining terminal
@@ -17,5 +23,34 @@ export const LiveTail = React.memo(function LiveTail({ isEmpty, sessionHint, emp
17
23
  // status (tool hint, thinking tick) keeps updating in place: same line,
18
24
  // no growth, no yank.
19
25
  const freezeLive = held === true && busy;
20
- 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, isEmpty && emptySessionTitle && emptySessionTitle.trim() ? (_jsxs(Text, { dimColor: true, children: ["Session: ", emptySessionTitle.trim()] })) : 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] }));
26
+ const thoughtLines = thinking !== null ? thinking.split("\n") : [];
27
+ const thoughtTail = thoughtLines.slice(-LIVE_THINKING_LINES);
28
+ const thoughtTruncated = thoughtLines.length > thoughtTail.length;
29
+ // Restraint (ticket 07): an empty live zone mounts nothing. The Box below
30
+ // carries marginY, which Ink paints as blank lines even with no children —
31
+ // without this guard every idle frame with history wasted two vertical
32
+ // lines between the transcript and the input. When the transcript is empty
33
+ // the hint lines always render, so only the non-empty, nothing-live case
34
+ // collapses. Mirror the JSX conditions below (falsy draft/toolHint render
35
+ // nothing there, so they count as nothing here too).
36
+ const showsDraft = !freezeLive && !!draft;
37
+ const showsThinking = !freezeLive && thinking !== null && showThinking;
38
+ const showsToolHint = busy && !!toolHint;
39
+ const showsThinkingGap = !freezeLive && busy && !draft && !thinking && !toolHint;
40
+ if (!isEmpty &&
41
+ !freezeLive &&
42
+ !showsDraft &&
43
+ !showsThinking &&
44
+ !showsToolHint &&
45
+ !showsThinkingGap) {
46
+ return null;
47
+ }
48
+ 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, isEmpty && emptySessionTitle && emptySessionTitle.trim() ? (_jsxs(Text, { dimColor: true, children: ["Session: ", emptySessionTitle.trim()] })) : 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 ? (
49
+ // Grouped thinking unit: dim labeled header + quoteBar-prefixed tail
50
+ // body reads as one quiet block, structurally separate from the
51
+ // answer draft above (magenta ATOM> + markdown). Tail-window behavior
52
+ // unchanged; divider lives inside this row's own box (no extra
53
+ // Static rows). Muted = dimColor per theme law, never gray paint
54
+ // (cursor glyph keeps the reserved mutedPaint shade).
55
+ _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [theme.symbol.thinking, " thinking", thoughtTruncated ? ` ${theme.symbol.ellipsis}` : ""] }), thoughtTail.map((line, idx) => (_jsxs(Text, { dimColor: true, children: [`${theme.symbol.quoteBar} ${line}`, idx === thoughtTail.length - 1 ? (_jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBar })) : null] }, idx)))] })) : 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] }));
21
56
  });
@@ -532,9 +532,14 @@ export function MarkdownText({ text }) {
532
532
  // True folding needs interactive history (Static items freeze on commit);
533
533
  // this component is that chunk's seam.
534
534
  export const TOOL_SLOW_MS = 2000;
535
- export function ToolLine({ content, error, ms }) {
535
+ export function ToolLine({ content, error, ms, via }) {
536
+ // Approval provenance suffix (ticket 04): a dim `· via <token>` marker
537
+ // rendered OUTSIDE the label text, so `⚙ name target` stays byte-identical
538
+ // for the parsers/tests that read it (parseToolLabel/parseActivityHint)
539
+ // while what allowed the call stays visible on the audit line.
540
+ const suffix = via ? ` ${theme.symbol.separator} via ${via}` : "";
536
541
  if (error) {
537
- return _jsx(Text, { color: theme.color.toolError, children: content });
542
+ return _jsxs(Text, { color: theme.color.toolError, children: [content, suffix] });
538
543
  }
539
544
  if (content.startsWith("⚠ ")) {
540
545
  return _jsx(Text, { color: theme.color.warning, children: content });
@@ -543,7 +548,7 @@ export function ToolLine({ content, error, ms }) {
543
548
  !content.includes("\n") &&
544
549
  ms !== undefined &&
545
550
  ms >= TOOL_SLOW_MS) {
546
- return (_jsxs(Text, { color: theme.color.tool, dimColor: true, children: [content, " ", theme.symbol.separator, " ", Math.round(ms / 1000), "s"] }));
551
+ return (_jsxs(Text, { color: theme.color.tool, dimColor: true, children: [content, suffix, " ", theme.symbol.separator, " ", Math.round(ms / 1000), "s"] }));
547
552
  }
548
- return (_jsx(Text, { color: theme.color.tool, dimColor: true, children: content }));
553
+ return (_jsxs(Text, { color: theme.color.tool, dimColor: true, children: [content, suffix] }));
549
554
  }
package/dist/ui/modals.js CHANGED
@@ -7,9 +7,11 @@ import React from "react";
7
7
  import { Box, Text } from "ink";
8
8
  import { SideBySideDiffView } from "./side-by-side.js";
9
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.
10
+ // Display-only window for the approval preview (hunk headers excluded;
11
+ // the trailer names the remainder). The diff engine stays uncapped and the
12
+ // transcript renders the full diff on approve — this window only keeps the
13
+ // modal (and its allow/deny options) on screen instead of pushing the frame
14
+ // into fullscreen full-clear territory on every large write.
13
15
  export const APPROVAL_DIFF_MAX_LINES = 40;
14
16
  export const APPROVAL_OPTIONS = ["once", "always", "trustAll", "no"];
15
17
  // Command/file preview: the audit description minus its `⚙ name` prefix
@@ -39,7 +41,7 @@ export const ApprovalBox = React.memo(function ApprovalBox({ toolName, descripti
39
41
  { label: "[t]rust all write/edit/bash this session", option: "trustAll" },
40
42
  { label: "[n]o — deny this call", option: "no" },
41
43
  ];
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" })] }));
44
+ 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, path: diff.path, maxRows: APPROVAL_DIFF_MAX_LINES }) : null, diff ? _jsx(Text, { dimColor: true, children: "Full diff renders in the transcript on approve." }) : 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
45
  });
44
46
  export const QuestionBox = React.memo(function QuestionBox({ question, options, allowCustom, askCustom, askSelIndex }) {
45
47
  questionRenderProbe.count += 1;