atom-agent 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -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 +5 -5
- package/dist/ui/diff-view.js +16 -7
- package/dist/ui/diff.js +73 -51
- 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 +6 -4
- 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 +88 -27
- 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 +9 -6
- 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/extensions.md +1 -1
- 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/examples/extensions/01-audit-gate.js +2 -2
- package/examples/extensions/02-notes-tool.js +2 -2
- package/examples/extensions/03-custom-command.js +2 -2
- package/package.json +3 -2
|
@@ -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)
|
package/dist/ui/side-by-side.js
CHANGED
|
@@ -14,27 +14,81 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
14
14
|
// (code-point safe); below NARROW_COLUMNS the view degrades to the
|
|
15
15
|
// stacked unified DiffView instead of destroying the layout.
|
|
16
16
|
// - computed once per mount (useMemo, keyed on inputs + pane width) and
|
|
17
|
-
//
|
|
17
|
+
// rendered whole (an explicit maxRows windows it when a caller passes one)
|
|
18
|
+
// — never recomputed per tick, never floods via re-computation.
|
|
18
19
|
// All paint comes from ui/theme tokens.
|
|
19
20
|
import React from "react";
|
|
20
21
|
import { Box, Text } from "ink";
|
|
21
22
|
import { useStdout } from "ink";
|
|
22
|
-
import { computeSideBySide, wordRuns } from "./diff.js";
|
|
23
|
-
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";
|
|
24
25
|
import { theme } from "./theme.js";
|
|
25
26
|
// Below this width two panes cannot breathe — stack unified instead.
|
|
26
27
|
export const SBS_NARROW_COLUMNS = 70;
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
28
|
+
// Uncapped: views render the full row list (smooth via per-mount useMemo +
|
|
29
|
+
// append-once Static + word-token/Myers fallbacks in the engine). maxRows
|
|
30
|
+
// remains as an opt-in window for callers/tests that want a collapsed tail.
|
|
31
|
+
// Retained for compatibility.
|
|
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
|
+
}
|
|
31
68
|
function truncateTo(s, width) {
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
return
|
|
69
|
+
const src = expandTabs(s);
|
|
70
|
+
if (displayWidth(src) <= width)
|
|
71
|
+
return src;
|
|
35
72
|
if (width < 4)
|
|
36
73
|
return "";
|
|
37
|
-
|
|
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);
|
|
38
92
|
}
|
|
39
93
|
// Fit engine rows to a pane content width: truncate cell texts (with …)
|
|
40
94
|
// and re-derive word runs on the truncated pair so offsets always tile
|
|
@@ -75,12 +129,9 @@ function fitRows(rows, contentW) {
|
|
|
75
129
|
});
|
|
76
130
|
}
|
|
77
131
|
function padEnd(s, width) {
|
|
78
|
-
|
|
79
|
-
if (len >= width)
|
|
80
|
-
return s;
|
|
81
|
-
return s + " ".repeat(width - len);
|
|
132
|
+
return padDisplay(s, width);
|
|
82
133
|
}
|
|
83
|
-
function SideBySideInner({ oldText, newText, lang = null, maxRows = Infinity, columns, }) {
|
|
134
|
+
function SideBySideInner({ oldText, newText, lang = null, path = null, maxRows = Infinity, columns, }) {
|
|
84
135
|
let stdoutCols;
|
|
85
136
|
try {
|
|
86
137
|
stdoutCols = useStdout()?.stdout?.columns;
|
|
@@ -102,10 +153,16 @@ function SideBySideInner({ oldText, newText, lang = null, maxRows = Infinity, co
|
|
|
102
153
|
// Graceful narrow-terminal degrade: stacked unified keeps every char
|
|
103
154
|
// instead of crushing two panes into unreadable slivers.
|
|
104
155
|
if (totalW < SBS_NARROW_COLUMNS) {
|
|
105
|
-
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 });
|
|
106
157
|
}
|
|
107
158
|
const sep = ` ${theme.symbol.bar} `;
|
|
108
|
-
|
|
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));
|
|
109
166
|
let maxNo = 0;
|
|
110
167
|
for (const r of sbs.rows) {
|
|
111
168
|
if (r.kind === "context")
|
|
@@ -124,21 +181,25 @@ function SideBySideInner({ oldText, newText, lang = null, maxRows = Infinity, co
|
|
|
124
181
|
[sbs, contentW]);
|
|
125
182
|
const shown = view.slice(0, maxRows);
|
|
126
183
|
const overflow = Math.max(0, view.length - shown.length);
|
|
127
|
-
|
|
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) => {
|
|
128
188
|
const num = no === null ? " ".repeat(numW) : padEnd(String(no), numW);
|
|
129
|
-
|
|
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] }));
|
|
130
191
|
};
|
|
131
|
-
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) => {
|
|
132
193
|
if (r.kind === "context") {
|
|
133
|
-
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));
|
|
134
195
|
}
|
|
135
196
|
const leftNumColor = r.left !== null && r.changed ? theme.color.toolError : undefined;
|
|
136
197
|
const rightNumColor = r.right !== null && r.changed ? theme.color.success : undefined;
|
|
137
|
-
return (_jsxs(Text, { children: [r.left !== null
|
|
138
|
-
? renderCell(r.left.no, _jsx(LineBody, { lineText: r.left.text, runs: r.left.runs, base: "del", lang: lang }), { numColor: leftNumColor, dim: !r.changed })
|
|
139
|
-
: renderCell(null, _jsx(Text, { children:
|
|
140
|
-
? renderCell(r.right.no, _jsx(LineBody, { lineText: r.right.text, runs: r.right.runs, base: "add", lang: lang }), { numColor: rightNumColor, dim: !r.changed })
|
|
141
|
-
: 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));
|
|
142
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] }));
|
|
143
204
|
}
|
|
144
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
|
}
|