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.
Files changed (67) hide show
  1. package/CHANGELOG.md +40 -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 +1 -1
  34. package/dist/ui/diff-view.js +13 -5
  35. package/dist/ui/diff.js +67 -0
  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 +7 -5
  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 +81 -22
  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 +8 -5
  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/goals.md +1 -1
  62. package/documentation/index.md +4 -4
  63. package/documentation/providers.md +2 -3
  64. package/documentation/skills.md +3 -3
  65. package/documentation/tools.md +8 -3
  66. package/documentation/troubleshooting.md +1 -1
  67. package/package.json +3 -2
@@ -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 chars = [...s];
35
- if (chars.length <= width)
36
- return s;
69
+ const src = expandTabs(s);
70
+ if (displayWidth(src) <= width)
71
+ return src;
37
72
  if (width < 4)
38
73
  return "";
39
- 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);
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
- const len = [...s].length;
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
- 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));
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
- 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) => {
130
188
  const num = no === null ? " ".repeat(numW) : padEnd(String(no), numW);
131
- 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] }));
132
191
  };
133
- 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) => {
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: padEnd("", contentW) }), { dim: true }), _jsx(Text, { dimColor: true, children: sep }), r.right !== null
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: 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));
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);
@@ -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
  }
@@ -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 quiet annotations (never confused
57
- // with answers): dim label plus the raw reasoning text, verbatim.
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
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [theme.symbol.thinking, " thinking"] }), _jsx(Text, { dimColor: true, children: t.content })] }, i));
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
+ }