atom-agent 0.3.0 → 1.1.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 (61) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +83 -32
  3. package/dist/App.js +2178 -318
  4. package/dist/adapters.js +146 -15
  5. package/dist/agent/gates.js +153 -0
  6. package/dist/agent/loop-guard.js +184 -0
  7. package/dist/agent/loop.js +908 -0
  8. package/dist/agent/normalize.js +144 -0
  9. package/dist/agent/types.js +1 -0
  10. package/dist/auth.js +2 -1
  11. package/dist/cli.js +68 -6
  12. package/dist/compact.js +6 -48
  13. package/dist/config.js +171 -0
  14. package/dist/context-manager.js +564 -0
  15. package/dist/kilo.js +343 -0
  16. package/dist/local-discovery.js +308 -0
  17. package/dist/policy.js +286 -0
  18. package/dist/prompt-cache.js +99 -0
  19. package/dist/providers.js +183 -2
  20. package/dist/rollback.js +21 -0
  21. package/dist/scheduler.js +247 -0
  22. package/dist/session.js +35 -3
  23. package/dist/skills.js +214 -43
  24. package/dist/snapshots.js +57 -2
  25. package/dist/system.js +8 -1
  26. package/dist/telemetry-dashboard.js +589 -0
  27. package/dist/telemetry-server.js +301 -0
  28. package/dist/telemetry.js +1056 -0
  29. package/dist/tools/dir-cache.js +207 -0
  30. package/dist/tools/filesystem.js +149 -0
  31. package/dist/tools/fingerprints.js +33 -0
  32. package/dist/tools/overflow.js +76 -0
  33. package/dist/tools/read-cache.js +160 -0
  34. package/dist/tools/registry.js +802 -0
  35. package/dist/tools/search.js +242 -0
  36. package/dist/tools/shared.js +31 -0
  37. package/dist/tools/shell.js +273 -0
  38. package/dist/tools/todo.js +191 -0
  39. package/dist/tools/web.js +454 -0
  40. package/dist/tools.js +17 -1863
  41. package/dist/ui/activity.js +51 -0
  42. package/dist/ui/diff-panel.js +55 -0
  43. package/dist/ui/diff-view.js +112 -0
  44. package/dist/ui/diff.js +422 -0
  45. package/dist/ui/errors.js +129 -0
  46. package/dist/ui/highlight.js +120 -0
  47. package/dist/ui/input-model.js +115 -0
  48. package/dist/ui/input.js +40 -0
  49. package/dist/ui/live-tail.js +15 -0
  50. package/dist/ui/markdown.js +525 -0
  51. package/dist/ui/modals.js +47 -0
  52. package/dist/ui/palette.js +70 -0
  53. package/dist/ui/pickers.js +32 -0
  54. package/dist/ui/side-by-side.js +144 -0
  55. package/dist/ui/status-bar.js +75 -0
  56. package/dist/ui/theme.js +128 -0
  57. package/dist/ui/todo-panel.js +30 -0
  58. package/dist/ui/tool-inspector.js +59 -0
  59. package/dist/ui/transcript.js +128 -0
  60. package/dist/zen.js +145 -666
  61. package/package.json +1 -1
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { theme } from "./theme.js";
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] }));
6
+ }
7
+ export function PickerMoreAbove({ count }) {
8
+ if (count <= 0)
9
+ return null;
10
+ return (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " ", count, " more"] }));
11
+ }
12
+ export function PickerMoreBelow({ count }) {
13
+ if (count <= 0)
14
+ return null;
15
+ return (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreBelow, " ", count, " more"] }));
16
+ }
17
+ // Visible window for a picker: at most `visible` rows, scrolled so the
18
+ // highlight stays visible (centered while scrolling, pinned at both ends).
19
+ // Pure — the frame never grows past the window no matter how many rows
20
+ // the list holds. Shared by every windowed popup (pickers, slash menu,
21
+ // palette).
22
+ export const MODEL_PICKER_VISIBLE = 10;
23
+ export function pickerWindow(total, highlight, visible = MODEL_PICKER_VISIBLE) {
24
+ if (total <= visible)
25
+ return { start: 0, end: total };
26
+ const h = Math.max(0, Math.min(highlight, total - 1));
27
+ const start = Math.max(0, Math.min(h - Math.floor(visible / 2), total - visible));
28
+ return { start, end: start + visible };
29
+ }
30
+ export function PickerRow({ highlighted, highlightColor = theme.color.selection, children, }) {
31
+ return (_jsxs(Text, { color: highlighted ? highlightColor : undefined, children: [highlighted ? `${theme.symbol.select} ` : theme.spacing.rowIndent, children] }));
32
+ }
@@ -0,0 +1,144 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Side-by-side diff view for write/edit results: BEFORE pane (left) vs
3
+ // AFTER pane (right), aligned rows, preserved line numbers.
4
+ //
5
+ // Requirements it answers (presentation only — data comes from the
6
+ // existing approve-time preview / commit slot, tool behavior untouched):
7
+ // - left = original, right = result; additions right, removals left,
8
+ // context on both sides; paired changes share ONE row so corresponding
9
+ // lines align; changed regions pop via the existing word-background +
10
+ // add/del line-number treatment; syntax colors reused per cell.
11
+ // - hunks only (configurable context in the engine) — never whole files.
12
+ // - width-aware: panes split the measured terminal (useStdout, same
13
+ // pattern as App's termColumns); long lines truncate per pane with …
14
+ // (code-point safe); below NARROW_COLUMNS the view degrades to the
15
+ // stacked unified DiffView instead of destroying the layout.
16
+ // - computed once per mount (useMemo, keyed on inputs + pane width) and
17
+ // capped (maxRows + trailer) — never recomputed per tick, never floods.
18
+ // All paint comes from ui/theme tokens.
19
+ import React from "react";
20
+ import { Box, Text } from "ink";
21
+ import { useStdout } from "ink";
22
+ import { computeSideBySide, wordRuns } from "./diff.js";
23
+ import { DiffView, LineBody } from "./diff-view.js";
24
+ import { theme } from "./theme.js";
25
+ // Below this width two panes cannot breathe — stack unified instead.
26
+ 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;
31
+ function truncateTo(s, width) {
32
+ const chars = [...s];
33
+ if (chars.length <= width)
34
+ return s;
35
+ if (width < 4)
36
+ return "";
37
+ return chars.slice(0, width - 1).join("") + theme.symbol.ellipsis;
38
+ }
39
+ // Fit engine rows to a pane content width: truncate cell texts (with …)
40
+ // and re-derive word runs on the truncated pair so offsets always tile
41
+ // the displayed text. Runs once per mount/width — never per tick.
42
+ function fitRows(rows, contentW) {
43
+ return rows.map((r) => {
44
+ if (r.kind === "context") {
45
+ return {
46
+ kind: "context",
47
+ left: { no: r.oldNo, text: truncateTo(r.text, contentW) },
48
+ right: { no: r.newNo, text: truncateTo(r.text, contentW) },
49
+ };
50
+ }
51
+ const tOld = r.oldText !== null ? truncateTo(r.oldText, contentW) : null;
52
+ const tNew = r.newText !== null ? truncateTo(r.newText, contentW) : null;
53
+ let oldRuns = [];
54
+ let newRuns = [];
55
+ if (tOld !== null && tNew !== null) {
56
+ const w = wordRuns(tOld, tNew);
57
+ oldRuns = w.del;
58
+ newRuns = w.add;
59
+ }
60
+ else if (tOld !== null) {
61
+ oldRuns = tOld === "" ? [] : [{ text: tOld, changed: false }];
62
+ }
63
+ else if (tNew !== null) {
64
+ newRuns = tNew === "" ? [] : [{ text: tNew, changed: false }];
65
+ }
66
+ const changed = oldRuns.some((x) => x.changed) ||
67
+ newRuns.some((x) => x.changed) ||
68
+ (r.oldText === null) !== (r.newText === null);
69
+ return {
70
+ kind: "change",
71
+ changed,
72
+ left: tOld !== null ? { no: r.oldNo, text: tOld, runs: oldRuns } : null,
73
+ right: tNew !== null ? { no: r.newNo, text: tNew, runs: newRuns } : null,
74
+ };
75
+ });
76
+ }
77
+ function padEnd(s, width) {
78
+ const len = [...s].length;
79
+ if (len >= width)
80
+ return s;
81
+ return s + " ".repeat(width - len);
82
+ }
83
+ function SideBySideInner({ oldText, newText, lang = null, maxRows = Infinity, columns, }) {
84
+ let stdoutCols;
85
+ try {
86
+ stdoutCols = useStdout()?.stdout?.columns;
87
+ }
88
+ catch {
89
+ stdoutCols = undefined;
90
+ }
91
+ const totalW = columns ?? stdoutCols ?? 100;
92
+ const sbs = React.useMemo(() => computeSideBySide(oldText, newText), [oldText, newText]);
93
+ if (sbs.kind === "same") {
94
+ return _jsx(Text, { dimColor: true, children: "(no changes \u2014 file unchanged)" });
95
+ }
96
+ if (sbs.kind === "binary") {
97
+ return _jsx(Text, { dimColor: true, children: "binary file changed" });
98
+ }
99
+ if (sbs.kind === "skipped") {
100
+ return _jsx(Text, { dimColor: true, children: sbs.reason });
101
+ }
102
+ // Graceful narrow-terminal degrade: stacked unified keeps every char
103
+ // instead of crushing two panes into unreadable slivers.
104
+ if (totalW < SBS_NARROW_COLUMNS) {
105
+ return _jsx(DiffView, { oldText: oldText, newText: newText, lang: lang, maxLines: maxRows });
106
+ }
107
+ const sep = ` ${theme.symbol.bar} `;
108
+ const paneW = Math.max(20, Math.floor((totalW - sep.length) / 2));
109
+ let maxNo = 0;
110
+ for (const r of sbs.rows) {
111
+ if (r.kind === "context")
112
+ maxNo = Math.max(maxNo, r.oldNo, r.newNo);
113
+ else {
114
+ if (r.oldNo !== null)
115
+ maxNo = Math.max(maxNo, r.oldNo);
116
+ if (r.newNo !== null)
117
+ maxNo = Math.max(maxNo, r.newNo);
118
+ }
119
+ }
120
+ const numW = String(Math.max(maxNo, 1)).length;
121
+ const contentW = Math.max(8, paneW - numW - 1);
122
+ const view = React.useMemo(() => fitRows(sbs.rows, contentW),
123
+ // eslint-disable-next-line react-hooks/exhaustive-deps
124
+ [sbs, contentW]);
125
+ const shown = view.slice(0, maxRows);
126
+ const overflow = Math.max(0, view.length - shown.length);
127
+ const renderCell = (no, body, opts) => {
128
+ 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] }));
130
+ };
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) => {
132
+ 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));
134
+ }
135
+ const leftNumColor = r.left !== null && r.changed ? theme.color.toolError : undefined;
136
+ 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));
142
+ }), 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
+ }
144
+ export const SideBySideDiffView = React.memo(SideBySideInner);
@@ -0,0 +1,75 @@
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.
3
+ // - idle: provider/model │ token │ cwd[:branch] │ reasoning │ mode.
4
+ // Labels are positional (no `provider:` prefixes); mode/trust show always
5
+ // (pinned), cwd shortens, branch only for git repos.
6
+ // - busy: activity │ elapsed │ token │ reasoning │ mode │ esc-hint (+waiting/approval flags).
7
+ // Provider/model/cwd drop while working — the activity, the
8
+ // clock, context pressure, effort, and the pinned mode are what matter mid-turn.
9
+ // All paint comes from ui/theme tokens. The token segment formatter lives
10
+ // in context-windows (its only surface).
11
+ import React from "react";
12
+ import { Box, Text } from "ink";
13
+ import { formatTokenSegment } from "../context-windows.js";
14
+ import { theme } from "./theme.js";
15
+ // ~/… collapse + tail-cut: informative, never a full scroll of nesting.
16
+ // Further shrinking for tight widths goes through shrinkTo below (the bar
17
+ // measures first and only renders what fits).
18
+ export function shortenCwd(cwd, home, max = 20) {
19
+ const short = home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
20
+ if (short.length <= max)
21
+ return short;
22
+ return `…/${short.slice(-(max - 3))}`;
23
+ }
24
+ // Shrink text to n chars max for tight widths (`…/tail` keeps the
25
+ // meaningful end). n < 4 yields "" (caller drops the segment instead).
26
+ export function shrinkTo(s, n) {
27
+ if (s.length <= n)
28
+ return s;
29
+ if (n < 4)
30
+ return "";
31
+ return `…/${s.slice(-(n - 3))}`;
32
+ }
33
+ // Render-count probe for the flicker tests: incremented on every StatusBar
34
+ // render (same-props parent churn — token paints, keystrokes, unrelated
35
+ // ticks — must skip it; only changed props repaint).
36
+ export const statusBarRenderProbe = { count: 0 };
37
+ export const StatusBar = React.memo(function StatusBar({ provider, model, usageTotals, contextLoad, reasoningDisplay, mode, trustAll, busy, activity, phaseLabel, elapsedSecs, stalled, approvalPending, cwd, branch, columns = 100, }) {
38
+ statusBarRenderProbe.count += 1;
39
+ const bar = theme.symbol.bar;
40
+ if (!busy) {
41
+ const token = formatTokenSegment(usageTotals, model, contextLoad);
42
+ const trust = trustAll && mode !== "plan" ? "+trust" : "";
43
+ // Measure-first layout: the location (cwd + branch) flexes so the whole
44
+ // line always fits `columns`. Fixed segments never shrink (wrapping
45
+ // would split `mode: X` needles across lines); the location yields in
46
+ // order: branch → cwd tail → the whole segment.
47
+ const tail = `reasoning: ${reasoningDisplay} ${bar} mode: ${mode}${trust}`;
48
+ const baseLen = `${provider}/${model} ${bar} ${token} ${bar} ${bar} ${tail}`.length;
49
+ const avail = columns - baseLen;
50
+ let loc = null;
51
+ if (cwd) {
52
+ const branchPart = branch ? ` : ${branch}` : "";
53
+ if (3 + cwd.length + branchPart.length <= avail) {
54
+ loc = `${cwd}${branchPart}`;
55
+ }
56
+ else if (3 + cwd.length <= avail) {
57
+ loc = cwd;
58
+ }
59
+ else {
60
+ const shrunk = shrinkTo(cwd, avail - 3);
61
+ loc = shrunk ? shrunk : null;
62
+ }
63
+ }
64
+ return (_jsx(Box, { marginTop: theme.spacing.statusMarginTop, children: _jsxs(Text, { dimColor: true, children: [provider, "/", model, " ", bar, " ", token, loc ? (_jsxs(_Fragment, { children: [" ", bar, " ", loc] })) : null, " ", bar, " reasoning: ", reasoningDisplay, " ", bar, " mode: ", mode, trust ? "+trust" : null] }) }));
65
+ }
66
+ // Busy layout prioritizes activity + clock + interrupt hint; the mode
67
+ // stays pinned (it used to vanish while working), and the reasoning
68
+ // effort stays visible (it used to vanish while working). The activity text
69
+ // shrinks to fit so `esc stops` never wraps away.
70
+ const busyTrust = trustAll && mode !== "plan" ? "+trust" : "";
71
+ const busyFixed = ` ${bar} ${elapsedSecs}s ${bar} ${formatTokenSegment(usageTotals, model, contextLoad)} ${bar} reasoning: ${reasoningDisplay} ${bar} mode: ${mode}${busyTrust} ${bar} esc stops`;
72
+ const busyAvail = columns - busyFixed.length - 2;
73
+ const activityText = shrinkTo(activity ?? phaseLabel, Math.max(0, busyAvail));
74
+ 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] }) }));
75
+ });
@@ -0,0 +1,128 @@
1
+ // ATOM TUI design tokens: the single source of visual truth.
2
+ //
3
+ // Every color, glyph, separator, border, and spacing value in the interface
4
+ // lives here. Components reference these tokens — never string literals —
5
+ // so the whole TUI can be re-skinned by editing this file alone, and later
6
+ // polish chunks change values here instead of hunting call sites.
7
+ //
8
+ // Density rules (terminal space is scarce):
9
+ // - No decorative boxes: only the input, pickers, and modals are framed.
10
+ // - The transcript is frameless text; hierarchy comes from speaker labels,
11
+ // dimming, and one blank line between turns — never extra chrome.
12
+ // - `muted` is implemented as Ink `dimColor` (terminal-dimmed default fg),
13
+ // NOT as gray paint: it adapts to light/dark terminals. Literal gray
14
+ // (`color.mutedPaint`) is reserved for block glyphs (cursors) that need a
15
+ // fixed shade to read as a shape.
16
+ //
17
+ // Identities (ATOM's own, not borrowed):
18
+ // - Speaker labels: `you>` (cyan) vs `ATOM>` (magenta).
19
+ // - Selection marker: `❯` + highlight color; unselected rows indent two
20
+ // spaces so lists align without bullets.
21
+ // - Live activity: `◌` (tool running), `💭` (thinking), `▍`/`█` (cursors).
22
+ // - Status segments join with `·`; name/description rows join with `—`.
23
+ export const theme = {
24
+ color: {
25
+ // Base surfaces: inherit the terminal (no paint) — the TUI never sets a
26
+ // background, so light and dark terminals both work.
27
+ background: undefined,
28
+ foreground: undefined,
29
+ // Primary reading text: terminal default, emphasized with bold (titles,
30
+ // speaker labels), never with a hue.
31
+ primary: undefined,
32
+ // Secondary/muted text: Ink dimColor mechanism (see note above).
33
+ // `mutedPaint` is the fixed gray reserved for cursor glyphs.
34
+ mutedPaint: "gray",
35
+ // Interactive selection (picker rows, approval highlight).
36
+ selection: "green",
37
+ menuSelection: "cyan",
38
+ questionSelection: "magenta",
39
+ // Speaker identities.
40
+ user: "cyan",
41
+ assistant: "magenta",
42
+ inputPrompt: "cyan",
43
+ // Live activity (busy phase segment in the status bar).
44
+ activity: "yellow",
45
+ // Tool transcript lines: dim default text; red only on failure.
46
+ tool: undefined,
47
+ toolError: "red",
48
+ // Outcomes + permission surfaces.
49
+ success: "green",
50
+ warning: "yellow",
51
+ error: "red",
52
+ permission: "yellow",
53
+ // Reserved for the streaming-markdown chunk: code frames + links + headings.
54
+ code: "green",
55
+ link: "cyan",
56
+ heading: undefined, // bold, no hue
57
+ // Diff-body syntax colors (ui/highlight): Monokai-ish hues that read
58
+ // on dark and light terminals. Comments stay dim (no hue — same rule
59
+ // as muted text); plain code inherits the line paint.
60
+ synKeyword: "magenta",
61
+ synString: "yellow",
62
+ synNumber: "cyan",
63
+ },
64
+ border: {
65
+ style: "round",
66
+ input: "gray",
67
+ picker: "green",
68
+ menu: "cyan",
69
+ panel: "cyan",
70
+ approval: "yellow",
71
+ question: "magenta",
72
+ },
73
+ spacing: {
74
+ // Unselected picker rows indent to align with `❯ ` selected rows.
75
+ rowIndent: " ",
76
+ // Code-block body indent (no boxes around code — indentation only).
77
+ codeIndent: " ",
78
+ pickerPadX: 1,
79
+ // Breathing room: one blank line after each committed turn; the live
80
+ // tail floats with vertical margin; the status bar sits one line below.
81
+ turnGap: 1,
82
+ liveTailMarginY: 1,
83
+ statusMarginTop: 1,
84
+ },
85
+ symbol: {
86
+ select: "❯",
87
+ bullet: "•",
88
+ quoteBar: "│",
89
+ moreAbove: "↑",
90
+ moreBelow: "↓",
91
+ separator: "·",
92
+ descSeparator: "—",
93
+ // Status-bar segment divider (structural, quiet). Inline joins elsewhere
94
+ // keep `·`.
95
+ bar: "│",
96
+ // Expanded-view divider unit (repeated for the rule line — a divider,
97
+ // never a frame).
98
+ rule: "─",
99
+ ellipsis: "…",
100
+ running: "◌",
101
+ thinking: "💭",
102
+ // Working-state glyphs: open circle = unsettled (thinking), filled
103
+ // circle = engaged (tool executing). Stable by design — liveness reads
104
+ // from ticking elapsed seconds, not animation (see ui/activity).
105
+ workThinking: "◐",
106
+ workTool: "◉",
107
+ // Attention marker for permission + warning surfaces (mirrors the
108
+ // loop's `⚠ ` warning prefix).
109
+ warningMark: "⚠",
110
+ // The loop's tool-audit marker: committed call rows keep `⚙ name target`
111
+ // byte-identical (tests + help pin the text); ToolLine keys its slow-run
112
+ // suffix off this same marker.
113
+ toolMark: "⚙",
114
+ cursorBar: "▍",
115
+ cursorBlock: "█",
116
+ inputPrompt: "›",
117
+ keyMask: "•",
118
+ keyPresent: "✓",
119
+ taskDone: "✅",
120
+ taskActive: "🔧",
121
+ // Pending means "not started yet" — an open circle (never ❌, which
122
+ // reads as failed/denied). Matches the circle language of the
123
+ // working-state glyphs (◌ unsettled / ◉ engaged).
124
+ taskPending: "○",
125
+ speakerUser: "you>",
126
+ speakerAssistant: "ATOM>",
127
+ },
128
+ };
@@ -0,0 +1,30 @@
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Live session checklist panel (TodoWrite mirror). Prop-driven; returns
3
+ // null when empty. Mounted below the transcript, fed by a checklist snapshot.
4
+ // Paint from ui/theme tokens — no literal colors or glyphs here.
5
+ import React from "react";
6
+ import { Box, Text } from "ink";
7
+ import { theme } from "./theme.js";
8
+ // Live session checklist (Claude-Code-style TodoWrite panel). Mounted in
9
+ // the live area below the transcript (NOT in <Static> scrollback) and fed
10
+ // by a snapshot the loop refreshes after every todowrite/todo_update call,
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.
13
+ // Render-count probe for the flicker tests: same-props parent churn must
14
+ // skip the panel (it only changes when the loop commits todo activity).
15
+ export const todoPanelRenderProbe = { count: 0 };
16
+ export const TodoPanel = React.memo(function TodoPanel({ items }) {
17
+ todoPanelRenderProbe.count += 1;
18
+ if (items.length === 0)
19
+ return null;
20
+ 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) => {
22
+ const mark = t.status === "completed"
23
+ ? theme.symbol.taskDone
24
+ : t.status === "in_progress"
25
+ ? theme.symbol.taskActive
26
+ : theme.symbol.taskPending;
27
+ const label = t.status === "in_progress" && t.activeForm ? t.activeForm : t.content;
28
+ return (_jsxs(Text, { dimColor: t.status === "completed", children: [mark, " ", label, t.priority ? ` (${t.priority})` : ""] }, `${i}-${t.content}`));
29
+ })] }));
30
+ });
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { theme } from "./theme.js";
4
+ export const MAX_TOOL_RECORDS = 50;
5
+ export const STORE_CHARS = 32768;
6
+ export const VIEWPORT_LINES = 20;
7
+ export const LIST_WINDOW = 15;
8
+ export function createToolRecord(id, label, result, isError, ms) {
9
+ const text = result ?? "";
10
+ const truncated = text.length > STORE_CHARS;
11
+ const stored = truncated ? text.slice(0, STORE_CHARS) : text;
12
+ return {
13
+ id,
14
+ label,
15
+ result: stored,
16
+ truncated,
17
+ isError,
18
+ ms,
19
+ lineCount: stored.length === 0 ? 0 : stored.split("\n").length,
20
+ };
21
+ }
22
+ // Centered window over a list: {slice, start, above, below}. Pure — the
23
+ // panel and its tests share it.
24
+ export function windowedList(items, index, size) {
25
+ if (items.length <= size)
26
+ return { slice: items, start: 0, above: 0, below: 0 };
27
+ const half = Math.floor(size / 2);
28
+ let start = Math.max(0, Math.min(index - half, items.length - size));
29
+ return {
30
+ slice: items.slice(start, start + size),
31
+ start,
32
+ above: start,
33
+ below: items.length - (start + size),
34
+ };
35
+ }
36
+ function formatDur(ms) {
37
+ return ` ${theme.symbol.separator} ${Math.max(1, Math.round(ms / 1000))}s`;
38
+ }
39
+ export function InspectorPanel({ records, index, expanded, scroll }) {
40
+ const sel = Math.max(0, Math.min(index, records.length - 1));
41
+ const rec = records[sel];
42
+ if (!rec)
43
+ return null;
44
+ if (!expanded) {
45
+ const win = windowedList(records, sel, LIST_WINDOW);
46
+ 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) => {
47
+ const i = win.start + k;
48
+ const hi = i === sel;
49
+ 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));
50
+ }), 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"] })] }));
51
+ }
52
+ const lines = rec.result.split("\n");
53
+ const total = lines.length;
54
+ const maxOffset = Math.max(0, total - VIEWPORT_LINES);
55
+ const off = Math.max(0, Math.min(scroll, maxOffset));
56
+ const view = lines.slice(off, off + VIEWPORT_LINES);
57
+ const rule = theme.symbol.rule.repeat(32);
58
+ 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` : ""] })] }));
59
+ }
@@ -0,0 +1,128 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Transcript leaves: the committed <Static> scrollback, its item renderer,
3
+ // and the startup banner. Prop-driven + memoized (see comments) so App state
4
+ // churn never repaints them. Turn is the display-transcript entry shape.
5
+ // All paint comes from ui/theme tokens — no literal colors or glyphs here.
6
+ import React from "react";
7
+ import { Box, Text } from "ink";
8
+ import { SideBySideDiffView, TRANSCRIPT_DIFF_MAX_LINES } from "./side-by-side.js";
9
+ import { ErrorCard, classifyToolError } from "./errors.js";
10
+ import { MarkdownText, ToolLine } from "./markdown.js";
11
+ import { theme } from "./theme.js";
12
+ // Scrollback viewport: the committed transcript renders as a windowed
13
+ // slice of turns in a live Box (NOT <Static> — Static is append-only with
14
+ // no scroll API, so PgUp/Home/follow modes are impossible on it).
15
+ //
16
+ // Model: E = viewed end index (items visible: (E-WIN, E]). E === turns.length
17
+ // means follow mode — new turns extend the view automatically. Any E < len
18
+ // is manual mode: the view freezes while new turns accumulate below, and a
19
+ // `↓ N new` indicator offers the jump back. Clamping makes list replacement
20
+ // (/clear, /resume, /new) re-follow for free (E > len collapses to len).
21
+ // Banner shows only when the window touches the top.
22
+ export const SCROLLBACK_WINDOW = 300;
23
+ export const SCROLL_PAGE_ITEMS = 10;
24
+ export function resolveViewport(len, end, win = SCROLLBACK_WINDOW) {
25
+ const e = Math.max(0, Math.min(end ?? len, len));
26
+ const follow = e >= len;
27
+ return { start: Math.max(0, e - win), end: e, pending: len - e, follow };
28
+ }
29
+ export function applyScrollAction(end, len, action) {
30
+ const e = end ?? len;
31
+ switch (action.kind) {
32
+ case "pageUp":
33
+ // Short sessions (everything fits the window) have no window to move:
34
+ // freeze at the bottom instead of no-op-ing, so PgUp always engages
35
+ // the held view (live output stops growing; the terminal stops
36
+ // yanking). Long sessions move the window up a page, as before.
37
+ if (len <= SCROLLBACK_WINDOW)
38
+ return len;
39
+ return Math.max(Math.min(len, SCROLLBACK_WINDOW), e - SCROLL_PAGE_ITEMS);
40
+ case "pageDown": {
41
+ const next = Math.min(len, e + SCROLL_PAGE_ITEMS);
42
+ return next >= len ? null : next;
43
+ }
44
+ case "home":
45
+ return Math.min(len, SCROLLBACK_WINDOW);
46
+ case "end":
47
+ return null;
48
+ }
49
+ }
50
+ function isAuditLabel(t) {
51
+ return t.role === "tool" && !t.error && t.content.startsWith(`${theme.symbol.toolMark} `);
52
+ }
53
+ export function renderTranscriptItem(item) {
54
+ if (!item.turn)
55
+ return _jsx(StartupBanner, {}, item.id);
56
+ const t = item.turn;
57
+ const i = item.id;
58
+ // Committed thinking blocks read as quiet annotations (never confused
59
+ // with answers): dim label plus the raw reasoning text, verbatim.
60
+ if (t.thinking === true) {
61
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [theme.symbol.thinking, " thinking"] }), _jsx(Text, { dimColor: true, children: t.content })] }, i));
62
+ }
63
+ // Conversation turns (user/assistant) breathe: one blank line after each,
64
+ // so the eye lands on the next turn. Tool/status lines stay dense — they
65
+ // read as lightweight annotations woven between turns, not blocks.
66
+ if (t.role === "user") {
67
+ return (_jsx(Box, { flexDirection: "column", marginBottom: theme.spacing.turnGap, children: _jsxs(Text, { children: [_jsxs(Text, { color: theme.color.user, bold: true, children: [theme.symbol.speakerUser, " "] }), t.content] }) }, i));
68
+ }
69
+ if (t.role === "tool") {
70
+ const classified = classifyToolError(t, item.label ?? null);
71
+ if (classified) {
72
+ // Paired cards keep the verbatim audit line above the card (pinned
73
+ // `⚙ name target` text for tests/scanning) and name the failure in
74
+ // the card title. Lone details render the card alone. A successful
75
+ // write/edit label swallowed by pairing (success line immediately
76
+ // followed by an error line) keeps its committed diff above the card.
77
+ const labelDiff = item.label?.diff;
78
+ 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, maxRows: TRANSCRIPT_DIFF_MAX_LINES })) : null, _jsx(ErrorCard, { classified: classified })] }, i));
79
+ }
80
+ 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, maxRows: TRANSCRIPT_DIFF_MAX_LINES })) : null] }, i));
81
+ }
82
+ 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));
83
+ }
84
+ // Render-count probe for the timer-isolation test: incremented on every
85
+ // TranscriptView render (a 1s timer tick must leave it unchanged).
86
+ export const transcriptRenderProbe = { count: 0 };
87
+ export const TranscriptView = React.memo(function TranscriptView({ turns, clearGen, renderItem, end, windowSize, held, showThinking = true, }) {
88
+ transcriptRenderProbe.count += 1;
89
+ const render = renderItem ?? renderTranscriptItem;
90
+ const win = windowSize ?? SCROLLBACK_WINDOW;
91
+ const vp = resolveViewport(turns.length, end, win);
92
+ // Pairing ([audit label, error detail] → one card) runs over the VISIBLE
93
+ // slice only — pairing is positional, and off-window turns never mount.
94
+ // Keys stay global (`turn-${idx}`) so scrolling never remounts rows.
95
+ // Hidden thinking turns are skipped in place (same index stability).
96
+ const body = [];
97
+ for (let idx = vp.start; idx < vp.end; idx++) {
98
+ const turn = turns[idx];
99
+ if (turn.thinking === true && !showThinking)
100
+ continue;
101
+ const next = idx + 1 < vp.end ? turns[idx + 1] : undefined;
102
+ if (isAuditLabel(turn) && next !== undefined && next.role === "tool" && next.error === true) {
103
+ body.push({ id: `turn-${idx}`, turn: next, label: turn });
104
+ idx += 1;
105
+ continue;
106
+ }
107
+ body.push({ id: `turn-${idx}`, turn });
108
+ }
109
+ const items = clearGen === 0 && vp.start === 0 ? [{ id: "banner" }, ...body] : body;
110
+ return (_jsxs(Box, { flexDirection: "column", children: [items.map((item) => (_jsx(React.Fragment, { children: render(item) }, item.id))), vp.pending > 0 ? (_jsxs(Text, { dimColor: true, children: ["\u2193 ", vp.pending, " new \u2014 End for latest"] })) : held ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " held \u2014 End to follow"] })) : null] }));
111
+ });
112
+ // Startup banner: the ATOM block-letter art, rendered once at launch inside
113
+ // <Static> (scrollback, so it scrolls away naturally). FIGlet "ANSI Shadow"
114
+ // ATOM (Unicode box-drawing — needs a monospace font with box-drawing
115
+ // support, which Windows Terminal / ConHost / most terminals have). The art
116
+ // is the whole banner: the footer status line is the sole info bar, so no
117
+ // hint lines live here.
118
+ export const ATOM_ART = [
119
+ " █████╗ ████████╗ ██████╗ ███╗ ███╗",
120
+ "██╔══██╗╚══██╔══╝██╔═══██╗████╗ ████║",
121
+ "███████║ ██║ ██║ ██║██╔████╔██║",
122
+ "██╔══██║ ██║ ██║ ██║██║╚██╔╝██║",
123
+ "██║ ██║ ██║ ╚██████╔╝██║ ╚═╝ ██║",
124
+ "╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝",
125
+ ];
126
+ export function StartupBanner() {
127
+ return (_jsx(Box, { flexDirection: "column", marginBottom: theme.spacing.turnGap, children: ATOM_ART.map((line, i) => (_jsx(Text, { color: theme.color.user, bold: true, children: line }, i))) }));
128
+ }