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.
- package/CHANGELOG.md +70 -0
- package/README.md +221 -224
- package/dist/App.js +922 -341
- package/dist/adapters.js +502 -21
- package/dist/agent/goal-evaluator.js +3 -0
- package/dist/agent/loop.js +250 -434
- package/dist/agent/tool-pipeline.js +398 -0
- package/dist/agent/turn-events.js +12 -0
- package/dist/cli.js +57 -8
- package/dist/compact.js +72 -8
- package/dist/config.js +19 -0
- package/dist/context-manager.js +6 -2
- package/dist/extensions.js +6 -0
- package/dist/file-diffs.js +108 -0
- package/dist/kilo.js +1 -1
- package/dist/local-discovery.js +2 -2
- package/dist/media.js +276 -0
- package/dist/overflow.js +140 -0
- package/dist/policy.js +8 -0
- package/dist/providers.js +11 -3
- package/dist/scheduler.js +38 -9
- package/dist/session-revert.js +125 -0
- package/dist/sessions.js +101 -0
- package/dist/snapshots.js +69 -0
- package/dist/system.js +2 -89
- package/dist/telemetry.js +79 -5
- package/dist/todos.js +241 -0
- package/dist/tools/filesystem.js +102 -22
- package/dist/tools/registry.js +184 -45
- package/dist/tools/ripgrep.js +7 -6
- package/dist/tools/search.js +172 -17
- package/dist/tools/shared.js +6 -0
- package/dist/tools.js +7 -39
- package/dist/ui/diff-panel.js +1 -1
- package/dist/ui/diff-view.js +13 -5
- package/dist/ui/diff.js +67 -0
- package/dist/ui/errors.js +20 -6
- package/dist/ui/input.js +24 -20
- package/dist/ui/live-tail.js +36 -1
- package/dist/ui/markdown.js +9 -4
- package/dist/ui/modals.js +7 -5
- package/dist/ui/paint-scheduler.js +120 -0
- package/dist/ui/palette.js +4 -2
- package/dist/ui/pickers.js +4 -1
- package/dist/ui/side-by-side.js +81 -22
- package/dist/ui/status-bar.js +63 -8
- package/dist/ui/stream-store.js +7 -0
- package/dist/ui/theme.js +23 -1
- package/dist/ui/todo-panel.js +5 -2
- package/dist/ui/tool-inspector.js +33 -4
- package/dist/ui/transcript.js +8 -5
- package/dist/web/events.js +93 -0
- package/dist/web/runtime.js +790 -0
- package/dist/web/server.js +570 -0
- package/dist/web/ui/app.js +1925 -0
- package/dist/web/ui/index.html +135 -0
- package/dist/web/ui/styles.css +515 -0
- package/dist/zen.js +532 -34
- package/documentation/cli.md +5 -5
- package/documentation/configuration.md +11 -6
- package/documentation/development.md +4 -3
- package/documentation/goals.md +1 -1
- package/documentation/index.md +4 -4
- package/documentation/providers.md +2 -3
- package/documentation/skills.md +3 -3
- package/documentation/tools.md +8 -3
- package/documentation/troubleshooting.md +1 -1
- 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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 allToolDefinitions — one list for model visibility
|
|
34
|
+
// and executability.
|
package/dist/ui/diff-panel.js
CHANGED
|
@@ -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: [
|
|
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
|
}
|
package/dist/ui/diff-view.js
CHANGED
|
@@ -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" ?
|
|
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:
|
|
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
|
-
|
|
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: [
|
|
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
|
|
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
|
|
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.
|
|
5
|
-
//
|
|
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
|
|
18
|
-
// state churn never repaint it — keystrokes stay at exactly one paint
|
|
19
|
-
// which is what makes navigation feel instant instead of choppy.
|
|
20
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
});
|
package/dist/ui/live-tail.js
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/dist/ui/markdown.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
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
|
+
}
|
package/dist/ui/palette.js
CHANGED
|
@@ -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 (
|
|
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
|
});
|
package/dist/ui/pickers.js
CHANGED
|
@@ -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 (
|
|
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)
|