atom-agent 1.4.0 → 1.5.1

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 (68) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +221 -224
  3. package/dist/App.js +922 -341
  4. package/dist/adapters.js +502 -21
  5. package/dist/agent/goal-evaluator.js +3 -0
  6. package/dist/agent/loop.js +250 -434
  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/providers.js +11 -3
  21. package/dist/scheduler.js +38 -9
  22. package/dist/session-revert.js +125 -0
  23. package/dist/sessions.js +101 -0
  24. package/dist/snapshots.js +69 -0
  25. package/dist/system.js +2 -89
  26. package/dist/telemetry.js +79 -5
  27. package/dist/todos.js +241 -0
  28. package/dist/tools/filesystem.js +102 -22
  29. package/dist/tools/registry.js +184 -45
  30. package/dist/tools/ripgrep.js +7 -6
  31. package/dist/tools/search.js +172 -17
  32. package/dist/tools/shared.js +6 -0
  33. package/dist/tools.js +7 -39
  34. package/dist/ui/diff-panel.js +1 -1
  35. package/dist/ui/diff-view.js +13 -5
  36. package/dist/ui/diff.js +67 -0
  37. package/dist/ui/errors.js +20 -6
  38. package/dist/ui/input.js +24 -20
  39. package/dist/ui/live-tail.js +36 -1
  40. package/dist/ui/markdown.js +9 -4
  41. package/dist/ui/modals.js +7 -5
  42. package/dist/ui/paint-scheduler.js +120 -0
  43. package/dist/ui/palette.js +4 -2
  44. package/dist/ui/pickers.js +4 -1
  45. package/dist/ui/side-by-side.js +81 -22
  46. package/dist/ui/status-bar.js +63 -8
  47. package/dist/ui/stream-store.js +7 -0
  48. package/dist/ui/theme.js +23 -1
  49. package/dist/ui/todo-panel.js +5 -2
  50. package/dist/ui/tool-inspector.js +33 -4
  51. package/dist/ui/transcript.js +8 -5
  52. package/dist/web/events.js +93 -0
  53. package/dist/web/runtime.js +790 -0
  54. package/dist/web/server.js +570 -0
  55. package/dist/web/ui/app.js +1925 -0
  56. package/dist/web/ui/index.html +135 -0
  57. package/dist/web/ui/styles.css +515 -0
  58. package/dist/zen.js +532 -34
  59. package/documentation/cli.md +5 -5
  60. package/documentation/configuration.md +11 -6
  61. package/documentation/development.md +4 -3
  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/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.
@@ -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 }), _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
  }
@@ -13,7 +13,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
13
  // against the installed Ink 7 typings).
14
14
  import React from "react";
15
15
  import { Box, Text } from "ink";
16
- import { computeDiff } from "./diff.js";
16
+ import { computeDiff, hunksRange, rangeLabel } from "./diff.js";
17
17
  import { highlightLine } from "./highlight.js";
18
18
  import { theme } from "./theme.js";
19
19
  function syntaxColor(kind) {
@@ -37,7 +37,7 @@ function syntaxColor(kind) {
37
37
  // both the walk and the tokenize lookup.
38
38
  export const LineBody = React.memo(function LineBody({ lineText, runs, base, lang, }) {
39
39
  const baseColor = base === "add" ? theme.color.success : theme.color.toolError;
40
- const hlBg = base === "add" ? "green" : "red";
40
+ const hlBg = base === "add" ? theme.color.diffAddBg : theme.color.diffDelBg;
41
41
  const langKnown = lang === "c" || lang === "py" || lang === "sh" || lang === "data";
42
42
  const syn = langKnown ? highlightLine(lineText, lang) : [];
43
43
  const nodes = [];
@@ -50,7 +50,7 @@ export const LineBody = React.memo(function LineBody({ lineText, runs, base, lan
50
50
  if (r.changed) {
51
51
  // Changed words keep the high-contrast background treatment —
52
52
  // syntax hues would muddy the signal.
53
- 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));
54
54
  // Advance the syntax cursor past this run so later runs align.
55
55
  while (synIdx < syn.length && syn[synIdx].end <= runEnd)
56
56
  synIdx += 1;
@@ -87,7 +87,15 @@ export const LineBody = React.memo(function LineBody({ lineText, runs, base, lan
87
87
  });
88
88
  return _jsx(Text, { color: langKnown ? undefined : baseColor, children: nodes });
89
89
  });
90
- 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 }) {
91
99
  const diff = React.useMemo(() => computeDiff(oldText, newText), [oldText, newText]);
92
100
  if (diff.skipped) {
93
101
  return _jsx(Text, { dimColor: true, children: diff.skipped });
@@ -102,7 +110,7 @@ function DiffViewInner({ oldText, newText, lang = null, maxLines = Infinity }) {
102
110
  total += h.lines.length;
103
111
  return Math.max(0, total - maxLines);
104
112
  })();
105
- 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) => {
106
114
  bodyLines += 1;
107
115
  if (bodyLines > maxLines)
108
116
  return null;
package/dist/ui/diff.js CHANGED
@@ -261,6 +261,73 @@ export function computeDiff(oldText, newText) {
261
261
  flush(hunkStart, hunkEnd);
262
262
  return { hunks, adds, dels, truncated, skipped: null, isNewFile: oldText === null };
263
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
+ }
291
+ }
292
+ }
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;
330
+ }
264
331
  export function computeSideBySide(oldText, newText) {
265
332
  if (isBinary(newText) || (oldText !== null && isBinary(oldText))) {
266
333
  return { kind: "binary" };
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,10 +7,12 @@ 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
- // Retained for compatibility (no longer applied the approval preview
11
- // renders the full diff; smoothness comes from the per-mount memo + word
12
- // fallbacks, not from a row cap).
13
- export const APPROVAL_DIFF_MAX_LINES = Infinity;
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.
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
16
18
  // (the tool name already headlines above). Falls back to the full text
@@ -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 }) : 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;
@@ -0,0 +1,120 @@
1
+ // Centralized streaming paint scheduler: ONE trailing timer for every live
2
+ // lane (answer draft + thinking).
3
+ //
4
+ // EVENT FREQUENCY != RENDER FREQUENCY: tokens may arrive hundreds per
5
+ // second, but terminal paints coalesce to at most one per interval, always
6
+ // carrying the latest pending text per lane, delivered in a SINGLE onFlush
7
+ // call so draft + thinking land in the same React render instead of
8
+ // fighting across two frames.
9
+ //
10
+ // Rules it enforces:
11
+ // - latest-wins per lane (never queue stale paints behind each other)
12
+ // - leading immediate paint after idle (no typing/stream-start latency)
13
+ // - trailing coalescing inside the window (no per-token renders)
14
+ // - deterministic flush() for turn end, tool transitions, errors, and
15
+ // completion (the final state always paints exactly once)
16
+ // - cancel() drops pending text AND the timer, so a trailing paint can
17
+ // never resurrect stale content after a clear/rollback
18
+ // - timer failure degrades to immediate paint (never lose content)
19
+ //
20
+ // The interval is injected (App passes DRAFT_THROTTLE_MS); the default
21
+ // matches it. For unit tests, now/clock are injectable like the throttler's.
22
+ export const PAINT_INTERVAL_MS = 64;
23
+ export function createPaintScheduler(opts) {
24
+ const intervalMs = opts.intervalMs ?? PAINT_INTERVAL_MS;
25
+ const nowFn = opts.now ?? Date.now;
26
+ const setT = opts.setTimeoutFn ?? setTimeout;
27
+ const clearT = opts.clearTimeoutFn ?? clearTimeout;
28
+ const onFlush = opts.onFlush;
29
+ const pending = new Map();
30
+ let lastFlush = Number.NEGATIVE_INFINITY;
31
+ let timer = null;
32
+ function safeNow() {
33
+ try {
34
+ return nowFn();
35
+ }
36
+ catch {
37
+ return Date.now();
38
+ }
39
+ }
40
+ function clearTimer() {
41
+ if (timer !== null) {
42
+ try {
43
+ clearT(timer);
44
+ }
45
+ catch {
46
+ // ignore (a stray trailing paint is harmless — flush() already ran)
47
+ }
48
+ timer = null;
49
+ }
50
+ }
51
+ // Single paint path (also the never-lose-content fallback).
52
+ function emit(at) {
53
+ if (pending.size === 0) {
54
+ clearTimer();
55
+ return;
56
+ }
57
+ clearTimer();
58
+ const lanes = {};
59
+ const draft = pending.get("draft");
60
+ const thinking = pending.get("thinking");
61
+ if (draft !== undefined)
62
+ lanes.draft = draft;
63
+ if (thinking !== undefined)
64
+ lanes.thinking = thinking;
65
+ pending.clear();
66
+ lastFlush = at;
67
+ onFlush(lanes);
68
+ }
69
+ return {
70
+ push(lane, text) {
71
+ pending.set(lane, text);
72
+ const t = safeNow();
73
+ if (t - lastFlush >= intervalMs) {
74
+ emit(t);
75
+ return;
76
+ }
77
+ if (timer !== null)
78
+ return; // trailing paint already scheduled
79
+ const wait = intervalMs - (t - lastFlush);
80
+ try {
81
+ timer = setT(() => {
82
+ timer = null;
83
+ emit(safeNow());
84
+ }, Math.max(0, wait));
85
+ }
86
+ catch {
87
+ // No timer available: paint now rather than lose the token.
88
+ emit(safeNow());
89
+ }
90
+ },
91
+ flush() {
92
+ if (pending.size === 0) {
93
+ clearTimer();
94
+ return;
95
+ }
96
+ emit(safeNow());
97
+ },
98
+ cancel(lane) {
99
+ if (lane === undefined) {
100
+ pending.clear();
101
+ }
102
+ else {
103
+ pending.delete(lane);
104
+ }
105
+ if (pending.size === 0)
106
+ clearTimer();
107
+ },
108
+ reset() {
109
+ pending.clear();
110
+ clearTimer();
111
+ lastFlush = Number.NEGATIVE_INFINITY;
112
+ },
113
+ getPending(lane) {
114
+ return pending.get(lane) ?? null;
115
+ },
116
+ pendingTimers() {
117
+ return timer === null ? 0 : 1;
118
+ },
119
+ };
120
+ }
@@ -30,7 +30,6 @@ const PALETTE_CATEGORIES = {
30
30
  "/allow": "Tools",
31
31
  "/deny": "Tools",
32
32
  "/rules": "Tools",
33
- "/skills": "Skills",
34
33
  "/skill": "Skills",
35
34
  "/queue": "Flow",
36
35
  "/steer": "Flow",
@@ -68,5 +67,8 @@ export const PalettePanel = React.memo(function PalettePanel({ entries, index, f
68
67
  }
69
68
  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}`));
70
69
  });
71
- 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] }));
70
+ return (
71
+ // flexShrink=0: footer-cluster anchoring (ticket 05) — same contract as
72
+ // PickerShell; the list truncates via pickerWindow instead of squeezing.
73
+ _jsxs(Box, { flexDirection: "column", flexShrink: 0, 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] }));
72
74
  });
@@ -2,7 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { theme } from "./theme.js";
4
4
  export function PickerShell({ title, borderColor = theme.border.picker, children, }) {
5
- return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: borderColor, paddingX: theme.spacing.pickerPadX, children: [_jsx(Text, { bold: true, children: title }), children] }));
5
+ return (
6
+ // flexShrink=0: footer-cluster anchoring (ticket 05) — a tall list never
7
+ // squeezes when the terminal runs short; it truncates via pickerWindow.
8
+ _jsxs(Box, { flexDirection: "column", flexShrink: 0, borderStyle: theme.border.style, borderColor: borderColor, paddingX: theme.spacing.pickerPadX, children: [_jsx(Text, { bold: true, children: title }), children] }));
6
9
  }
7
10
  export function PickerMoreAbove({ count }) {
8
11
  if (count <= 0)