atom-agent 1.1.0 → 1.3.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 (74) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/README.md +18 -8
  3. package/atom.example.json +11 -0
  4. package/dist/App.js +1637 -255
  5. package/dist/adapters.js +112 -21
  6. package/dist/agent/gates.js +14 -1
  7. package/dist/agent/goal-evaluator.js +69 -0
  8. package/dist/agent/loop-guard.js +11 -13
  9. package/dist/agent/loop.js +716 -132
  10. package/dist/agent/normalize.js +9 -2
  11. package/dist/cli.js +25 -3
  12. package/dist/compact.js +169 -17
  13. package/dist/config.js +43 -7
  14. package/dist/context-manager.js +16 -198
  15. package/dist/context-windows.js +4 -2
  16. package/dist/env-block.js +46 -8
  17. package/dist/extension-commands.js +196 -0
  18. package/dist/extension-ui.js +153 -0
  19. package/dist/extensions.js +1571 -0
  20. package/dist/goal.js +583 -0
  21. package/dist/project-trust.js +96 -0
  22. package/dist/providers.js +6 -6
  23. package/dist/scheduler.js +159 -41
  24. package/dist/session.js +23 -5
  25. package/dist/sessions.js +543 -0
  26. package/dist/system.js +89 -13
  27. package/dist/telemetry-dashboard.js +28 -0
  28. package/dist/telemetry.js +39 -0
  29. package/dist/tools/compaction-hooks.js +165 -0
  30. package/dist/tools/custom.js +189 -0
  31. package/dist/tools/dir-cache.js +7 -0
  32. package/dist/tools/filesystem.js +3 -2
  33. package/dist/tools/intercept.js +145 -0
  34. package/dist/tools/overrides.js +105 -0
  35. package/dist/tools/provider-hooks.js +224 -0
  36. package/dist/tools/registry.js +247 -17
  37. package/dist/tools/ripgrep.js +256 -0
  38. package/dist/tools/search.js +119 -58
  39. package/dist/tools/shared.js +39 -0
  40. package/dist/tools/shell.js +7 -5
  41. package/dist/tools/web.js +6 -6
  42. package/dist/tools.js +45 -0
  43. package/dist/ui/diff-view.js +7 -2
  44. package/dist/ui/live-host.js +18 -0
  45. package/dist/ui/live-tail.js +9 -3
  46. package/dist/ui/markdown.js +26 -2
  47. package/dist/ui/palette.js +3 -1
  48. package/dist/ui/side-by-side.js +2 -2
  49. package/dist/ui/status-bar.js +80 -5
  50. package/dist/ui/status-host.js +22 -0
  51. package/dist/ui/stream-store.js +48 -0
  52. package/dist/ui/tool-inspector.js +7 -1
  53. package/dist/ui/transcript.js +92 -38
  54. package/dist/zen.js +370 -87
  55. package/documentation/architecture.md +114 -0
  56. package/documentation/cli.md +82 -0
  57. package/documentation/compaction.md +50 -0
  58. package/documentation/configuration.md +111 -0
  59. package/documentation/development.md +62 -0
  60. package/documentation/extensions.md +160 -0
  61. package/documentation/getting-started.md +63 -0
  62. package/documentation/goals.md +41 -0
  63. package/documentation/index.md +41 -0
  64. package/documentation/observability.md +70 -0
  65. package/documentation/permissions.md +66 -0
  66. package/documentation/providers.md +78 -0
  67. package/documentation/sessions.md +92 -0
  68. package/documentation/skills.md +57 -0
  69. package/documentation/tools.md +94 -0
  70. package/documentation/troubleshooting.md +54 -0
  71. package/examples/extensions/01-audit-gate.js +24 -0
  72. package/examples/extensions/02-notes-tool.js +32 -0
  73. package/examples/extensions/03-custom-command.js +32 -0
  74. package/package.json +6 -2
@@ -29,7 +29,12 @@ function syntaxColor(kind) {
29
29
  // an offset cursor paints every char exactly once (text integrity is
30
30
  // pinned by tests — highlighting must never alter content). Exported:
31
31
  // the side-by-side view (ui/side-by-side) reuses it per pane cell.
32
- export function LineBody({ lineText, runs, base, lang, }) {
32
+ //
33
+ // Memoized: props are (lineText, runs, base, lang) — `runs` keeps identity
34
+ // from the parent's useMemo'd diff, and highlightLine is itself line-cached,
35
+ // so unrelated parent renders (ticks, keystrokes, appends elsewhere) skip
36
+ // both the walk and the tokenize lookup.
37
+ export const LineBody = React.memo(function LineBody({ lineText, runs, base, lang, }) {
33
38
  const baseColor = base === "add" ? theme.color.success : theme.color.toolError;
34
39
  const hlBg = base === "add" ? "green" : "red";
35
40
  const langKnown = lang === "c" || lang === "py" || lang === "sh" || lang === "data";
@@ -80,7 +85,7 @@ export function LineBody({ lineText, runs, base, lang, }) {
80
85
  nodes.push(_jsx(Text, { children: parts }, k));
81
86
  });
82
87
  return _jsx(Text, { color: langKnown ? undefined : baseColor, children: nodes });
83
- }
88
+ });
84
89
  function DiffViewInner({ oldText, newText, lang = null, maxLines = Infinity }) {
85
90
  const diff = React.useMemo(() => computeDiff(oldText, newText), [oldText, newText]);
86
91
  if (diff.skipped) {
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // Live-tail host: the subscription boundary between App and the streaming UI.
3
+ //
4
+ // App renders this host with LOW-frequency props only (busy/held/empty flags,
5
+ // elapsed seconds, tool hint). The HIGH-frequency streaming text (draft +
6
+ // thinking, up to ~15 paints/sec via DRAFT_THROTTLE_MS) flows through the
7
+ // StreamStore instead: this host subscribes via useSyncExternalStore, so a
8
+ // token paint re-renders this host + LiveTail alone — App's body,
9
+ // reconciliation of every other leaf, and their prop assembly never run.
10
+ //
11
+ // LiveTail itself is untouched (same props API, same paint), so all existing
12
+ // LiveTail tests keep passing; only the delivery path changed.
13
+ import React, { useSyncExternalStore } from "react";
14
+ import { LiveTail } from "./live-tail.js";
15
+ export const LiveTailHost = React.memo(function LiveTailHost({ store, isEmpty, sessionHint, emptySessionTitle, busy, held, toolHint, toolElapsedSecs, elapsedSecs, showThinking, }) {
16
+ const snap = useSyncExternalStore(store.subscribe, store.getSnapshot);
17
+ return (_jsx(LiveTail, { isEmpty: isEmpty, sessionHint: sessionHint, emptySessionTitle: emptySessionTitle, draft: snap.draft, thinking: snap.thinking, busy: busy, held: held, toolHint: toolHint, toolElapsedSecs: toolElapsedSecs, elapsedSecs: elapsedSecs, showThinking: showThinking }));
18
+ });
@@ -1,9 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Live-tail leaf: the dynamic zone between the committed <Static>
3
+ // transcript and the modals — empty-state hints, the streaming answer
4
+ // draft, the transient thinking block, and the tool-call hint. Re-renders
5
+ // every tick by design (unlike TranscriptView/InputBox); all paint comes
6
+ // from ui/theme tokens. The streaming-markdown chunk owns this file next.
7
+ import React from "react";
2
8
  import { Box, Text } from "ink";
3
9
  import { activityText } from "./activity.js";
4
10
  import { MarkdownStream } from "./markdown.js";
5
11
  import { theme } from "./theme.js";
6
- export function LiveTail({ isEmpty, sessionHint, draft, thinking, busy, held, toolHint, toolElapsedSecs, elapsedSecs, showThinking = true }) {
12
+ export const LiveTail = React.memo(function LiveTail({ isEmpty, sessionHint, emptySessionTitle, draft, thinking, busy, held, toolHint, toolElapsedSecs, elapsedSecs, showThinking = true }) {
7
13
  // Held view (user scrolled up mid-turn): the growing draft/thinking blocks
8
14
  // are replaced by one static line so the frame stops gaining terminal
9
15
  // lines — the terminal stops yanking and scrollback stays readable. The
@@ -11,5 +17,5 @@ export function LiveTail({ isEmpty, sessionHint, draft, thinking, busy, held, to
11
17
  // status (tool hint, thinking tick) keeps updating in place: same line,
12
18
  // no growth, no yank.
13
19
  const freezeLive = held === true && busy;
14
- return (_jsxs(Box, { flexDirection: "column", marginY: theme.spacing.liveTailMarginY, children: [isEmpty ? (_jsx(Text, { dimColor: true, children: "Say hi to Atom \u2014 or type / for commands, /provider to pick a provider + key, /model to switch models." })) : null, sessionHint && isEmpty ? (_jsx(Text, { dimColor: true, children: "(last session available \u2014 /resume to restore)" })) : null, freezeLive ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " held \u2014 turn running \u00B7 End to follow"] })) : null, !freezeLive && draft ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: _jsxs(Text, { color: theme.color.assistant, bold: true, children: [theme.symbol.speakerAssistant, " "] }) }), _jsx(MarkdownStream, { text: draft })] })) : null, !freezeLive && thinking && showThinking ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.thinking, " ", thinking, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBar })] })) : null, busy && toolHint ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.workTool, " ", activityText(toolHint), toolElapsedSecs !== null && toolElapsedSecs >= 2 ? (_jsxs(_Fragment, { children: [" ", theme.symbol.separator, " ", toolElapsedSecs, "s"] })) : (theme.symbol.ellipsis)] })) : null, !freezeLive && busy && !draft && !thinking && !toolHint ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.workThinking, " Thinking", theme.symbol.ellipsis, " ", theme.symbol.separator, " ", elapsedSecs, "s"] })) : null] }));
15
- }
20
+ return (_jsxs(Box, { flexDirection: "column", marginY: theme.spacing.liveTailMarginY, children: [isEmpty ? (_jsx(Text, { dimColor: true, children: "Say hi to Atom \u2014 or type / for commands, /provider to pick a provider + key, /model to switch models." })) : null, isEmpty && emptySessionTitle && emptySessionTitle.trim() ? (_jsxs(Text, { dimColor: true, children: ["Session: ", emptySessionTitle.trim()] })) : null, sessionHint && isEmpty ? (_jsx(Text, { dimColor: true, children: "(last session available \u2014 /resume to restore)" })) : null, freezeLive ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " held \u2014 turn running \u00B7 End to follow"] })) : null, !freezeLive && draft ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: _jsxs(Text, { color: theme.color.assistant, bold: true, children: [theme.symbol.speakerAssistant, " "] }) }), _jsx(MarkdownStream, { text: draft })] })) : null, !freezeLive && thinking && showThinking ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.thinking, " ", thinking, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBar })] })) : null, busy && toolHint ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.workTool, " ", activityText(toolHint), toolElapsedSecs !== null && toolElapsedSecs >= 2 ? (_jsxs(_Fragment, { children: [" ", theme.symbol.separator, " ", toolElapsedSecs, "s"] })) : (theme.symbol.ellipsis)] })) : null, !freezeLive && busy && !draft && !thinking && !toolHint ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.workThinking, " Thinking", theme.symbol.ellipsis, " ", theme.symbol.separator, " ", elapsedSecs, "s"] })) : null] }));
21
+ });
@@ -1,4 +1,21 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Zero-dependency markdown renderer for assistant transcript turns.
3
+ //
4
+ // Terminal-native hierarchy, no boxes: headings are bold, lists use one
5
+ // consistent bullet (nesting as 2-space indents, task lists as ballot
6
+ // boxes), code blocks are indentation + a dim language label (```/~~~
7
+ // fences, unclosed runs to end of input), tables are aligned columns with
8
+ // one dim separator row, links read as `text (url)`. Soft line breaks join
9
+ // (true markdown); blank lines separate paragraphs. Raw fences/bold-markers
10
+ // never leak: when the model emits plain text, it paints back byte-identical.
11
+ // Long code lines are never pre-wrapped or truncated — Ink wraps them and
12
+ // the source line stays intact for copy/paste; long table cells truncate
13
+ // with `…` so one cell never blows out the grid.
14
+ //
15
+ // Performance: parsed blocks are cached per exact input (bounded FIFO), so
16
+ // re-renders and long sessions never re-parse. Parsing is linear in input
17
+ // size; rendering stays one <Text> per run (no per-character nodes).
18
+ import React from "react";
2
19
  import { Box, Text } from "ink";
3
20
  import { theme } from "./theme.js";
4
21
  // Split `s` on inline-code spans first (code content is never formatted),
@@ -481,10 +498,17 @@ export function closeStreamingMarkers(s) {
481
498
  // block cursor riding the final run. Converges to MarkdownText byte-for-
482
499
  // byte once the stream completes (cursor aside), so commit never visually
483
500
  // jumps.
484
- export function MarkdownStream({ text }) {
501
+ //
502
+ // Memoized on `text` — the ONLY parse input (TABLE_MAX_COL is a fixed
503
+ // const, wrapping is Ink's job, the cursor glyph is a module const). A 1s
504
+ // busy tick re-renders the parent with identical text and must NOT reparse:
505
+ // same text bails here, changed text re-parses (one linear pass).
506
+ export const streamParseProbe = { count: 0 };
507
+ export const MarkdownStream = React.memo(function MarkdownStream({ text }) {
508
+ streamParseProbe.count += 1;
485
509
  const blocks = parseMarkdown(closeStreamingMarkers(text) + theme.symbol.cursorBar);
486
510
  return (_jsx(Box, { flexDirection: "column", children: blocks.map((b, k) => (_jsx(BlockView, { block: b, gap: k > 0 }, k))) }));
487
- }
511
+ });
488
512
  // Assistant body: full markdown when the text parses into structure,
489
513
  // byte-identical plain text otherwise (a single paragraph paints its runs;
490
514
  // with no formatting syntax those runs are the input verbatim).
@@ -9,7 +9,7 @@ import { Box, Text } from "ink";
9
9
  import { PickerMoreAbove, PickerMoreBelow, pickerWindow } from "./pickers.js";
10
10
  import { theme } from "./theme.js";
11
11
  export const PALETTE_WINDOW = 12;
12
- export const PALETTE_CATEGORY_ORDER = ["Model", "Session", "Tools", "Skills", "Flow", "Help"];
12
+ export const PALETTE_CATEGORY_ORDER = ["Model", "Session", "Tools", "Skills", "Flow", "Extensions", "Help"];
13
13
  const PALETTE_CATEGORIES = {
14
14
  "/model": "Model",
15
15
  "/provider": "Model",
@@ -18,6 +18,8 @@ const PALETTE_CATEGORIES = {
18
18
  "/clear": "Session",
19
19
  "/new": "Session",
20
20
  "/resume": "Session",
21
+ "/rename": "Session",
22
+ "/session": "Session",
21
23
  "/rewind": "Session",
22
24
  "/context": "Session",
23
25
  "/telemetry": "Session",
@@ -9,8 +9,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  // lines align; changed regions pop via the existing word-background +
10
10
  // add/del line-number treatment; syntax colors reused per cell.
11
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 …
12
+ // - width-aware: panes split the measured terminal (local useStdout, same
13
+ // pattern as StatusBarHost); long lines truncate per pane with …
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
@@ -12,6 +12,47 @@ import React from "react";
12
12
  import { Box, Text } from "ink";
13
13
  import { formatTokenSegment } from "../context-windows.js";
14
14
  import { theme } from "./theme.js";
15
+ // Default objective budget for the goal segment: compact enough to share the
16
+ // line with the pinned model/token/mode segments at 100 columns.
17
+ export const GOAL_STATUS_OBJECTIVE_CHARS = 32;
18
+ // Truncate an objective to n chars max (`…` tail keeps the start, which
19
+ // carries the verb). n < 4 yields "" (the caller drops the segment instead).
20
+ export function truncateGoalObjective(objective, max = GOAL_STATUS_OBJECTIVE_CHARS) {
21
+ const text = typeof objective === "string" ? objective : "";
22
+ if (text.length <= max)
23
+ return text;
24
+ if (max < 4)
25
+ return "";
26
+ return `${text.slice(0, max - 1)}…`;
27
+ }
28
+ // Full goal segment at the default budget, or null when no goal is live.
29
+ // Paused reads distinct from active (`[paused]` vs `[active]`).
30
+ export function formatGoalSegment(goal, max = GOAL_STATUS_OBJECTIVE_CHARS) {
31
+ if (!goal || typeof goal.objective !== "string" || goal.objective.length === 0)
32
+ return null;
33
+ const state = goal.active === true ? "active" : "paused";
34
+ return `goal: ${truncateGoalObjective(goal.objective, max)} [${state}]`;
35
+ }
36
+ // Fit the goal segment into `room` chars (the width left after every other
37
+ // segment): full text when it fits, a shorter truncation when it almost
38
+ // fits, null (drop the segment) when even a stub would displace the line.
39
+ // Never throws; never returns "".
40
+ export function fitGoalSegment(goal, room) {
41
+ if (!goal || typeof goal.objective !== "string" || goal.objective.length === 0)
42
+ return null;
43
+ if (typeof room !== "number" || !Number.isFinite(room) || room <= 0)
44
+ return null;
45
+ const state = goal.active === true ? "active" : "paused";
46
+ const full = `goal: ${goal.objective} [${state}]`;
47
+ if (full.length <= room)
48
+ return full;
49
+ // Room for at least 4 objective chars plus the fixed framing, else drop.
50
+ const overhead = `goal: [${state}]`.length + 1;
51
+ const allow = Math.floor(room - overhead);
52
+ if (allow < 4)
53
+ return null;
54
+ return `goal: ${truncateGoalObjective(goal.objective, allow)} [${state}]`;
55
+ }
15
56
  // ~/… collapse + tail-cut: informative, never a full scroll of nesting.
16
57
  // Further shrinking for tight widths goes through shrinkTo below (the bar
17
58
  // measures first and only renders what fits).
@@ -34,9 +75,13 @@ export function shrinkTo(s, n) {
34
75
  // render (same-props parent churn — token paints, keystrokes, unrelated
35
76
  // ticks — must skip it; only changed props repaint).
36
77
  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, }) {
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, }) {
38
79
  statusBarRenderProbe.count += 1;
39
80
  const bar = theme.symbol.bar;
81
+ // Extension guest slot (ticket 10): pre-budgeted text renders only when
82
+ // the full line still fits — the fixed-width contract above. The `+ 3`
83
+ // is the ` ${bar} ` separator the segment carries with it.
84
+ const hasExt = typeof extensionStatus === "string" && extensionStatus.length > 0;
40
85
  if (!busy) {
41
86
  const token = formatTokenSegment(usageTotals, model, contextLoad);
42
87
  const trust = trustAll && mode !== "plan" ? "+trust" : "";
@@ -46,7 +91,8 @@ export const StatusBar = React.memo(function StatusBar({ provider, model, usageT
46
91
  // order: branch → cwd tail → the whole segment.
47
92
  const tail = `reasoning: ${reasoningDisplay} ${bar} mode: ${mode}${trust}`;
48
93
  const baseLen = `${provider}/${model} ${bar} ${token} ${bar} ${bar} ${tail}`.length;
49
- const avail = columns - baseLen;
94
+ const showExt = hasExt && baseLen + extensionStatus.length + 3 + 2 <= columns;
95
+ const avail = columns - baseLen - (showExt ? extensionStatus.length + 3 : 0);
50
96
  let loc = null;
51
97
  if (cwd) {
52
98
  const branchPart = branch ? ` : ${branch}` : "";
@@ -61,15 +107,44 @@ export const StatusBar = React.memo(function StatusBar({ provider, model, usageT
61
107
  loc = shrunk ? shrunk : null;
62
108
  }
63
109
  }
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] }) }));
110
+ // Goal segment (ticket 09): lowest-priority builtin it takes only the
111
+ // width left after every other segment and drops whole rather than push
112
+ // the line past `columns`. Hidden entirely with no goal.
113
+ const lineSoFar = baseLen + (showExt ? extensionStatus.length + 3 : 0) + (loc ? loc.length + 3 : 0);
114
+ 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] }) }));
65
116
  }
66
117
  // Busy layout prioritizes activity + clock + interrupt hint; the mode
67
118
  // stays pinned (it used to vanish while working), and the reasoning
68
119
  // effort stays visible (it used to vanish while working). The activity text
69
120
  // shrinks to fit so `esc stops` never wraps away.
70
121
  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`;
122
+ const busyToken = formatTokenSegment(usageTotals, model, contextLoad);
123
+ // Goal segment (ticket 09): a guest in the fixed part — capped at 48
124
+ // chars and rendered only when the FULL activity text still fits beside
125
+ // it. Otherwise the goal drops whole and every existing segment renders
126
+ // exactly as with no goal (the goal never displaces, same precedent as
127
+ // the extension guest above). The clock, token, mode, and esc-hint
128
+ // segments never move for it either way.
129
+ const busyGoalSeg = fitGoalSegment(goal ?? null, 48);
130
+ const busyGoalCandidate = busyGoalSeg ? ` ${bar} ${busyGoalSeg}` : "";
131
+ const activityFull = activity ?? phaseLabel;
132
+ const busyCore = ` ${bar} ${elapsedSecs}s ${bar} ${busyToken} ${bar} reasoning: ${reasoningDisplay} ${bar} mode: ${mode}${busyTrust}`;
133
+ const busyTail = ` ${bar} esc stops`;
134
+ const busyExtCandidate = hasExt && `${busyCore}${busyGoalCandidate}${busyTail}`.length + extensionStatus.length + 3 + 2 <= columns
135
+ ? ` ${bar} ${extensionStatus}`
136
+ : "";
137
+ const withGoalFixed = `${busyCore}${busyExtCandidate}${busyGoalCandidate}${busyTail}`;
138
+ // Room check against the unfitted activity text: when it no longer fits
139
+ // whole with the goal aboard, the goal yields (drop whole, recompute).
140
+ const busyGoalPart = busyGoalCandidate !== "" &&
141
+ withGoalFixed.length + activityFull.length + 2 <= columns
142
+ ? busyGoalCandidate
143
+ : "";
144
+ const busyNoExt = `${busyCore}${busyGoalPart}${busyTail}`;
145
+ const showBusyExt = hasExt && busyNoExt.length + extensionStatus.length + 3 + 2 <= columns;
146
+ const busyFixed = ` ${bar} ${elapsedSecs}s${showBusyExt ? ` ${bar} ${extensionStatus}` : ""} ${bar} ${busyToken} ${bar} reasoning: ${reasoningDisplay} ${bar} mode: ${mode}${busyTrust}${busyGoalPart} ${bar} esc stops`;
72
147
  const busyAvail = columns - busyFixed.length - 2;
73
- const activityText = shrinkTo(activity ?? phaseLabel, Math.max(0, busyAvail));
148
+ const activityText = shrinkTo(activityFull, Math.max(0, busyAvail));
74
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] }) }));
75
150
  });
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // Status-bar host: keeps the terminal-width subscription out of App.
3
+ //
4
+ // App used to call useStdout() in its own body to measure columns for the
5
+ // bar's fit-or-drop logic, coupling the whole App render to stdout changes.
6
+ // This memoized host owns that read instead: resizes re-render the bar
7
+ // alone. StatusBar itself is untouched (same props API); `columns` becomes
8
+ // an optional override (tests keep passing explicit widths, production
9
+ // measures). The 100 fallback matches StatusBar's own default.
10
+ import React from "react";
11
+ import { useStdout } from "ink";
12
+ import { StatusBar } from "./status-bar.js";
13
+ export const StatusBarHost = React.memo(function StatusBarHost(props) {
14
+ let measured;
15
+ try {
16
+ measured = useStdout()?.stdout?.columns;
17
+ }
18
+ catch {
19
+ measured = undefined;
20
+ }
21
+ return _jsx(StatusBar, { ...props, columns: props.columns ?? measured ?? 100 });
22
+ });
@@ -0,0 +1,48 @@
1
+ const EMPTY = { draft: null, thinking: null };
2
+ export function createStreamStore() {
3
+ let snapshot = EMPTY;
4
+ const listeners = new Set();
5
+ function emit() {
6
+ for (const cb of [...listeners]) {
7
+ try {
8
+ cb();
9
+ }
10
+ catch {
11
+ // A throwing listener must not break the remaining subscribers.
12
+ }
13
+ }
14
+ }
15
+ function assign(next) {
16
+ if (next.draft === snapshot.draft && next.thinking === snapshot.thinking)
17
+ return;
18
+ snapshot = next;
19
+ emit();
20
+ }
21
+ return {
22
+ getSnapshot: () => snapshot,
23
+ subscribe: (cb) => {
24
+ listeners.add(cb);
25
+ return () => {
26
+ listeners.delete(cb);
27
+ };
28
+ },
29
+ setDraft: (text) => {
30
+ if (text === snapshot.draft)
31
+ return;
32
+ assign({ draft: text, thinking: snapshot.thinking });
33
+ },
34
+ setThinking: (text) => {
35
+ if (text === snapshot.thinking)
36
+ return;
37
+ assign({ draft: snapshot.draft, thinking: text });
38
+ },
39
+ getDraft: () => snapshot.draft,
40
+ getThinking: () => snapshot.thinking,
41
+ clear: () => {
42
+ if (snapshot !== EMPTY) {
43
+ snapshot = EMPTY;
44
+ emit();
45
+ }
46
+ },
47
+ };
48
+ }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
+ import { truncateHead } from "../tools/shared.js";
3
4
  import { theme } from "./theme.js";
4
5
  export const MAX_TOOL_RECORDS = 50;
5
6
  export const STORE_CHARS = 32768;
@@ -8,7 +9,12 @@ export const LIST_WINDOW = 15;
8
9
  export function createToolRecord(id, label, result, isError, ms) {
9
10
  const text = result ?? "";
10
11
  const truncated = text.length > STORE_CHARS;
11
- const stored = truncated ? text.slice(0, STORE_CHARS) : text;
12
+ // Line-aware store cap (issue 04): the stored head never ends mid-line.
13
+ // Single-giant-line inputs keep the hard cut (documented tail edge case),
14
+ // so over-cap single-line results still store exactly STORE_CHARS.
15
+ const stored = truncated
16
+ ? truncateHead(text, STORE_CHARS, "\n[truncated: stored output exceeded 32KB]").head
17
+ : text;
12
18
  return {
13
19
  id,
14
20
  label,
@@ -1,39 +1,37 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  // Transcript leaves: the committed <Static> scrollback, its item renderer,
3
3
  // and the startup banner. Prop-driven + memoized (see comments) so App state
4
4
  // churn never repaints them. Turn is the display-transcript entry shape.
5
5
  // All paint comes from ui/theme tokens — no literal colors or glyphs here.
6
6
  import React from "react";
7
- import { Box, Text } from "ink";
7
+ import { Box, Static, Text } from "ink";
8
8
  import { SideBySideDiffView, TRANSCRIPT_DIFF_MAX_LINES } from "./side-by-side.js";
9
9
  import { ErrorCard, classifyToolError } from "./errors.js";
10
10
  import { MarkdownText, ToolLine } from "./markdown.js";
11
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).
12
+ // Commit frontier model: the committed transcript prints to terminal
13
+ // scrollback ONCE via <Static> and is never rewritten (this is what keeps
14
+ // a full-page transcript from flashing on every keystroke Ink takes a
15
+ // clearTerminal + full-reprint path for fullscreen dynamic frames).
15
16
  //
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.
17
+ // Model: E = committed end index (null = follow: commit everything). Any
18
+ // E < len is manual mode — new turns accumulate below the frontier and a
19
+ // `↓ N new` indicator offers the jump back. Printed output can never
20
+ // retract, so E below the committed count holds back future commits only;
21
+ // deep history lives in terminal scrollback. Banner shows on fresh mounts.
22
+ // List replacement (/clear, /resume, /new, /rewind) bumps clearGen, which
23
+ // resets the Static buffer via the identity below.
22
24
  export const SCROLLBACK_WINDOW = 300;
23
25
  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
26
  export function applyScrollAction(end, len, action) {
30
27
  const e = end ?? len;
31
28
  switch (action.kind) {
32
29
  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.
30
+ // Freeze the commit frontier instead of no-op-ing, so PgUp always
31
+ // engages the held view (new output stops printing below; the live
32
+ // tail stops growing; the terminal stops yanking). Values below the
33
+ // already-committed count hold back future commits only printed
34
+ // output lives in terminal scrollback and can never retract.
37
35
  if (len <= SCROLLBACK_WINDOW)
38
36
  return len;
39
37
  return Math.max(Math.min(len, SCROLLBACK_WINDOW), e - SCROLL_PAGE_ITEMS);
@@ -84,30 +82,86 @@ export function renderTranscriptItem(item) {
84
82
  // Render-count probe for the timer-isolation test: incremented on every
85
83
  // TranscriptView render (a 1s timer tick must leave it unchanged).
86
84
  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++) {
85
+ // Render-count probe for row isolation: incremented per mounted row paint
86
+ // (appending one turn must paint exactly one new row, never the window).
87
+ export const transcriptRowRenderProbe = { count: 0 };
88
+ // Committed turns are immutable once appended (diffs attach pre-commit in
89
+ // onToolActivity, never post-append), and keys stay global (`turn-${idx}`),
90
+ // so a row whose item identity is unchanged can skip rendering entirely.
91
+ // Custom compare: the body array is rebuilt per TranscriptView render with
92
+ // fresh wrappers around the SAME turn refs shallow compare would always
93
+ // miss, hence id + turn/label identity. An unstable render fn falls back to
94
+ // today's behavior (re-render) rather than going stale.
95
+ function transcriptRowEqual(a, b) {
96
+ return (a.render === b.render &&
97
+ a.item.id === b.item.id &&
98
+ a.item.turn === b.item.turn &&
99
+ a.item.label === b.item.label);
100
+ }
101
+ const TranscriptRow = React.memo(function TranscriptRow({ item, render }) {
102
+ transcriptRowRenderProbe.count += 1;
103
+ return _jsx(React.Fragment, { children: render(item) });
104
+ }, transcriptRowEqual);
105
+ // Monotonic static admission: convert record turns [from, to) into Static
106
+ // items, pairing adjacent [audit label, error detail] within the batch and
107
+ // permanently skipping hidden thinking turns. ALWAYS returns next ===
108
+ // clamped `to` (even when everything skips) so the frontier only moves
109
+ // forward — shrinking or reordering same-identity items would misalign
110
+ // Ink's append-only Static buffer and duplicate terminal scrollback.
111
+ // List replacements (/clear, /resume, /rewind) bump clearGen instead, which
112
+ // resets the buffer via the Static identity below.
113
+ export function admitStaticBatch(turns, from, to, showThinking) {
114
+ const end = Math.max(from, Math.min(to, turns.length));
115
+ const items = [];
116
+ let idx = from;
117
+ while (idx < end) {
98
118
  const turn = turns[idx];
99
- if (turn.thinking === true && !showThinking)
119
+ if (turn.thinking === true && !showThinking) {
120
+ idx += 1;
100
121
  continue;
101
- const next = idx + 1 < vp.end ? turns[idx + 1] : undefined;
122
+ }
123
+ const next = idx + 1 < end ? turns[idx + 1] : undefined;
102
124
  if (isAuditLabel(turn) && next !== undefined && next.role === "tool" && next.error === true) {
103
- body.push({ id: `turn-${idx}`, turn: next, label: turn });
104
- idx += 1;
125
+ items.push({ id: `turn-${idx}`, turn: next, label: turn });
126
+ idx += 2;
105
127
  continue;
106
128
  }
107
- body.push({ id: `turn-${idx}`, turn });
129
+ items.push({ id: `turn-${idx}`, turn });
130
+ idx += 1;
131
+ }
132
+ return { items, next: end };
133
+ }
134
+ export const TranscriptView = React.memo(function TranscriptView({ turns, clearGen, renderItem, end, held, showThinking = true, }) {
135
+ transcriptRenderProbe.count += 1;
136
+ const render = renderItem ?? renderTranscriptItem;
137
+ const frontier = end ?? turns.length;
138
+ // Committed static state: full reset on clearGen (list replacements bump
139
+ // it — replacements must never reuse the buffer), suffix-only advance
140
+ // otherwise (setState-during-render derived-state pattern; the extra pass
141
+ // runs only when genuinely new items commit, never on ticks/keystrokes).
142
+ const [committed, setCommitted] = React.useState(() => {
143
+ const base = clearGen === 0 ? [{ id: "banner" }] : [];
144
+ const batch = admitStaticBatch(turns, 0, frontier, showThinking);
145
+ return { gen: clearGen, items: [...base, ...batch.items], next: batch.next };
146
+ });
147
+ if (committed.gen !== clearGen) {
148
+ const base = clearGen === 0 ? [{ id: "banner" }] : [];
149
+ const batch = admitStaticBatch(turns, 0, end ?? turns.length, showThinking);
150
+ setCommitted({ gen: clearGen, items: [...base, ...batch.items], next: batch.next });
151
+ }
152
+ else {
153
+ const batch = admitStaticBatch(turns, committed.next, frontier, showThinking);
154
+ if (batch.items.length > 0 || batch.next !== committed.next) {
155
+ setCommitted({
156
+ gen: clearGen,
157
+ items: [...committed.items, ...batch.items],
158
+ next: batch.next,
159
+ });
160
+ }
108
161
  }
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] }));
162
+ // Backlog below the committed frontier (frozen appends, not yet printed).
163
+ const pending = turns.length - committed.next;
164
+ return (_jsxs(_Fragment, { children: [_jsx(Static, { items: committed.items, children: (item) => _jsx(TranscriptRow, { item: item, render: render }, item.id) }, clearGen), pending > 0 ? (_jsxs(Text, { dimColor: true, children: ["\u2193 ", pending, " new \u2014 End for latest"] })) : held ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.moreAbove, " held \u2014 End to follow"] })) : null] }));
111
165
  });
112
166
  // Startup banner: the ATOM block-letter art, rendered once at launch inside
113
167
  // <Static> (scrollback, so it scrolls away naturally). FIGlet "ANSI Shadow"