atom-agent 1.4.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.
- package/CHANGELOG.md +40 -0
- package/README.md +220 -224
- package/dist/App.js +922 -341
- package/dist/adapters.js +127 -14
- package/dist/agent/goal-evaluator.js +3 -0
- package/dist/agent/loop.js +211 -430
- 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/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 +26 -1
- 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 +115 -4
- 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/ui/side-by-side.js
CHANGED
|
@@ -20,8 +20,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
20
20
|
import React from "react";
|
|
21
21
|
import { Box, Text } from "ink";
|
|
22
22
|
import { useStdout } from "ink";
|
|
23
|
-
import { computeSideBySide, wordRuns } from "./diff.js";
|
|
24
|
-
import { DiffView, LineBody } from "./diff-view.js";
|
|
23
|
+
import { computeSideBySide, rangeLabel, sbsRange, wordRuns } from "./diff.js";
|
|
24
|
+
import { DiffSummary, DiffView, LineBody } from "./diff-view.js";
|
|
25
25
|
import { theme } from "./theme.js";
|
|
26
26
|
// Below this width two panes cannot breathe — stack unified instead.
|
|
27
27
|
export const SBS_NARROW_COLUMNS = 70;
|
|
@@ -30,13 +30,65 @@ export const SBS_NARROW_COLUMNS = 70;
|
|
|
30
30
|
// remains as an opt-in window for callers/tests that want a collapsed tail.
|
|
31
31
|
// Retained for compatibility.
|
|
32
32
|
export const TRANSCRIPT_DIFF_MAX_LINES = Infinity;
|
|
33
|
+
// Terminal-cell width helpers: the old truncateTo/padEnd counted code
|
|
34
|
+
// points, so tabs (1 cp, N columns), CJK/emoji (1 cp, 2 columns), and
|
|
35
|
+
// unpadded short lines all shifted the │ separator per row — the "messy
|
|
36
|
+
// spacing" in the report. These helpers normalize first, then measure in
|
|
37
|
+
// terminal cells so every row tiles exactly paneW + sep + paneW.
|
|
38
|
+
function expandTabs(s) {
|
|
39
|
+
// Repo indent is 2 spaces; a tab becomes 2 columns (compact, stable).
|
|
40
|
+
return s.replace(/\t/g, " ");
|
|
41
|
+
}
|
|
42
|
+
function cellWidth(ch) {
|
|
43
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
44
|
+
if (cp < 0x1100)
|
|
45
|
+
return 1;
|
|
46
|
+
if ((cp >= 0x1100 && cp <= 0x115f) ||
|
|
47
|
+
(cp >= 0x2e80 && cp <= 0x303e) ||
|
|
48
|
+
(cp >= 0x3041 && cp <= 0x33ff) ||
|
|
49
|
+
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
50
|
+
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
51
|
+
(cp >= 0xac00 && cp <= 0xd7af) ||
|
|
52
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
53
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) ||
|
|
54
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
55
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
56
|
+
(cp >= 0x20000 && cp <= 0x3fffd) ||
|
|
57
|
+
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
58
|
+
(cp >= 0x2600 && cp <= 0x27bf))
|
|
59
|
+
return 2;
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
function displayWidth(s) {
|
|
63
|
+
let w = 0;
|
|
64
|
+
for (const ch of expandTabs(s))
|
|
65
|
+
w += cellWidth(ch);
|
|
66
|
+
return w;
|
|
67
|
+
}
|
|
33
68
|
function truncateTo(s, width) {
|
|
34
|
-
const
|
|
35
|
-
if (
|
|
36
|
-
return
|
|
69
|
+
const src = expandTabs(s);
|
|
70
|
+
if (displayWidth(src) <= width)
|
|
71
|
+
return src;
|
|
37
72
|
if (width < 4)
|
|
38
73
|
return "";
|
|
39
|
-
|
|
74
|
+
const ell = theme.symbol.ellipsis; // 1 cell
|
|
75
|
+
let w = 0;
|
|
76
|
+
let out = "";
|
|
77
|
+
for (const ch of src) {
|
|
78
|
+
const cw = cellWidth(ch);
|
|
79
|
+
if (w + cw > width - 1)
|
|
80
|
+
break;
|
|
81
|
+
out += ch;
|
|
82
|
+
w += cw;
|
|
83
|
+
}
|
|
84
|
+
return out + ell;
|
|
85
|
+
}
|
|
86
|
+
function padDisplay(s, width) {
|
|
87
|
+
const src = expandTabs(s);
|
|
88
|
+
const w = displayWidth(src);
|
|
89
|
+
if (w >= width)
|
|
90
|
+
return src;
|
|
91
|
+
return src + " ".repeat(width - w);
|
|
40
92
|
}
|
|
41
93
|
// Fit engine rows to a pane content width: truncate cell texts (with …)
|
|
42
94
|
// and re-derive word runs on the truncated pair so offsets always tile
|
|
@@ -77,12 +129,9 @@ function fitRows(rows, contentW) {
|
|
|
77
129
|
});
|
|
78
130
|
}
|
|
79
131
|
function padEnd(s, width) {
|
|
80
|
-
|
|
81
|
-
if (len >= width)
|
|
82
|
-
return s;
|
|
83
|
-
return s + " ".repeat(width - len);
|
|
132
|
+
return padDisplay(s, width);
|
|
84
133
|
}
|
|
85
|
-
function SideBySideInner({ oldText, newText, lang = null, maxRows = Infinity, columns, }) {
|
|
134
|
+
function SideBySideInner({ oldText, newText, lang = null, path = null, maxRows = Infinity, columns, }) {
|
|
86
135
|
let stdoutCols;
|
|
87
136
|
try {
|
|
88
137
|
stdoutCols = useStdout()?.stdout?.columns;
|
|
@@ -104,10 +153,16 @@ function SideBySideInner({ oldText, newText, lang = null, maxRows = Infinity, co
|
|
|
104
153
|
// Graceful narrow-terminal degrade: stacked unified keeps every char
|
|
105
154
|
// instead of crushing two panes into unreadable slivers.
|
|
106
155
|
if (totalW < SBS_NARROW_COLUMNS) {
|
|
107
|
-
return _jsx(DiffView, { oldText: oldText, newText: newText, lang: lang, maxLines: maxRows });
|
|
156
|
+
return _jsx(DiffView, { oldText: oldText, newText: newText, lang: lang, path: path, maxLines: maxRows });
|
|
108
157
|
}
|
|
109
158
|
const sep = ` ${theme.symbol.bar} `;
|
|
110
|
-
|
|
159
|
+
// Reserve the App root padding (padding={1} each side = 2 cols) so tiles
|
|
160
|
+
// never overflow the frame and wrap. Inside bordered modals (border 2 +
|
|
161
|
+
// padding 2) we still overestimate by ~4 — wrap="truncate" below contains
|
|
162
|
+
// that instead of breaking the box.
|
|
163
|
+
const availW = Math.max(SBS_NARROW_COLUMNS, totalW - 2);
|
|
164
|
+
const sepW = displayWidth(sep);
|
|
165
|
+
const paneW = Math.max(20, Math.floor((availW - sepW) / 2));
|
|
111
166
|
let maxNo = 0;
|
|
112
167
|
for (const r of sbs.rows) {
|
|
113
168
|
if (r.kind === "context")
|
|
@@ -126,21 +181,25 @@ function SideBySideInner({ oldText, newText, lang = null, maxRows = Infinity, co
|
|
|
126
181
|
[sbs, contentW]);
|
|
127
182
|
const shown = view.slice(0, maxRows);
|
|
128
183
|
const overflow = Math.max(0, view.length - shown.length);
|
|
129
|
-
|
|
184
|
+
// Every cell tiles exactly numW + 1 + contentW cells: the body text is
|
|
185
|
+
// already truncated to contentW by fitRows, so trailing spaces pad it to
|
|
186
|
+
// full width and the │ separator lands in the same column every row.
|
|
187
|
+
const renderCell = (no, bodyText, body, opts) => {
|
|
130
188
|
const num = no === null ? " ".repeat(numW) : padEnd(String(no), numW);
|
|
131
|
-
|
|
189
|
+
const pad = " ".repeat(Math.max(0, contentW - displayWidth(bodyText)));
|
|
190
|
+
return (_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: opts.numColor, dimColor: opts.numColor === undefined || opts.dim, children: [num, " "] }), body, pad] }));
|
|
132
191
|
};
|
|
133
|
-
return (_jsxs(Box, { flexDirection: "column", children: [
|
|
192
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(DiffSummary, { adds: sbs.adds, dels: sbs.dels, isNewFile: sbs.isNewFile, path: path, range: sbs.isNewFile ? null : rangeLabel(sbsRange(sbs.rows)) }), shown.map((r, k) => {
|
|
134
193
|
if (r.kind === "context") {
|
|
135
|
-
return (_jsxs(Text, { children: [renderCell(r.left.no, _jsx(Text, { dimColor: true, children: r.left.text }), { dim: true }), _jsx(Text, { dimColor: true, children: sep }), renderCell(r.right.no, _jsx(Text, { dimColor: true, children: r.right.text }), { dim: true })] }, k));
|
|
194
|
+
return (_jsxs(Text, { wrap: "truncate", children: [renderCell(r.left.no, r.left.text, _jsx(Text, { dimColor: true, children: r.left.text }), { dim: true }), _jsx(Text, { dimColor: true, children: sep }), renderCell(r.right.no, r.right.text, _jsx(Text, { dimColor: true, children: r.right.text }), { dim: true })] }, k));
|
|
136
195
|
}
|
|
137
196
|
const leftNumColor = r.left !== null && r.changed ? theme.color.toolError : undefined;
|
|
138
197
|
const rightNumColor = r.right !== null && r.changed ? theme.color.success : undefined;
|
|
139
|
-
return (_jsxs(Text, { children: [r.left !== null
|
|
140
|
-
? renderCell(r.left.no, _jsx(LineBody, { lineText: r.left.text, runs: r.left.runs, base: "del", lang: lang }), { numColor: leftNumColor, dim: !r.changed })
|
|
141
|
-
: renderCell(null, _jsx(Text, { children:
|
|
142
|
-
? renderCell(r.right.no, _jsx(LineBody, { lineText: r.right.text, runs: r.right.runs, base: "add", lang: lang }), { numColor: rightNumColor, dim: !r.changed })
|
|
143
|
-
: renderCell(null, _jsx(Text, { children:
|
|
198
|
+
return (_jsxs(Text, { wrap: "truncate", children: [r.left !== null
|
|
199
|
+
? renderCell(r.left.no, r.left.text, _jsx(LineBody, { lineText: r.left.text, runs: r.left.runs, base: "del", lang: lang }), { numColor: leftNumColor, dim: !r.changed })
|
|
200
|
+
: renderCell(null, "", _jsx(Text, { children: "" }), { dim: true }), _jsx(Text, { dimColor: true, children: sep }), r.right !== null
|
|
201
|
+
? renderCell(r.right.no, r.right.text, _jsx(LineBody, { lineText: r.right.text, runs: r.right.runs, base: "add", lang: lang }), { numColor: rightNumColor, dim: !r.changed })
|
|
202
|
+
: renderCell(null, "", _jsx(Text, { children: "" }), { dim: true })] }, k));
|
|
144
203
|
}), overflow > 0 ? _jsxs(Text, { dimColor: true, children: ["\u2026 ", overflow, " more row", overflow === 1 ? "" : "s"] }) : null, sbs.truncated ? _jsx(Text, { dimColor: true, children: "(diff truncated at 400 changed lines)" }) : null] }));
|
|
145
204
|
}
|
|
146
205
|
export const SideBySideDiffView = React.memo(SideBySideInner);
|
package/dist/ui/status-bar.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
// Status bar leaf: the sole info bar, state-prioritized and quiet
|
|
2
|
+
// Status bar leaf: the sole info bar, state-prioritized and quiet (ticket 06
|
|
3
|
+
// information discipline).
|
|
3
4
|
// - idle: provider/model │ token │ cwd[:branch] │ reasoning │ mode.
|
|
4
5
|
// Labels are positional (no `provider:` prefixes); mode/trust show always
|
|
5
|
-
// (pinned), cwd shortens, branch only for git repos.
|
|
6
|
+
// (pinned), cwd shortens, branch only for git repos. A pending approval
|
|
7
|
+
// pins a `waiting approval` decision flag (warning color) — decision demand
|
|
8
|
+
// outranks location, which yields first under width pressure.
|
|
6
9
|
// - busy: activity │ elapsed │ token │ reasoning │ mode │ esc-hint (+waiting/approval flags).
|
|
7
10
|
// Provider/model/cwd drop while working — the activity, the
|
|
8
11
|
// clock, context pressure, effort, and the pinned mode are what matter mid-turn.
|
|
12
|
+
// - estimates: the token P% reads `(~P%)` (tilde) whenever the context load
|
|
13
|
+
// is a chars-based estimate rather than provider-reported input tokens
|
|
14
|
+
// (see `loadEstimated` / `isEstimatedLoad`); `token: n/a` and bare
|
|
15
|
+
// `token: NK` never gain a marker. Estimates are never exact facts.
|
|
9
16
|
// All paint comes from ui/theme tokens. The token segment formatter lives
|
|
10
17
|
// in context-windows (its only surface).
|
|
11
18
|
import React from "react";
|
|
@@ -53,6 +60,44 @@ export function fitGoalSegment(goal, room) {
|
|
|
53
60
|
return null;
|
|
54
61
|
return `goal: ${truncateGoalObjective(goal.objective, allow)} [${state}]`;
|
|
55
62
|
}
|
|
63
|
+
// Estimate honesty (ticket 06): is the context load behind P% a heuristic
|
|
64
|
+
// rather than provider-reported input tokens? An explicit `override` (the
|
|
65
|
+
// `loadEstimated` prop, owned by the caller that tracks the report latch)
|
|
66
|
+
// always wins. Without one, the bar can only prove the never-reported case:
|
|
67
|
+
// usage exists with no finite prompt_tokens anywhere in the accumulated
|
|
68
|
+
// totals, so the load could only have come from the chars/token estimate.
|
|
69
|
+
// Stale-totals resets (compaction/clear/resume/switch keep accumulated
|
|
70
|
+
// prompt_tokens while the load falls back to the estimate) need the explicit
|
|
71
|
+
// latch — the heuristic stays exact there, documented as a known gap rather
|
|
72
|
+
// than guessed. Pure; never throws.
|
|
73
|
+
export function isEstimatedLoad(usage, load, override) {
|
|
74
|
+
if (override === true)
|
|
75
|
+
return true;
|
|
76
|
+
if (override === false)
|
|
77
|
+
return false;
|
|
78
|
+
if (!usage || typeof load !== "number" || !Number.isFinite(load))
|
|
79
|
+
return false;
|
|
80
|
+
const reported = usage.prompt_tokens;
|
|
81
|
+
return !(typeof reported === "number" && Number.isFinite(reported));
|
|
82
|
+
}
|
|
83
|
+
// Tilde-marker for an estimated P%: `token: (17%) 44K` → `token: (~17%) 44K`.
|
|
84
|
+
// Single source of truth stays `formatTokenSegment` — this only inserts the
|
|
85
|
+
// `~` when the exact `(P%)` form is present, so `token: n/a` (nothing
|
|
86
|
+
// reported yet) and bare `token: NK` (no verified window, spend only) pass
|
|
87
|
+
// through byte-identical. Pure; never throws.
|
|
88
|
+
export function markTokenEstimate(segment) {
|
|
89
|
+
const prefix = "token: (";
|
|
90
|
+
if (typeof segment === "string" && segment.startsWith(prefix)) {
|
|
91
|
+
return `token: (~${segment.slice(prefix.length)}`;
|
|
92
|
+
}
|
|
93
|
+
return segment;
|
|
94
|
+
}
|
|
95
|
+
// Labeled token segment for the bar: exact `(P%)` for provider-reported
|
|
96
|
+
// loads, `(~P%)` for estimates, `n/a` / bare forms untouched. Pure.
|
|
97
|
+
export function formatStatusTokenSegment(usage, model, load, loadEstimated) {
|
|
98
|
+
const segment = formatTokenSegment(usage, model, load);
|
|
99
|
+
return isEstimatedLoad(usage, load, loadEstimated) ? markTokenEstimate(segment) : segment;
|
|
100
|
+
}
|
|
56
101
|
// ~/… collapse + tail-cut: informative, never a full scroll of nesting.
|
|
57
102
|
// Further shrinking for tight widths goes through shrinkTo below (the bar
|
|
58
103
|
// measures first and only renders what fits).
|
|
@@ -75,7 +120,7 @@ export function shrinkTo(s, n) {
|
|
|
75
120
|
// render (same-props parent churn — token paints, keystrokes, unrelated
|
|
76
121
|
// ticks — must skip it; only changed props repaint).
|
|
77
122
|
export const statusBarRenderProbe = { count: 0 };
|
|
78
|
-
export const StatusBar = React.memo(function StatusBar({ provider, model, usageTotals, contextLoad, reasoningDisplay, mode, trustAll, busy, activity, phaseLabel, elapsedSecs, stalled, approvalPending, cwd, branch, columns = 100, extensionStatus, goal, }) {
|
|
123
|
+
export const StatusBar = React.memo(function StatusBar({ provider, model, usageTotals, contextLoad, loadEstimated, reasoningDisplay, mode, trustAll, busy, activity, phaseLabel, elapsedSecs, stalled, approvalPending, cwd, branch, columns = 100, extensionStatus, goal, }) {
|
|
79
124
|
statusBarRenderProbe.count += 1;
|
|
80
125
|
const bar = theme.symbol.bar;
|
|
81
126
|
// Extension guest slot (ticket 10): pre-budgeted text renders only when
|
|
@@ -83,14 +128,18 @@ export const StatusBar = React.memo(function StatusBar({ provider, model, usageT
|
|
|
83
128
|
// is the ` ${bar} ` separator the segment carries with it.
|
|
84
129
|
const hasExt = typeof extensionStatus === "string" && extensionStatus.length > 0;
|
|
85
130
|
if (!busy) {
|
|
86
|
-
const token =
|
|
131
|
+
const token = formatStatusTokenSegment(usageTotals, model, contextLoad, loadEstimated);
|
|
87
132
|
const trust = trustAll && mode !== "plan" ? "+trust" : "";
|
|
133
|
+
// Waiting-approval while idle (ticket 06): the decision flag is pinned —
|
|
134
|
+
// decision demand outranks location. It joins the width budget up front
|
|
135
|
+
// so the location (then the goal) yields for it instead of overflowing.
|
|
136
|
+
const approvalSeg = approvalPending ? ` ${bar} waiting approval` : "";
|
|
88
137
|
// Measure-first layout: the location (cwd + branch) flexes so the whole
|
|
89
138
|
// line always fits `columns`. Fixed segments never shrink (wrapping
|
|
90
139
|
// would split `mode: X` needles across lines); the location yields in
|
|
91
140
|
// order: branch → cwd tail → the whole segment.
|
|
92
141
|
const tail = `reasoning: ${reasoningDisplay} ${bar} mode: ${mode}${trust}`;
|
|
93
|
-
const baseLen = `${provider}/${model} ${bar} ${token} ${bar} ${bar} ${tail}`.length;
|
|
142
|
+
const baseLen = `${provider}/${model} ${bar} ${token} ${bar} ${bar} ${tail}${approvalSeg}`.length;
|
|
94
143
|
const showExt = hasExt && baseLen + extensionStatus.length + 3 + 2 <= columns;
|
|
95
144
|
const avail = columns - baseLen - (showExt ? extensionStatus.length + 3 : 0);
|
|
96
145
|
let loc = null;
|
|
@@ -112,14 +161,18 @@ export const StatusBar = React.memo(function StatusBar({ provider, model, usageT
|
|
|
112
161
|
// the line past `columns`. Hidden entirely with no goal.
|
|
113
162
|
const lineSoFar = baseLen + (showExt ? extensionStatus.length + 3 : 0) + (loc ? loc.length + 3 : 0);
|
|
114
163
|
const goalSeg = fitGoalSegment(goal ?? null, columns - lineSoFar - 2);
|
|
115
|
-
return (
|
|
164
|
+
return (
|
|
165
|
+
// flexShrink=0: footer-cluster anchoring (ticket 05) — the status line
|
|
166
|
+
// is the cluster's bottom pin; segments fit-or-drop via `columns`
|
|
167
|
+
// (ticket 06 discipline: decision flag outranks location, goal yields).
|
|
168
|
+
_jsx(Box, { marginTop: theme.spacing.statusMarginTop, flexShrink: 0, children: _jsxs(Text, { dimColor: true, children: [provider, "/", model, " ", bar, " ", token, showExt ? (_jsxs(_Fragment, { children: [" ", bar, " ", extensionStatus] })) : null, loc ? (_jsxs(_Fragment, { children: [" ", bar, " ", loc] })) : null, " ", bar, " reasoning: ", reasoningDisplay, " ", bar, " mode: ", mode, trust ? "+trust" : null, approvalPending ? (_jsx(Text, { color: theme.color.warning, children: approvalSeg })) : null, goalSeg ? (_jsxs(_Fragment, { children: [" ", bar, " ", goalSeg] })) : null] }) }));
|
|
116
169
|
}
|
|
117
170
|
// Busy layout prioritizes activity + clock + interrupt hint; the mode
|
|
118
171
|
// stays pinned (it used to vanish while working), and the reasoning
|
|
119
172
|
// effort stays visible (it used to vanish while working). The activity text
|
|
120
173
|
// shrinks to fit so `esc stops` never wraps away.
|
|
121
174
|
const busyTrust = trustAll && mode !== "plan" ? "+trust" : "";
|
|
122
|
-
const busyToken =
|
|
175
|
+
const busyToken = formatStatusTokenSegment(usageTotals, model, contextLoad, loadEstimated);
|
|
123
176
|
// Goal segment (ticket 09): a guest in the fixed part — capped at 48
|
|
124
177
|
// chars and rendered only when the FULL activity text still fits beside
|
|
125
178
|
// it. Otherwise the goal drops whole and every existing segment renders
|
|
@@ -146,5 +199,7 @@ export const StatusBar = React.memo(function StatusBar({ provider, model, usageT
|
|
|
146
199
|
const busyFixed = ` ${bar} ${elapsedSecs}s${showBusyExt ? ` ${bar} ${extensionStatus}` : ""} ${bar} ${busyToken} ${bar} reasoning: ${reasoningDisplay} ${bar} mode: ${mode}${busyTrust}${busyGoalPart} ${bar} esc stops`;
|
|
147
200
|
const busyAvail = columns - busyFixed.length - 2;
|
|
148
201
|
const activityText = shrinkTo(activityFull, Math.max(0, busyAvail));
|
|
149
|
-
return (
|
|
202
|
+
return (
|
|
203
|
+
// flexShrink=0: same footer-cluster pin as the idle layout above.
|
|
204
|
+
_jsx(Box, { marginTop: theme.spacing.statusMarginTop, flexShrink: 0, children: _jsxs(Text, { dimColor: true, children: [_jsxs(Text, { color: theme.color.activity, children: [theme.symbol.workTool, " ", activityText] }), busyFixed, stalled && !approvalPending ? ` ${bar} waiting${theme.symbol.ellipsis}` : null, approvalPending ? (_jsxs(Text, { color: theme.color.warning, children: [" ", bar, " waiting approval"] })) : null] }) }));
|
|
150
205
|
});
|
package/dist/ui/stream-store.js
CHANGED
|
@@ -36,6 +36,13 @@ export function createStreamStore() {
|
|
|
36
36
|
return;
|
|
37
37
|
assign({ draft: snapshot.draft, thinking: text });
|
|
38
38
|
},
|
|
39
|
+
set: (next) => {
|
|
40
|
+
const draft = next.draft !== undefined ? next.draft : snapshot.draft;
|
|
41
|
+
const thinking = next.thinking !== undefined ? next.thinking : snapshot.thinking;
|
|
42
|
+
if (draft === snapshot.draft && thinking === snapshot.thinking)
|
|
43
|
+
return;
|
|
44
|
+
assign({ draft, thinking });
|
|
45
|
+
},
|
|
39
46
|
getDraft: () => snapshot.draft,
|
|
40
47
|
getThinking: () => snapshot.thinking,
|
|
41
48
|
clear: () => {
|
package/dist/ui/theme.js
CHANGED
|
@@ -6,9 +6,18 @@
|
|
|
6
6
|
// polish chunks change values here instead of hunting call sites.
|
|
7
7
|
//
|
|
8
8
|
// Density rules (terminal space is scarce):
|
|
9
|
-
// -
|
|
9
|
+
// - Framed surfaces: the input, pickers/popups, and modals only. Live
|
|
10
|
+
// panels (todo checklist, tool inspector, diff review) stay frameless —
|
|
11
|
+
// their bold headers name the group, matching the transcript's frameless
|
|
12
|
+
// text. Third-party extension widgets keep the `panel` frame: untrusted
|
|
13
|
+
// content of unbounded shape needs a containment + provenance boundary.
|
|
10
14
|
// - The transcript is frameless text; hierarchy comes from speaker labels,
|
|
11
15
|
// dimming, and one blank line between turns — never extra chrome.
|
|
16
|
+
// - The live tail mounts nothing when there is nothing live (no draft,
|
|
17
|
+
// thinking, tool, or held line): its margin would otherwise spend two
|
|
18
|
+
// blank lines on every idle frame with history.
|
|
19
|
+
// - Liveness reads from ticking elapsed seconds, never animated glyphs —
|
|
20
|
+
// no animation without feedback, no spinner timers.
|
|
12
21
|
// - `muted` is implemented as Ink `dimColor` (terminal-dimmed default fg),
|
|
13
22
|
// NOT as gray paint: it adapts to light/dark terminals. Literal gray
|
|
14
23
|
// (`color.mutedPaint`) is reserved for block glyphs (cursors) that need a
|
|
@@ -60,6 +69,11 @@ export const theme = {
|
|
|
60
69
|
synKeyword: "magenta",
|
|
61
70
|
synString: "yellow",
|
|
62
71
|
synNumber: "cyan",
|
|
72
|
+
// Diff changed-word highlight: high-contrast background treatment so
|
|
73
|
+
// changed words pop over syntax hues (paired with diffChangedFg).
|
|
74
|
+
diffAddBg: "green",
|
|
75
|
+
diffDelBg: "red",
|
|
76
|
+
diffChangedFg: "black",
|
|
63
77
|
},
|
|
64
78
|
border: {
|
|
65
79
|
style: "round",
|
|
@@ -116,6 +130,14 @@ export const theme = {
|
|
|
116
130
|
inputPrompt: "›",
|
|
117
131
|
keyMask: "•",
|
|
118
132
|
keyPresent: "✓",
|
|
133
|
+
// Collapsed tool-block states (ticket 03): every collapsed one-liner
|
|
134
|
+
// (inspector row, expanded header) reads its outcome glyph from here —
|
|
135
|
+
// never ad-hoc literals — so success/failed/denied stay one token edit.
|
|
136
|
+
// Denied is calm-neutral (its own glyph, never the failure cross); the
|
|
137
|
+
// running state keeps the live `running`/`workTool` glyphs above.
|
|
138
|
+
toolOk: "✓",
|
|
139
|
+
toolFail: "✕",
|
|
140
|
+
toolDenied: "⊘",
|
|
119
141
|
taskDone: "✅",
|
|
120
142
|
taskActive: "🔧",
|
|
121
143
|
// Pending means "not started yet" — an open circle (never ❌, which
|
package/dist/ui/todo-panel.js
CHANGED
|
@@ -9,7 +9,10 @@ import { theme } from "./theme.js";
|
|
|
9
9
|
// the live area below the transcript (NOT in <Static> scrollback) and fed
|
|
10
10
|
// by a snapshot the loop refreshes after every todowrite/todo_update call,
|
|
11
11
|
// so the in-progress row — shown with its activeForm when present — always
|
|
12
|
-
// answers "what is the model doing right now".
|
|
12
|
+
// answers "what is the model doing right now". Frameless by restraint
|
|
13
|
+
// (ticket 07): the bold `Tasks n/m` header names the group, matching the
|
|
14
|
+
// frameless inspector/diff-panel lists — a box would spend two rows and two
|
|
15
|
+
// columns on chrome the header already carries. Returns null when empty.
|
|
13
16
|
// Render-count probe for the flicker tests: same-props parent churn must
|
|
14
17
|
// skip the panel (it only changes when the loop commits todo activity).
|
|
15
18
|
export const todoPanelRenderProbe = { count: 0 };
|
|
@@ -18,7 +21,7 @@ export const TodoPanel = React.memo(function TodoPanel({ items }) {
|
|
|
18
21
|
if (items.length === 0)
|
|
19
22
|
return null;
|
|
20
23
|
const done = items.filter((t) => t.status === "completed").length;
|
|
21
|
-
return (_jsxs(Box, { flexDirection: "column",
|
|
24
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: theme.spacing.turnGap, children: [_jsxs(Text, { bold: true, children: ["Tasks ", done, "/", items.length] }), items.map((t, i) => {
|
|
22
25
|
const mark = t.status === "completed"
|
|
23
26
|
? theme.symbol.taskDone
|
|
24
27
|
: t.status === "in_progress"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import { truncateHead } from "../tools/shared.js";
|
|
4
4
|
import { theme } from "./theme.js";
|
|
@@ -39,6 +39,16 @@ export function windowedList(items, index, size) {
|
|
|
39
39
|
below: items.length - (start + size),
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
+
export function toolBlockState(rec) {
|
|
43
|
+
if (rec.isError && /denied by user/i.test(`${rec.label} ${rec.result}`))
|
|
44
|
+
return "denied";
|
|
45
|
+
return rec.isError ? "failed" : "ok";
|
|
46
|
+
}
|
|
47
|
+
const TOOL_STATE_GLYPH = {
|
|
48
|
+
ok: theme.symbol.toolOk,
|
|
49
|
+
failed: theme.symbol.toolFail,
|
|
50
|
+
denied: theme.symbol.toolDenied,
|
|
51
|
+
};
|
|
42
52
|
function formatDur(ms) {
|
|
43
53
|
return ` ${theme.symbol.separator} ${Math.max(1, Math.round(ms / 1000))}s`;
|
|
44
54
|
}
|
|
@@ -49,10 +59,20 @@ export function InspectorPanel({ records, index, expanded, scroll }) {
|
|
|
49
59
|
return null;
|
|
50
60
|
if (!expanded) {
|
|
51
61
|
const win = windowedList(records, sel, LIST_WINDOW);
|
|
52
|
-
return (_jsxs(Box, { flexDirection: "column", children: [
|
|
62
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: ["Tool outputs ", theme.symbol.descSeparator, " select to inspect (Enter expands, Esc closes):"] }), win.above > 0 ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " ", win.above, " more"] })) : null, win.slice.map((r, k) => {
|
|
53
63
|
const i = win.start + k;
|
|
54
64
|
const hi = i === sel;
|
|
55
|
-
|
|
65
|
+
// Collapsed one-liner (ticket 03): state glyph + name + target +
|
|
66
|
+
// duration where useful. Every row carries its state explicitly —
|
|
67
|
+
// success is a quiet ✓, failures ✕, denials the calm ⊘.
|
|
68
|
+
const st = toolBlockState(r);
|
|
69
|
+
return (_jsxs(Text, { color: hi
|
|
70
|
+
? theme.color.selection
|
|
71
|
+
: st === "failed"
|
|
72
|
+
? theme.color.toolError
|
|
73
|
+
: st === "denied"
|
|
74
|
+
? theme.color.warning
|
|
75
|
+
: undefined, children: [hi ? `${theme.symbol.select} ` : theme.spacing.rowIndent, TOOL_STATE_GLYPH[st], " ", r.label, r.ms >= 2000 ? formatDur(r.ms) : ""] }, r.id));
|
|
56
76
|
}), 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 \u00B7 Ctrl+O closes"] })] }));
|
|
57
77
|
}
|
|
58
78
|
const lines = rec.result.split("\n");
|
|
@@ -61,5 +81,14 @@ export function InspectorPanel({ records, index, expanded, scroll }) {
|
|
|
61
81
|
const off = Math.max(0, Math.min(scroll, maxOffset));
|
|
62
82
|
const view = lines.slice(off, off + VIEWPORT_LINES);
|
|
63
83
|
const rule = theme.symbol.rule.repeat(32);
|
|
64
|
-
|
|
84
|
+
// Expanded in place (ticket 03): the same record's full retained output
|
|
85
|
+
// under its one-liner header — one key (Enter) opens, another (Esc/Enter)
|
|
86
|
+
// collapses back to the list. The panel mounts in the dynamic zone, never
|
|
87
|
+
// in <Static>, so committed rows keep their identities throughout.
|
|
88
|
+
const expandedState = toolBlockState(rec);
|
|
89
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [_jsxs(Text, { color: expandedState === "failed"
|
|
90
|
+
? theme.color.toolError
|
|
91
|
+
: expandedState === "denied"
|
|
92
|
+
? theme.color.warning
|
|
93
|
+
: theme.color.success, children: [TOOL_STATE_GLYPH[expandedState], " "] }), rec.label, rec.ms >= 2000 ? formatDur(rec.ms) : "", _jsxs(Text, { dimColor: true, children: [" ", theme.symbol.separator, " ", rec.lineCount, " line", rec.lineCount === 1 ? "" : "s"] })] }), rec.truncated ? (_jsxs(Text, { dimColor: true, children: ["(stored output truncated at ", Math.round(STORE_CHARS / 1024), "KB)"] })) : null, _jsx(Text, { dimColor: true, children: rule }), view.map((ln, k) => (_jsx(Text, { children: ln.length > 0 ? ln : " " }, off + k))), _jsx(Text, { dimColor: true, children: rule }), _jsxs(Text, { dimColor: true, children: [off > 0 ? `${theme.symbol.moreAbove} ${off} more ` : "", theme.symbol.moreAbove, "/", theme.symbol.moreBelow, " scroll \u00B7 PgUp/PgDn jump \u00B7 Enter collapses \u00B7 Esc closes", maxOffset - off > 0 ? ` ${theme.symbol.moreBelow} ${maxOffset - off} more` : ""] })] }));
|
|
65
94
|
}
|
package/dist/ui/transcript.js
CHANGED
|
@@ -53,10 +53,13 @@ export function renderTranscriptItem(item) {
|
|
|
53
53
|
return _jsx(StartupBanner, {}, item.id);
|
|
54
54
|
const t = item.turn;
|
|
55
55
|
const i = item.id;
|
|
56
|
-
// Committed thinking blocks read as
|
|
57
|
-
// with answers): dim
|
|
56
|
+
// Committed thinking blocks read as one grouped unit (never confused
|
|
57
|
+
// with answers): dim labeled header plus quoteBar-prefixed body lines —
|
|
58
|
+
// the same visual language as the live thinking block. The divider lives
|
|
59
|
+
// inside this row's own box (no extra Static rows), all dim per theme law.
|
|
58
60
|
if (t.thinking === true) {
|
|
59
|
-
|
|
61
|
+
const bodyLines = t.content.split("\n");
|
|
62
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [theme.symbol.thinking, " thinking"] }), bodyLines.map((line, idx) => (_jsx(Text, { dimColor: true, children: `${theme.symbol.quoteBar} ${line}` }, idx)))] }, i));
|
|
60
63
|
}
|
|
61
64
|
// Conversation turns (user/assistant) breathe: one blank line after each,
|
|
62
65
|
// so the eye lands on the next turn. Tool/status lines stay dense — they
|
|
@@ -73,9 +76,9 @@ export function renderTranscriptItem(item) {
|
|
|
73
76
|
// write/edit label swallowed by pairing (success line immediately
|
|
74
77
|
// followed by an error line) keeps its committed diff above the card.
|
|
75
78
|
const labelDiff = item.label?.diff;
|
|
76
|
-
return (_jsxs(React.Fragment, { children: [item.label ? _jsx(ToolLine, { content: item.label.content, ms: item.label.ms }) : null, labelDiff && !item.label?.error ? (_jsx(SideBySideDiffView, { oldText: labelDiff.oldText, newText: labelDiff.newText, lang: labelDiff.lang })) : null, _jsx(ErrorCard, { classified: classified })] }, i));
|
|
79
|
+
return (_jsxs(React.Fragment, { children: [item.label ? _jsx(ToolLine, { content: item.label.content, ms: item.label.ms, via: item.label.approvalVia }) : null, labelDiff && !item.label?.error ? (_jsx(SideBySideDiffView, { oldText: labelDiff.oldText, newText: labelDiff.newText, lang: labelDiff.lang, path: labelDiff.path })) : null, _jsx(ErrorCard, { classified: classified })] }, i));
|
|
77
80
|
}
|
|
78
|
-
return (_jsxs(React.Fragment, { children: [_jsx(ToolLine, { content: t.content, error: t.error, ms: t.ms }), t.diff && !t.error ? (_jsx(SideBySideDiffView, { oldText: t.diff.oldText, newText: t.diff.newText, lang: t.diff.lang })) : null] }, i));
|
|
81
|
+
return (_jsxs(React.Fragment, { children: [_jsx(ToolLine, { content: t.content, error: t.error, ms: t.ms, via: t.approvalVia }), t.diff && !t.error ? (_jsx(SideBySideDiffView, { oldText: t.diff.oldText, newText: t.diff.newText, lang: t.diff.lang, path: t.diff.path })) : null] }, i));
|
|
79
82
|
}
|
|
80
83
|
return (_jsxs(Box, { flexDirection: "column", marginBottom: theme.spacing.turnGap, children: [_jsx(Text, { children: _jsxs(Text, { color: theme.color.assistant, bold: true, children: [theme.symbol.speakerAssistant, " "] }) }), _jsx(MarkdownText, { text: t.content })] }, i));
|
|
81
84
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// WebUI event protocol: the SSE vocabulary the browser consumes.
|
|
2
|
+
//
|
|
3
|
+
// Every kind maps 1:1 to an existing ATOM callback or sink — nothing is
|
|
4
|
+
// invented. The runtime (./runtime.js) translates, this module only defines
|
|
5
|
+
// and serializes:
|
|
6
|
+
//
|
|
7
|
+
// - token / thinking ← StreamCallbacks.onToken / onThinking (accumulated
|
|
8
|
+
// text, same value the TUI renders in its live tail)
|
|
9
|
+
// - phase ← StreamCallbacks.onPhase ("thinking" | "streaming" |
|
|
10
|
+
// "tool" | "retry" | "done", detail carries the tool name / retry summary)
|
|
11
|
+
// - tool_delta ← StreamCallbacks.onToolDelta (name revealed mid-stream)
|
|
12
|
+
// - tool_started /
|
|
13
|
+
// tool_finished ← TurnEventsSink.onToolStarted / onToolFinished
|
|
14
|
+
// (stable toolCallId + name + index / isError — never display labels)
|
|
15
|
+
// - tool_call ← the approve() gate decision (name + effective args +
|
|
16
|
+
// description + decision + provenance — the only pre-execution arg source)
|
|
17
|
+
// - tool_activity ← AgenticOpts.onToolActivity (label + result + isError)
|
|
18
|
+
// - tool_result ← AgenticOpts.onToolResult (name + effective args +
|
|
19
|
+
// result + isError for EVERY committed call, including read-only tools
|
|
20
|
+
// that never consult approve; result text capped, see truncateEventText)
|
|
21
|
+
// - file_diff ← write/edit commits only: op (created/modified) +
|
|
22
|
+
// pre-computed unified hunks + side-by-side rows from src/ui/diff.ts
|
|
23
|
+
// (the same engine the TUI approval preview uses), all text-capped.
|
|
24
|
+
// ATOM has no delete tool and bash side effects are opaque by design
|
|
25
|
+
// (see src/rollback.ts), so deletions never appear as file events.
|
|
26
|
+
// - usage ← AgenticOpts.onUsage (API-reported tokens only)
|
|
27
|
+
// - reasoning ← AgenticOpts.onReasoning (per-POST reasoning label)
|
|
28
|
+
// - warning ← StreamCallbacks.onWarning
|
|
29
|
+
// - approval_request /
|
|
30
|
+
// approval_resolved ← the approve() gate (write/edit/bash in normal mode;
|
|
31
|
+
// the turn blocks until the browser POSTs a decision)
|
|
32
|
+
// - question_request /
|
|
33
|
+
// question_resolved ← the askUser() hook (ask_question tool; same blocking
|
|
34
|
+
// contract as approvals)
|
|
35
|
+
// - message ← committed transcript turns (user/assistant/tool rows)
|
|
36
|
+
// - error ← failed POST / tool-throw abort (caller rolls back)
|
|
37
|
+
// - done / cancelled ← clean turn end / LoopCancelledError rollback
|
|
38
|
+
//
|
|
39
|
+
// Pure module: types + SSE serialization only. No I/O, no loop imports.
|
|
40
|
+
export function createWebEvent(seq, kind, data) {
|
|
41
|
+
return {
|
|
42
|
+
seq,
|
|
43
|
+
at: new Date().toISOString(),
|
|
44
|
+
kind,
|
|
45
|
+
data: data ?? {},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// Serialize one event as an SSE frame. `id:` carries the sequence so an
|
|
49
|
+
// EventSource client resumes without gaps; `event:` carries the kind so the
|
|
50
|
+
// browser dispatches without parsing the body first.
|
|
51
|
+
export function formatSSE(event) {
|
|
52
|
+
return `id: ${event.seq}\nevent: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`;
|
|
53
|
+
}
|
|
54
|
+
// SSE response headers shared by every event stream on the server.
|
|
55
|
+
export function sseHeaders() {
|
|
56
|
+
return {
|
|
57
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
58
|
+
"Cache-Control": "no-store",
|
|
59
|
+
"X-Content-Type-Options": "nosniff",
|
|
60
|
+
Connection: "keep-alive",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// Parse one SSE frame back (tests + future resumable clients). Returns null
|
|
64
|
+
// for heartbeats/comments or malformed frames — never throws.
|
|
65
|
+
export function parseSSEFrame(frame) {
|
|
66
|
+
try {
|
|
67
|
+
const lines = frame.split("\n");
|
|
68
|
+
const dataLine = lines.find((l) => l.startsWith("data:"));
|
|
69
|
+
if (!dataLine)
|
|
70
|
+
return null;
|
|
71
|
+
const parsed = JSON.parse(dataLine.slice("data:".length).trim());
|
|
72
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
73
|
+
return null;
|
|
74
|
+
const o = parsed;
|
|
75
|
+
if (typeof o["seq"] !== "number" || typeof o["kind"] !== "string")
|
|
76
|
+
return null;
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Reconnect replay: events after the client's last seen id, oldest first.
|
|
84
|
+
// Pure (unit-tested); the runtime and the SSE route share it.
|
|
85
|
+
export function eventsAfter(log, lastEventId) {
|
|
86
|
+
if (typeof lastEventId !== "number" || !Number.isFinite(lastEventId))
|
|
87
|
+
return [];
|
|
88
|
+
return log.filter((e) => e.seq > lastEventId);
|
|
89
|
+
}
|
|
90
|
+
// Heartbeat comment (keeps proxies/load-balancers from closing idle turns).
|
|
91
|
+
export function sseHeartbeat() {
|
|
92
|
+
return `: ping\n\n`;
|
|
93
|
+
}
|