atom-agent 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +220 -224
  3. package/dist/App.js +922 -341
  4. package/dist/adapters.js +127 -14
  5. package/dist/agent/goal-evaluator.js +3 -0
  6. package/dist/agent/loop.js +211 -430
  7. package/dist/agent/tool-pipeline.js +398 -0
  8. package/dist/agent/turn-events.js +12 -0
  9. package/dist/cli.js +57 -8
  10. package/dist/compact.js +72 -8
  11. package/dist/config.js +19 -0
  12. package/dist/context-manager.js +6 -2
  13. package/dist/extensions.js +6 -0
  14. package/dist/file-diffs.js +108 -0
  15. package/dist/kilo.js +1 -1
  16. package/dist/local-discovery.js +2 -2
  17. package/dist/media.js +276 -0
  18. package/dist/overflow.js +140 -0
  19. package/dist/policy.js +8 -0
  20. package/dist/scheduler.js +38 -9
  21. package/dist/session-revert.js +125 -0
  22. package/dist/sessions.js +101 -0
  23. package/dist/snapshots.js +69 -0
  24. package/dist/system.js +2 -89
  25. package/dist/telemetry.js +26 -1
  26. package/dist/todos.js +241 -0
  27. package/dist/tools/filesystem.js +102 -22
  28. package/dist/tools/registry.js +184 -45
  29. package/dist/tools/ripgrep.js +7 -6
  30. package/dist/tools/search.js +172 -17
  31. package/dist/tools/shared.js +6 -0
  32. package/dist/tools.js +7 -39
  33. package/dist/ui/diff-panel.js +5 -5
  34. package/dist/ui/diff-view.js +16 -7
  35. package/dist/ui/diff.js +73 -51
  36. package/dist/ui/errors.js +20 -6
  37. package/dist/ui/input.js +24 -20
  38. package/dist/ui/live-tail.js +36 -1
  39. package/dist/ui/markdown.js +9 -4
  40. package/dist/ui/modals.js +6 -4
  41. package/dist/ui/paint-scheduler.js +120 -0
  42. package/dist/ui/palette.js +4 -2
  43. package/dist/ui/pickers.js +4 -1
  44. package/dist/ui/side-by-side.js +88 -27
  45. package/dist/ui/status-bar.js +63 -8
  46. package/dist/ui/stream-store.js +7 -0
  47. package/dist/ui/theme.js +23 -1
  48. package/dist/ui/todo-panel.js +5 -2
  49. package/dist/ui/tool-inspector.js +33 -4
  50. package/dist/ui/transcript.js +9 -6
  51. package/dist/web/events.js +93 -0
  52. package/dist/web/runtime.js +790 -0
  53. package/dist/web/server.js +570 -0
  54. package/dist/web/ui/app.js +1925 -0
  55. package/dist/web/ui/index.html +135 -0
  56. package/dist/web/ui/styles.css +515 -0
  57. package/dist/zen.js +115 -4
  58. package/documentation/cli.md +5 -5
  59. package/documentation/configuration.md +11 -6
  60. package/documentation/development.md +4 -3
  61. package/documentation/extensions.md +1 -1
  62. package/documentation/goals.md +1 -1
  63. package/documentation/index.md +4 -4
  64. package/documentation/providers.md +2 -3
  65. package/documentation/skills.md +3 -3
  66. package/documentation/tools.md +8 -3
  67. package/documentation/troubleshooting.md +1 -1
  68. package/examples/extensions/01-audit-gate.js +2 -2
  69. package/examples/extensions/02-notes-tool.js +2 -2
  70. package/examples/extensions/03-custom-command.js +2 -2
  71. package/package.json +3 -2
@@ -0,0 +1,120 @@
1
+ // Centralized streaming paint scheduler: ONE trailing timer for every live
2
+ // lane (answer draft + thinking).
3
+ //
4
+ // EVENT FREQUENCY != RENDER FREQUENCY: tokens may arrive hundreds per
5
+ // second, but terminal paints coalesce to at most one per interval, always
6
+ // carrying the latest pending text per lane, delivered in a SINGLE onFlush
7
+ // call so draft + thinking land in the same React render instead of
8
+ // fighting across two frames.
9
+ //
10
+ // Rules it enforces:
11
+ // - latest-wins per lane (never queue stale paints behind each other)
12
+ // - leading immediate paint after idle (no typing/stream-start latency)
13
+ // - trailing coalescing inside the window (no per-token renders)
14
+ // - deterministic flush() for turn end, tool transitions, errors, and
15
+ // completion (the final state always paints exactly once)
16
+ // - cancel() drops pending text AND the timer, so a trailing paint can
17
+ // never resurrect stale content after a clear/rollback
18
+ // - timer failure degrades to immediate paint (never lose content)
19
+ //
20
+ // The interval is injected (App passes DRAFT_THROTTLE_MS); the default
21
+ // matches it. For unit tests, now/clock are injectable like the throttler's.
22
+ export const PAINT_INTERVAL_MS = 64;
23
+ export function createPaintScheduler(opts) {
24
+ const intervalMs = opts.intervalMs ?? PAINT_INTERVAL_MS;
25
+ const nowFn = opts.now ?? Date.now;
26
+ const setT = opts.setTimeoutFn ?? setTimeout;
27
+ const clearT = opts.clearTimeoutFn ?? clearTimeout;
28
+ const onFlush = opts.onFlush;
29
+ const pending = new Map();
30
+ let lastFlush = Number.NEGATIVE_INFINITY;
31
+ let timer = null;
32
+ function safeNow() {
33
+ try {
34
+ return nowFn();
35
+ }
36
+ catch {
37
+ return Date.now();
38
+ }
39
+ }
40
+ function clearTimer() {
41
+ if (timer !== null) {
42
+ try {
43
+ clearT(timer);
44
+ }
45
+ catch {
46
+ // ignore (a stray trailing paint is harmless — flush() already ran)
47
+ }
48
+ timer = null;
49
+ }
50
+ }
51
+ // Single paint path (also the never-lose-content fallback).
52
+ function emit(at) {
53
+ if (pending.size === 0) {
54
+ clearTimer();
55
+ return;
56
+ }
57
+ clearTimer();
58
+ const lanes = {};
59
+ const draft = pending.get("draft");
60
+ const thinking = pending.get("thinking");
61
+ if (draft !== undefined)
62
+ lanes.draft = draft;
63
+ if (thinking !== undefined)
64
+ lanes.thinking = thinking;
65
+ pending.clear();
66
+ lastFlush = at;
67
+ onFlush(lanes);
68
+ }
69
+ return {
70
+ push(lane, text) {
71
+ pending.set(lane, text);
72
+ const t = safeNow();
73
+ if (t - lastFlush >= intervalMs) {
74
+ emit(t);
75
+ return;
76
+ }
77
+ if (timer !== null)
78
+ return; // trailing paint already scheduled
79
+ const wait = intervalMs - (t - lastFlush);
80
+ try {
81
+ timer = setT(() => {
82
+ timer = null;
83
+ emit(safeNow());
84
+ }, Math.max(0, wait));
85
+ }
86
+ catch {
87
+ // No timer available: paint now rather than lose the token.
88
+ emit(safeNow());
89
+ }
90
+ },
91
+ flush() {
92
+ if (pending.size === 0) {
93
+ clearTimer();
94
+ return;
95
+ }
96
+ emit(safeNow());
97
+ },
98
+ cancel(lane) {
99
+ if (lane === undefined) {
100
+ pending.clear();
101
+ }
102
+ else {
103
+ pending.delete(lane);
104
+ }
105
+ if (pending.size === 0)
106
+ clearTimer();
107
+ },
108
+ reset() {
109
+ pending.clear();
110
+ clearTimer();
111
+ lastFlush = Number.NEGATIVE_INFINITY;
112
+ },
113
+ getPending(lane) {
114
+ return pending.get(lane) ?? null;
115
+ },
116
+ pendingTimers() {
117
+ return timer === null ? 0 : 1;
118
+ },
119
+ };
120
+ }
@@ -30,7 +30,6 @@ const PALETTE_CATEGORIES = {
30
30
  "/allow": "Tools",
31
31
  "/deny": "Tools",
32
32
  "/rules": "Tools",
33
- "/skills": "Skills",
34
33
  "/skill": "Skills",
35
34
  "/queue": "Flow",
36
35
  "/steer": "Flow",
@@ -68,5 +67,8 @@ export const PalettePanel = React.memo(function PalettePanel({ entries, index, f
68
67
  }
69
68
  rows.push(_jsxs(Text, { color: i === hi ? theme.color.menuSelection : undefined, children: [i === hi ? `${theme.symbol.select} ` : theme.spacing.rowIndent, e.name, e.description ? ` ${theme.symbol.descSeparator} ${e.description}` : "", e.hint ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", e.hint] }) : null] }, `${e.name}-${i}`));
70
69
  });
71
- return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.menu, paddingX: theme.spacing.pickerPadX, children: [_jsx(Text, { bold: true, children: "Search commands \u2014 type to filter (\u2191/\u2193 + Enter to run, Esc closes):" }), _jsxs(Text, { children: [_jsxs(Text, { color: theme.color.inputPrompt, bold: true, children: [theme.symbol.inputPrompt, " "] }), filter, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), _jsx(PickerMoreAbove, { count: win.start }), rows, _jsx(PickerMoreBelow, { count: entries.length - win.end }), entries.length === 0 ? _jsx(Text, { dimColor: true, children: "No commands match \u2014 backspace to widen." }) : null] }));
70
+ return (
71
+ // flexShrink=0: footer-cluster anchoring (ticket 05) — same contract as
72
+ // PickerShell; the list truncates via pickerWindow instead of squeezing.
73
+ _jsxs(Box, { flexDirection: "column", flexShrink: 0, borderStyle: theme.border.style, borderColor: theme.border.menu, paddingX: theme.spacing.pickerPadX, children: [_jsx(Text, { bold: true, children: "Search commands \u2014 type to filter (\u2191/\u2193 + Enter to run, Esc closes):" }), _jsxs(Text, { children: [_jsxs(Text, { color: theme.color.inputPrompt, bold: true, children: [theme.symbol.inputPrompt, " "] }), filter, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), _jsx(PickerMoreAbove, { count: win.start }), rows, _jsx(PickerMoreBelow, { count: entries.length - win.end }), entries.length === 0 ? _jsx(Text, { dimColor: true, children: "No commands match \u2014 backspace to widen." }) : null] }));
72
74
  });
@@ -2,7 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { theme } from "./theme.js";
4
4
  export function PickerShell({ title, borderColor = theme.border.picker, children, }) {
5
- return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: borderColor, paddingX: theme.spacing.pickerPadX, children: [_jsx(Text, { bold: true, children: title }), children] }));
5
+ return (
6
+ // flexShrink=0: footer-cluster anchoring (ticket 05) — a tall list never
7
+ // squeezes when the terminal runs short; it truncates via pickerWindow.
8
+ _jsxs(Box, { flexDirection: "column", flexShrink: 0, borderStyle: theme.border.style, borderColor: borderColor, paddingX: theme.spacing.pickerPadX, children: [_jsx(Text, { bold: true, children: title }), children] }));
6
9
  }
7
10
  export function PickerMoreAbove({ count }) {
8
11
  if (count <= 0)
@@ -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
- // capped (maxRows + trailer) never recomputed per tick, never floods.
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
- // Committed-transcript cap (rows, context + change): the scrollback shows
28
- // the reviewable head; the file on disk is the whole truth. The engine
29
- // still truncates past 400 changed lines with its own notice.
30
- export const TRANSCRIPT_DIFF_MAX_LINES = 120;
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 chars = [...s];
33
- if (chars.length <= width)
34
- return s;
69
+ const src = expandTabs(s);
70
+ if (displayWidth(src) <= width)
71
+ return src;
35
72
  if (width < 4)
36
73
  return "";
37
- return chars.slice(0, width - 1).join("") + theme.symbol.ellipsis;
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
- const len = [...s].length;
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
- const paneW = Math.max(20, Math.floor((totalW - sep.length) / 2));
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
- const renderCell = (no, body, opts) => {
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
- return (_jsxs(Text, { children: [_jsxs(Text, { color: opts.numColor, dimColor: opts.numColor === undefined || opts.dim, children: [num, " "] }), body] }));
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: [_jsxs(Text, { dimColor: true, children: [sbs.isNewFile ? "new file " : "", "+", sbs.adds, " \u2212", sbs.dels] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, children: padEnd("BEFORE", paneW) }), _jsx(Text, { dimColor: true, children: sep }), _jsx(Text, { bold: true, children: "AFTER" })] }), shown.map((r, k) => {
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: padEnd("", contentW) }), { dim: true }), _jsx(Text, { dimColor: true, children: sep }), r.right !== null
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: padEnd("", contentW) }), { dim: true })] }, k));
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);
@@ -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 = formatTokenSegment(usageTotals, model, contextLoad);
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 (_jsx(Box, { marginTop: theme.spacing.statusMarginTop, 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, goalSeg ? (_jsxs(_Fragment, { children: [" ", bar, " ", goalSeg] })) : null] }) }));
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 = formatTokenSegment(usageTotals, model, contextLoad);
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 (_jsx(Box, { marginTop: theme.spacing.statusMarginTop, 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] }) }));
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
  });
@@ -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
- // - No decorative boxes: only the input, pickers, and modals are framed.
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
@@ -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". Returns null when empty.
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", borderStyle: theme.border.style, borderColor: theme.border.panel, paddingX: theme.spacing.pickerPadX, marginTop: theme.spacing.turnGap, children: [_jsxs(Text, { bold: true, children: ["Tasks ", done, "/", items.length] }), items.map((t, i) => {
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 { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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: [_jsx(Text, { bold: true, children: "Tool outputs \u2014 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) => {
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
- return (_jsxs(Text, { color: hi ? theme.color.selection : r.isError ? theme.color.toolError : undefined, children: [hi ? `${theme.symbol.select} ` : theme.spacing.rowIndent, r.isError ? "✕ " : "", r.label, r.ms >= 2000 ? formatDur(r.ms) : ""] }, r.id));
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
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [_jsx(Text, { color: rec.isError ? theme.color.toolError : theme.color.success, children: rec.isError ? "✕ " : "✓ " }), 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` : ""] })] }));
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
  }