@aragon-agent/cli 0.5.6 → 0.5.8

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 (59) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +60 -2
  3. package/dist/agent/entry-limits.d.ts +65 -0
  4. package/dist/agent/entry-limits.js +133 -0
  5. package/dist/agent/reducer.d.ts +8 -0
  6. package/dist/agent/reducer.js +67 -15
  7. package/dist/cli.js +36 -2
  8. package/dist/commands/builtins.js +5 -0
  9. package/dist/commands/perf.d.ts +45 -0
  10. package/dist/commands/perf.js +78 -0
  11. package/dist/config/env.d.ts +3 -1
  12. package/dist/config/env.js +31 -1
  13. package/dist/config/load.d.ts +14 -0
  14. package/dist/config/load.js +43 -2
  15. package/dist/config/schema.d.ts +61 -8
  16. package/dist/config/schema.js +43 -6
  17. package/dist/ui/App.js +86 -9
  18. package/dist/ui/Markdown.d.ts +7 -1
  19. package/dist/ui/Markdown.js +15 -48
  20. package/dist/ui/StatusBar.d.ts +13 -0
  21. package/dist/ui/StatusBar.js +4 -2
  22. package/dist/ui/Transcript.d.ts +56 -4
  23. package/dist/ui/Transcript.js +112 -10
  24. package/dist/ui/entries/AssistantEntry.d.ts +18 -1
  25. package/dist/ui/entries/AssistantEntry.js +33 -3
  26. package/dist/ui/entries/TeamCard.d.ts +9 -1
  27. package/dist/ui/entries/TeamCard.js +22 -1
  28. package/dist/ui/entries/TodoCard.d.ts +9 -1
  29. package/dist/ui/entries/TodoCard.js +23 -1
  30. package/dist/ui/entries/ToolCard.d.ts +13 -1
  31. package/dist/ui/entries/ToolCard.js +39 -5
  32. package/dist/ui/entries/ToolPreview.d.ts +2 -2
  33. package/dist/ui/entries/UserEntry.d.ts +9 -1
  34. package/dist/ui/entries/UserEntry.js +17 -1
  35. package/dist/ui/exit-snapshot.d.ts +7 -0
  36. package/dist/ui/layout/MeasuredEntry.d.ts +21 -0
  37. package/dist/ui/layout/MeasuredEntry.js +30 -0
  38. package/dist/ui/layout/ScrollViewport.js +8 -2
  39. package/dist/ui/layout/viewport-geometry.d.ts +28 -0
  40. package/dist/ui/layout/viewport-geometry.js +27 -0
  41. package/dist/ui/layout/virtual-window.d.ts +100 -0
  42. package/dist/ui/layout/virtual-window.js +204 -0
  43. package/dist/ui/live-clamp.d.ts +42 -0
  44. package/dist/ui/live-clamp.js +74 -0
  45. package/dist/ui/markdown-blocks.d.ts +25 -1
  46. package/dist/ui/markdown-blocks.js +43 -0
  47. package/dist/ui/render-cache.d.ts +81 -0
  48. package/dist/ui/render-cache.js +195 -0
  49. package/dist/ui/render-governor.d.ts +54 -0
  50. package/dist/ui/render-governor.js +78 -0
  51. package/dist/ui/transcript-text.d.ts +11 -0
  52. package/dist/ui/transcript-text.js +5 -0
  53. package/dist/ui/use-height-store.d.ts +31 -0
  54. package/dist/ui/use-height-store.js +76 -0
  55. package/dist/ui/use-render-governor.d.ts +39 -0
  56. package/dist/ui/use-render-governor.js +57 -0
  57. package/dist/ui/use-startup-notices.d.ts +9 -1
  58. package/dist/ui/use-startup-notices.js +35 -1
  59. package/package.json +2 -2
package/dist/ui/App.js CHANGED
@@ -11,7 +11,7 @@ import { Box, Text, useApp, useInput, useStdout } from 'ink';
11
11
  import { initialViewState, reduceEvent, viewReducer, } from '../agent/reducer.js';
12
12
  import { mergeDeltas } from '../agent/coalesce.js';
13
13
  import { computeCost } from '../agent/usage.js';
14
- import { parseMaxTokensInput, DEFAULT_TRANSCRIPT_WINDOW, } from '../config/schema.js';
14
+ import { parseMaxTokensInput, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from '../config/schema.js';
15
15
  import { updatePersistedConfig } from '../config/store.js';
16
16
  import { appendPrompt, loadPromptHistory } from '../config/prompt-history.js';
17
17
  import { bumpSubmitCount, setMouseNoticeSeen } from '../config/ui-state.js';
@@ -38,6 +38,9 @@ import { todoRailRows, todoRailWidth } from './layout/rail.js';
38
38
  import { TODO_LIMITS } from '../todo/limits.js';
39
39
  import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow-through.js';
40
40
  import { useTerminalSize } from './layout/useTerminalSize.js';
41
+ import { useHeightStore } from './use-height-store.js';
42
+ import { useRenderGovernor } from './use-render-governor.js';
43
+ import { setPerfResetHook, setPerfSnapshotProvider } from '../commands/perf.js';
41
44
  import { frameHeight, MIN_FULLSCREEN_ROWS } from './layout/frame.js';
42
45
  import { HINT_MIN_ROWS, viewportRows as computeViewportRows } from './layout/budget.js';
43
46
  import { UNMEASURED_GEOMETRY } from './layout/regions.js';
@@ -90,13 +93,23 @@ export function resolveSettingsMaxTokens(raw, previous) {
90
93
  toast: ['warn', 'Max tokens must be a number or "auto".'],
91
94
  };
92
95
  }
93
- /** Merge pending delta actions ~33 ms and flush the coalescer buffer. */
94
- const COALESCE_MS = 33;
95
96
  export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, geometryOverride, }) {
96
97
  const { exit } = useApp();
97
98
  const { stdout } = useStdout();
98
99
  const fullscreen = mode === 'fullscreen';
99
100
  const { rows, cols } = useTerminalSize();
101
+ // --- Render governor (tui-render-performance L4). ------------------------
102
+ //
103
+ // FIRST, BEFORE ANY OTHER WORK IN THIS BODY. The hook stamps
104
+ // `performance.now()` during render and reads it again in a layout effect, so
105
+ // the span it measures is only the whole commit if nothing precedes it here.
106
+ const cfg = controller.getConfig();
107
+ const governor = useRenderGovernor({
108
+ enabled: cfg.renderGovernor,
109
+ maxIntervalMs: cfg.maxRenderIntervalMs,
110
+ });
111
+ /** The transcript height cache, owned here and read by `TranscriptList` (L3). */
112
+ const heights = useHeightStore();
100
113
  const [state, dispatch] = useReducer(viewReducer, undefined, initialViewState);
101
114
  const [elapsedMs, setElapsedMs] = useState(0);
102
115
  const [confirmState, setConfirmState] = useState(null);
@@ -124,7 +137,6 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
124
137
  // the completion list are memoized against it, so a skill installed mid-session
125
138
  // gets its `/<name>` command and its autocomplete entry immediately.
126
139
  const [skillsNonce, setSkillsNonce] = useState(0);
127
- const cfg = controller.getConfig();
128
140
  const caps = useMemo(() => {
129
141
  const detected = detectCapabilities(process.env, stdout);
130
142
  return {
@@ -149,6 +161,20 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
149
161
  const pendingHuman = useRef(new Set());
150
162
  /** Written by `AppShell`'s layout effect, read at wheel-event time (§4.5). */
151
163
  const geometryRef = useRef(UNMEASURED_GEOMETRY);
164
+ /**
165
+ * What `/perf` reports. Assigned during render, exactly like `stateRef` above,
166
+ * because a slash command runs outside the render pass and a value captured in
167
+ * an effect closure would be one commit stale on every readout.
168
+ */
169
+ const perfRef = useRef(null);
170
+ /**
171
+ * How many entries `TranscriptList` actually mounted this frame.
172
+ *
173
+ * A ref written during the child's render, not state: this is the number
174
+ * `render-budget.test.tsx` gates on (AC-1 / AC-9), and turning it into state
175
+ * would make reading the render budget cost a render.
176
+ */
177
+ const mountedCount = useRef(0);
152
178
  // --- Follow-through (todo-plan-followthrough §3.4). ---------------------
153
179
  //
154
180
  // THREE REFS AND NO VIEW STATE, DELIBERATELY (§5.2). Every field added to
@@ -249,6 +275,8 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
249
275
  // --- Streaming coalescer + controller subscription. --------------------
250
276
  const pending = useRef([]);
251
277
  const flushTimer = useRef(null);
278
+ /** Alias so the subscription effect below reads one stable ref, not a hook. */
279
+ const governorInterval = governor.intervalMs;
252
280
  const flushPending = useCallback(() => {
253
281
  if (flushTimer.current) {
254
282
  clearTimeout(flushTimer.current);
@@ -276,7 +304,10 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
276
304
  if (action.type === 'textDelta' || action.type === 'thinkingDelta') {
277
305
  pending.current.push(action);
278
306
  if (!flushTimer.current) {
279
- flushTimer.current = setTimeout(() => flushPending(), COALESCE_MS);
307
+ // READ FROM THE REF AT ARM TIME (L4). This effect's dependency list
308
+ // never changes by construction, so a captured number would be
309
+ // pinned to the mount render and the ladder would move nothing.
310
+ flushTimer.current = setTimeout(() => flushPending(), governorInterval.current);
280
311
  }
281
312
  }
282
313
  else {
@@ -510,7 +541,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
510
541
  // `state.json`, not `config.json`: whether a one-shot notice has been shown
511
542
  // is bookkeeping, and the comment on the old persisted key said so.
512
543
  onSeen: () => setMouseNoticeSeen(true),
513
- });
544
+ },
545
+ // Present only when `loadConfig` had to raise the user's own
546
+ // `transcriptRetain` to `transcriptWindow` (tui-render-performance §5.1).
547
+ cfg.transcriptRetainRequested !== undefined
548
+ ? { requested: cfg.transcriptRetainRequested, resolved: cfg.transcriptRetain }
549
+ : undefined);
514
550
  // --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
515
551
  useEffect(() => {
516
552
  publishExitSnapshot({
@@ -519,8 +555,30 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
519
555
  provider: cfg.provider,
520
556
  model: cfg.model,
521
557
  startedAt: startedAt.current,
558
+ // Carried so the exit replay can say what the retain ring removed. A
559
+ // transcript that silently omits entries is worse than one that names the
560
+ // count (K-6).
561
+ droppedEntries: state.droppedEntries,
562
+ });
563
+ }, [state.entries, state.usageTotal, state.droppedEntries, cfg.provider, cfg.model]);
564
+ // --- `/perf` snapshot channel (tui-render-performance §5.4). ------------
565
+ //
566
+ // The deps are the two `useCallback`d members, NOT the handle objects: both
567
+ // hooks return a fresh literal per render, so depending on them would re-run
568
+ // this effect on every frame.
569
+ const governorReset = governor.reset;
570
+ const heightsClear = heights.clear;
571
+ useEffect(() => {
572
+ setPerfSnapshotProvider(() => perfRef.current);
573
+ setPerfResetHook(() => {
574
+ governorReset();
575
+ heightsClear();
522
576
  });
523
- }, [state.entries, state.usageTotal, cfg.provider, cfg.model]);
577
+ return () => {
578
+ setPerfSnapshotProvider(null);
579
+ setPerfResetHook(null);
580
+ };
581
+ }, [governorReset, heightsClear]);
524
582
  // --- Confirm bridge (confirmTools mode + skill approvals). -------------
525
583
  useEffect(() => {
526
584
  if (!confirmBridge)
@@ -1071,13 +1129,32 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1071
1129
  // the header the moment the first message lands.
1072
1130
  const opener = empty ? (_jsx(SessionOpener, { variant: fullscreen ? pickOpenerVariant(viewportBudget, contentCols, caps) : 'none', version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: fullscreen ? viewportBudget : Number.POSITIVE_INFINITY, theme: theme, caps: caps })) : null;
1073
1131
  const density = cfg.density;
1074
- const viewport = fullscreen ? (overlayNode ? (_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: overlayNode })) : (_jsxs(ScrollViewport, { intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, showScrollIndicator: true, cols: contentCols, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps, windowSize: cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW })] }))) : (_jsxs(_Fragment, { children: [opener, _jsx(Transcript, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }), overlayNode] }));
1132
+ const transcriptWindow = cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW;
1133
+ // Assigned during render so `/perf` reads the CURRENT frame (see `perfRef`).
1134
+ // `mountedEntries` is the same number `render-budget.test.tsx` asserts (AC-9).
1135
+ perfRef.current = {
1136
+ rung: governor.rung,
1137
+ intervalMs: governor.intervalMs.current,
1138
+ lastCommitMs: governor.lastCommitMs(),
1139
+ governorEnabled: cfg.renderGovernor,
1140
+ totalEntries: state.entries.length,
1141
+ retain: cfg.transcriptRetain ?? DEFAULT_TRANSCRIPT_RETAIN,
1142
+ droppedEntries: state.droppedEntries,
1143
+ mountedEntries: mountedCount.current,
1144
+ heightsMeasured: heights.stats().measured,
1145
+ heightsEstimated: heights.stats().estimated,
1146
+ cols: contentCols,
1147
+ mode: fullscreen ? 'fullscreen' : 'inline',
1148
+ viewportRows: fullscreen ? viewportBudget : rows,
1149
+ offset: scrolledLines,
1150
+ };
1151
+ const viewport = fullscreen ? (overlayNode ? (_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: overlayNode })) : (_jsxs(ScrollViewport, { intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, showScrollIndicator: true, cols: contentCols, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps, windowSize: transcriptWindow, cols: contentCols, heights: heights, mountedSink: mountedCount })] }))) : (_jsxs(_Fragment, { children: [opener, _jsx(Transcript, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }), overlayNode] }));
1075
1152
  // The completion popup lives inside the bottom chrome and can add up to 9
1076
1153
  // rows; unbounded, it pushes the status bar past the frame height where
1077
1154
  // `overflow: hidden` clips it away (R-14 / M-14). Cap it against the viewport.
1078
1155
  const popupMaxRows = fullscreen ? Math.max(1, viewportBudget - 4) : undefined;
1079
1156
  const composer = fullscreen ? (_jsx(Composer, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), showHint: rows >= HINT_MIN_ROWS, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, agentMode: state.agentMode, popupMaxRows: popupMaxRows, historyIntent: historyIntent, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(PromptInput, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), agentMode: state.agentMode, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }) }));
1080
- return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, geometryRef: geometryRef, header: header, viewport: viewport, team: state.team ? (_jsx(TeamPanel, { snapshot: state.team, rows: rows, cols: cols, reducedMotion: reducedMotion, theme: theme, caps: caps })) : null, rail: showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: state.status === 'running', reducedMotion: reducedMotion, theme: theme, caps: caps })) : null, strip: showStrip && state.todos ? (_jsx(TodoStrip, { snapshot: state.todos, cols: cols, theme: theme, caps: caps })) : null, toast: _jsx(ToastStack, { toasts: state.toasts, theme: theme, mode: mode }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode, ...(state.team
1157
+ return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, geometryRef: geometryRef, header: header, viewport: viewport, team: state.team ? (_jsx(TeamPanel, { snapshot: state.team, rows: rows, cols: cols, reducedMotion: reducedMotion, theme: theme, caps: caps })) : null, rail: showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: state.status === 'running', reducedMotion: reducedMotion, theme: theme, caps: caps })) : null, strip: showStrip && state.todos ? (_jsx(TodoStrip, { snapshot: state.todos, cols: cols, theme: theme, caps: caps })) : null, toast: _jsx(ToastStack, { toasts: state.toasts, theme: theme, mode: mode }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode, ecoRung: governor.rung, ...(state.team
1081
1158
  ? {
1082
1159
  teamActive: {
1083
1160
  running: state.team.runs.filter((r) => r.phase !== 'queued' && r.phase !== 'done' &&
@@ -7,7 +7,13 @@
7
7
  * *italic* / `code` / ~~strike~~. Anything else renders as plain text. Still
8
8
  * intentionally small — not a CommonMark implementation.
9
9
  *
10
- * The grammar lives in `markdown-blocks.ts`; this file only renders.
10
+ * The grammar lives in `markdown-blocks.ts`; this file only renders. That was
11
+ * true of the file header long before it was true of the file
12
+ * (tui-render-performance R2): the block grammar used to run here, in the render
13
+ * body, on every frame. Both expensive halves are now memoised on the input
14
+ * string — `parseMarkdownCached` for the AST, `highlightCached` for the ANSI —
15
+ * while theme and glyphs stay render-time, so `/theme` still takes effect
16
+ * immediately (K-5).
11
17
  */
12
18
  import React from 'react';
13
19
  import type { Theme } from './theme.js';
@@ -1,54 +1,27 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { highlight } from 'cli-highlight';
4
3
  import { pickGlyphs } from './glyphs.js';
5
4
  import { railBorderProps } from './layout/Gutter.js';
6
- import { formatTable, isRule, isTableDivider, parseHeading, parseInline, parseTableRow, tableAlignments, } from './markdown-blocks.js';
7
- const FENCE = /^```(\w*)\s*$/;
5
+ import { highlightCached, parseMarkdownCached } from './render-cache.js';
6
+ import { formatTable, isRule, parseHeading, parseInline, } from './markdown-blocks.js';
8
7
  /** Width of the `---` rule. Fixed so it cannot force a wrap on narrow frames. */
9
8
  const RULE_WIDTH = 24;
10
9
  export function Markdown({ text, theme, caps }) {
11
10
  const glyphs = pickGlyphs(caps);
12
- const lines = text.split('\n');
13
- const blocks = [];
14
- let i = 0;
15
- let key = 0;
16
- while (i < lines.length) {
17
- const line = lines[i] ?? '';
18
- const fence = FENCE.exec(line.trim());
19
- if (fence) {
20
- const lang = fence[1] || '';
21
- const codeLines = [];
22
- i += 1;
23
- while (i < lines.length && !FENCE.test((lines[i] ?? '').trim())) {
24
- codeLines.push(lines[i] ?? '');
25
- i += 1;
26
- }
27
- i += 1; // skip closing fence
28
- blocks.push(_jsx(CodeBlock, { code: codeLines.join('\n'), lang: lang, theme: theme, glyphs: glyphs }, `b${key++}`));
29
- continue;
30
- }
31
- // A pipe row followed by a divider row starts a table; consume the run.
32
- const headerCells = parseTableRow(line);
33
- if (headerCells && i + 1 < lines.length && isTableDivider(lines[i + 1] ?? '')) {
34
- const align = tableAlignments(lines[i + 1] ?? '');
35
- const rows = [headerCells];
36
- i += 2;
37
- while (i < lines.length) {
38
- const cells = parseTableRow(lines[i] ?? '');
39
- if (!cells)
40
- break;
41
- rows.push(cells);
42
- i += 1;
43
- }
44
- const formatted = formatTable(rows, align);
45
- blocks.push(_jsx(Box, { flexDirection: "column", children: formatted.map((row, r) => (_jsx(Text, { wrap: "truncate", bold: r === 0, color: r === 0 ? theme.accent : undefined, children: row }, r))) }, `b${key++}`));
46
- continue;
11
+ const blocks = parseMarkdownCached(text);
12
+ return (_jsx(Box, { flexDirection: "column", children: blocks.map((block, i) => renderBlock(block, i, theme, glyphs)) }));
13
+ }
14
+ function renderBlock(block, key, theme, glyphs) {
15
+ switch (block.kind) {
16
+ case 'code':
17
+ return (_jsx(CodeBlock, { code: block.code, lang: block.lang, theme: theme, glyphs: glyphs }, `b${key}`));
18
+ case 'table': {
19
+ const formatted = formatTable(block.rows, block.align);
20
+ return (_jsx(Box, { flexDirection: "column", children: formatted.map((row, r) => (_jsx(Text, { wrap: "truncate", bold: r === 0, color: r === 0 ? theme.accent : undefined, children: row }, r))) }, `b${key}`));
47
21
  }
48
- blocks.push(_jsx(InlineLine, { line: line, theme: theme, glyphs: glyphs }, `b${key++}`));
49
- i += 1;
22
+ default:
23
+ return _jsx(InlineLine, { line: block.text, theme: theme, glyphs: glyphs }, `b${key}`);
50
24
  }
51
- return _jsx(Box, { flexDirection: "column", children: blocks });
52
25
  }
53
26
  /**
54
27
  * Fenced code. The round border is gone (§4.3): nested inside a tool card it
@@ -57,13 +30,7 @@ export function Markdown({ text, theme, caps }) {
57
30
  * code" boundary for 1 column and 0 rows.
58
31
  */
59
32
  function CodeBlock({ code, lang, theme, glyphs, }) {
60
- let rendered = code;
61
- try {
62
- rendered = highlight(code, { language: lang || undefined, ignoreIllegals: true });
63
- }
64
- catch {
65
- rendered = code;
66
- }
33
+ const rendered = highlightCached(code, lang);
67
34
  return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [lang.length > 0 && (_jsx(Text, { color: theme.chip.fg, backgroundColor: theme.chip.bg, children: ` ${lang} ` })), _jsx(Text, { children: rendered })] }));
68
35
  }
69
36
  /** Six heading levels used to render identically; now they carry three weights. */
@@ -88,6 +88,19 @@ interface StatusBarProps {
88
88
  done: number;
89
89
  total: number;
90
90
  };
91
+ /**
92
+ * Render-governor rung (tui-render-performance L4). `> 0` renders a muted
93
+ * `eco` chip.
94
+ *
95
+ * I-L4-2 — THE GOVERNOR IS USER-VISIBLE, and this is where. A user who cannot
96
+ * see why the stream got chunkier will conclude the model got slower, and
97
+ * degrading silently is the one thing a claim of robustness cannot afford.
98
+ * It sits in the RIGHT cluster next to the other derived numbers and is
99
+ * suppressed on a narrow terminal, which is the `teamActive` treatment rather
100
+ * than the `agentMode` one: the mode must never be unreportable, whereas an
101
+ * eco chip on a 50-column terminal costs the context gauge more than it says.
102
+ */
103
+ ecoRung?: number;
91
104
  }
92
105
  export declare function StatusBar(props: StatusBarProps): React.ReactElement;
93
106
  export {};
@@ -6,6 +6,8 @@ import { buildGauge } from './gauge.js';
6
6
  import { MODE_LABEL } from '../agent/agent-mode.js';
7
7
  import { TEAM_LIMITS } from '../team/limits.js';
8
8
  import { TODO_LIMITS } from '../todo/limits.js';
9
+ /** Below this the `eco` chip costs the context gauge more than it is worth. */
10
+ const ECO_MIN_COLS = 72;
9
11
  /**
10
12
  * I-5 — DO NOT "CLEAN THIS UP". Ink short-circuits identical output at TWO
11
13
  * gates (`ink.js:132` and `log-update.js:13`), so a fixed frame whose React
@@ -25,7 +27,7 @@ function redrawChar(nonce) {
25
27
  return nonce % 2 === 0 ? ' ' : NBSP;
26
28
  }
27
29
  export function StatusBar(props) {
28
- const { model, provider, usageTotal, contextTokens, contextWindow, contextWindowKnown, status, elapsedMs, thinkingLevel, tokPerSec, theme, caps, scrolledLines = 0, redrawNonce = 0, agentMode = 'build', pendingAgentMode = null, teamActive, todoActive, } = props;
30
+ const { model, provider, usageTotal, contextTokens, contextWindow, contextWindowKnown, status, elapsedMs, thinkingLevel, tokPerSec, theme, caps, scrolledLines = 0, redrawNonce = 0, agentMode = 'build', pendingAgentMode = null, teamActive, todoActive, ecoRung = 0, } = props;
29
31
  const { stdout } = useStdout();
30
32
  const cols = stdout?.columns ?? 80;
31
33
  const running = status === 'running';
@@ -53,5 +55,5 @@ export function StatusBar(props) {
53
55
  ? `agents ${teamActive.running}/${teamActive.total}`
54
56
  : `[${teamActive.running}]`] })), todoActive && (_jsxs(Text, { color: theme.accent, children: [' ', cols >= TODO_LIMITS.statusCompactCols
55
57
  ? `todo ${todoActive.done}/${todoActive.total}`
56
- : `[${todoActive.done}/${todoActive.total}]`] })), cols >= 60 && (_jsxs(Text, { color: theme.muted, children: [' ', provider, ":", model] })), thinkingLevel !== 'off' && cols >= 72 && (_jsxs(Text, { color: theme.accent, children: [" think:", thinkingLevel] }))] }), _jsxs(Box, { flexDirection: "row", children: [scrolledLines > 0 && (_jsxs(Text, { color: theme.noticeWarn, children: [glyphs.arrowUp, scrolledLines, ' '] })), cols >= 60 && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "[" }), _jsx(Text, { color: gauge.fillColor, children: gauge.filled }), _jsx(Text, { color: gauge.trackColor, children: gauge.empty }), _jsx(Text, { color: theme.muted, children: "] " })] })), _jsx(Text, { color: theme.muted, children: pctLabel }), cols >= 72 && _jsxs(Text, { color: theme.muted, children: [" ", tokens] }), _jsxs(Text, { color: theme.muted, children: [" ", formatCost(usageTotal.costUsd)] }), running && (_jsxs(Text, { color: theme.muted, children: [tokPerSec > 0 && cols >= 72 ? ` ${tokPerSec} tok/s` : '', " ", formatDuration(elapsedMs)] }))] })] }));
58
+ : `[${todoActive.done}/${todoActive.total}]`] })), cols >= 60 && (_jsxs(Text, { color: theme.muted, children: [' ', provider, ":", model] })), thinkingLevel !== 'off' && cols >= 72 && (_jsxs(Text, { color: theme.accent, children: [" think:", thinkingLevel] }))] }), _jsxs(Box, { flexDirection: "row", children: [ecoRung > 0 && cols >= ECO_MIN_COLS && (_jsxs(Text, { color: theme.muted, children: ["eco", ' '] })), scrolledLines > 0 && (_jsxs(Text, { color: theme.noticeWarn, children: [glyphs.arrowUp, scrolledLines, ' '] })), cols >= 60 && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "[" }), _jsx(Text, { color: gauge.fillColor, children: gauge.filled }), _jsx(Text, { color: gauge.trackColor, children: gauge.empty }), _jsx(Text, { color: theme.muted, children: "] " })] })), _jsx(Text, { color: theme.muted, children: pctLabel }), cols >= 72 && _jsxs(Text, { color: theme.muted, children: [" ", tokens] }), _jsxs(Text, { color: theme.muted, children: [" ", formatCost(usageTotal.costUsd)] }), running && (_jsxs(Text, { color: theme.muted, children: [tokPerSec > 0 && cols >= 72 ? ` ${tokPerSec} tok/s` : '', " ", formatDuration(elapsedMs)] }))] })] }));
57
59
  }
@@ -15,12 +15,22 @@
15
15
  * parallel product. What inline still guarantees is its GEOMETRY contract — no
16
16
  * fixed frame, `<Static>` keeps the settled history, no private ANSI — not its
17
17
  * pixel-for-pixel appearance.
18
+ *
19
+ * Two things changed with tui-render-performance:
20
+ * - `EntryView` is `React.memo`'d with an explicit comparator (L2 / R3), which
21
+ * is only worth anything because `mapEntry` already preserves object
22
+ * identity for untouched entries;
23
+ * - `TranscriptList` (the FULL-SCREEN body) mounts only the entries inside the
24
+ * viewport and replaces the rest with two spacer boxes (L3 / R1), and the
25
+ * inline `Transcript` clamps its live region so `ink.js:121` is unreachable
26
+ * (L5 / R6).
18
27
  */
19
28
  import React from 'react';
20
29
  import { type Theme } from './theme.js';
21
30
  import type { TermCapabilities } from './capabilities.js';
22
31
  import { type DensityMode } from './density.js';
23
32
  import type { Entry } from '../agent/reducer.js';
33
+ import type { HeightStore } from './use-height-store.js';
24
34
  /**
25
35
  * Largest prefix length P such that `entries[0..P)` are all terminal, none is
26
36
  * within the last `LIVE_TAIL` entries, and none is currently expanded. Pure and
@@ -37,6 +47,35 @@ interface TranscriptProps {
37
47
  theme: Theme;
38
48
  caps: TermCapabilities;
39
49
  }
50
+ export interface EntryViewProps {
51
+ entry: Entry;
52
+ /** Previous entry in the list; `undefined` for the first item. */
53
+ prev: Entry | undefined;
54
+ expanded: boolean;
55
+ thinkingVisible: boolean;
56
+ reducedMotion: boolean;
57
+ density: DensityMode;
58
+ theme: Theme;
59
+ caps: TermCapabilities;
60
+ /** Inline mode only (L5); `undefined` in full-screen, which means no clamp. */
61
+ liveClampRows?: number;
62
+ }
63
+ declare function EntryViewImpl({ entry, prev, expanded, thinkingVisible, reducedMotion, density, theme, caps, liveClampRows, }: EntryViewProps): React.ReactElement | null;
64
+ /**
65
+ * THE memo boundary that makes a long transcript cheap (L2 / R3).
66
+ *
67
+ * The comparator is written out rather than defaulted because `prev` is the one
68
+ * prop whose identity matters for a reason the type does not show: it feeds
69
+ * `separationRows`, so an entry whose PREDECESSOR changed kind must re-render
70
+ * even though the entry itself did not.
71
+ *
72
+ * I-L2-1 — `theme` and `caps` are `useMemo`d in `App.tsx` and are therefore
73
+ * referentially stable across frames. If a future change makes either a fresh
74
+ * object per render, EVERY boundary below becomes a no-op AND NOTHING FAILS;
75
+ * `render-memo.test.tsx` asserts that stability directly so the trap is a red
76
+ * test rather than a silent regression.
77
+ */
78
+ export declare const EntryView: React.MemoExoticComponent<typeof EntryViewImpl>;
40
79
  /**
41
80
  * Full-screen transcript body — the same entry renderers with NO `<Static>`.
42
81
  *
@@ -51,12 +90,25 @@ interface TranscriptProps {
51
90
  * mark feeds nothing else — `/save` serializes `entries` in full — so leaving it
52
91
  * un-advanced has no side effect.
53
92
  *
54
- * Rendering budget (I-3): yoga lays out every child even when `overflow: hidden`
55
- * clips it, so cost grows linearly with the entry count. Only the last
56
- * `windowSize` entries are rendered; the reducer and `/save` keep everything.
93
+ * VIRTUALISED (L3 / R1). `windowSize` is now the SCROLL HORIZON — how far back
94
+ * the user can scroll — and no longer the rendering budget, because entries
95
+ * outside the viewport are not mounted at all. What bounds the frame is
96
+ * `selectWindow`, and the two spacers are what keep the scroll maths honest
97
+ * while they are absent.
57
98
  */
58
- export declare function TranscriptList({ entries, expandedToolIds, thinkingVisible, reducedMotion, density, theme, caps, windowSize, }: TranscriptProps & {
99
+ export declare function TranscriptList({ entries, expandedToolIds, thinkingVisible, reducedMotion, density, theme, caps, windowSize, cols, heights, mountedSink, }: TranscriptProps & {
59
100
  windowSize: number;
101
+ /** The viewport's own width — narrower than the terminal when the rail is up. */
102
+ cols: number;
103
+ heights: HeightStore;
104
+ /**
105
+ * Written during render with the number of entries actually mounted, for
106
+ * `/perf` (AC-9). A ref rather than a callback: a `setState` here would make
107
+ * observing the render budget cost a render.
108
+ */
109
+ mountedSink?: {
110
+ current: number;
111
+ };
60
112
  }): React.ReactElement;
61
113
  export declare function Transcript({ entries, expandedToolIds, thinkingVisible, reducedMotion, density, theme, caps, }: TranscriptProps): React.ReactElement;
62
114
  export {};
@@ -16,8 +16,17 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
16
16
  * parallel product. What inline still guarantees is its GEOMETRY contract — no
17
17
  * fixed frame, `<Static>` keeps the settled history, no private ANSI — not its
18
18
  * pixel-for-pixel appearance.
19
+ *
20
+ * Two things changed with tui-render-performance:
21
+ * - `EntryView` is `React.memo`'d with an explicit comparator (L2 / R3), which
22
+ * is only worth anything because `mapEntry` already preserves object
23
+ * identity for untouched entries;
24
+ * - `TranscriptList` (the FULL-SCREEN body) mounts only the entries inside the
25
+ * viewport and replaces the rest with two spacer boxes (L3 / R1), and the
26
+ * inline `Transcript` clamps its live region so `ink.js:121` is unreachable
27
+ * (L5 / R6).
19
28
  */
20
- import { useRef } from 'react';
29
+ import React, { useCallback, useRef } from 'react';
21
30
  import { Box, Static, Text } from 'ink';
22
31
  import Spinner from 'ink-spinner';
23
32
  import { pickGlyphs, toolGlyph } from './glyphs.js';
@@ -28,8 +37,19 @@ import { AssistantEntry } from './entries/AssistantEntry.js';
28
37
  import { ToolCard, statusColor, toolEntryGlyph } from './entries/ToolCard.js';
29
38
  import { TeamCard, teamCardColor } from './entries/TeamCard.js';
30
39
  import { TodoCard, todoCardColor } from './entries/TodoCard.js';
40
+ import { MeasuredEntry } from './layout/MeasuredEntry.js';
41
+ import { useViewportGeometry } from './layout/viewport-geometry.js';
42
+ import { estimateEntryRows, heightKey, selectWindow, VIRTUAL_LIMITS, } from './layout/virtual-window.js';
43
+ import { useTerminalSize } from './layout/useTerminalSize.js';
31
44
  /** Keep the last K entries in the live region so a mutating tail stays hot. */
32
45
  const LIVE_TAIL = 1;
46
+ /**
47
+ * Rows the inline live region leaves for the composer, the prompt echo and the
48
+ * one-row margin Ink needs before `outputHeight >= stdout.rows` (I-L5-1).
49
+ */
50
+ const INLINE_LIVE_MARGIN = 4;
51
+ /** Floor on the clamp, so a very short terminal still shows something useful. */
52
+ const INLINE_LIVE_MIN_ROWS = 4;
33
53
  /**
34
54
  * Largest prefix length P such that `entries[0..P)` are all terminal, none is
35
55
  * within the last `LIVE_TAIL` entries, and none is currently expanded. Pure and
@@ -81,7 +101,7 @@ function noticeSymbol(level, theme) {
81
101
  return theme.symbols.warn;
82
102
  return theme.symbols.info;
83
103
  }
84
- function EntryView({ entry, prev, expanded, thinkingVisible, reducedMotion, density, theme, caps, }) {
104
+ function EntryViewImpl({ entry, prev, expanded, thinkingVisible, reducedMotion, density, theme, caps, liveClampRows, }) {
85
105
  const glyphs = pickGlyphs(caps);
86
106
  const separation = separationRows(prev, entry, density);
87
107
  const frame = (glyph, color, children) => (_jsx(EntryFrame, { glyph: glyph, color: color, continuation: glyphs.railVertical, separation: separation, spacerColor: theme.border, children: children }));
@@ -93,10 +113,10 @@ function EntryView({ entry, prev, expanded, thinkingVisible, reducedMotion, dens
93
113
  // both an animation and a Unicode-only glyph, so reduced motion and an
94
114
  // ASCII terminal fall back to the same static marker (P2-11 / A-10).
95
115
  const animate = entry.streaming && !reducedMotion && caps.unicode;
96
- return frame(animate ? _jsx(Spinner, { type: "dots" }) : glyphs.assistant, theme.primary, _jsx(AssistantEntry, { text: entry.text, thinking: entry.thinking, thinkingOpen: entry.thinkingOpen, thinkingVisible: thinkingVisible, streaming: entry.streaming, aborted: entry.aborted, theme: theme, caps: caps }));
116
+ return frame(animate ? _jsx(Spinner, { type: "dots" }) : glyphs.assistant, theme.primary, _jsx(AssistantEntry, { text: entry.text, thinking: entry.thinking, thinkingOpen: entry.thinkingOpen, thinkingVisible: thinkingVisible, streaming: entry.streaming, aborted: entry.aborted, liveClampRows: liveClampRows, theme: theme, caps: caps }));
97
117
  }
98
118
  case 'tool':
99
- return frame(toolEntryGlyph(entry.name, caps), statusColor(entry.status, theme), _jsx(ToolCard, { name: entry.name, label: entry.label, argsRaw: entry.argsRaw, args: entry.args, status: entry.status, preview: entry.preview, durationMs: entry.durationMs, isError: entry.isError, expanded: expanded, reducedMotion: reducedMotion, theme: theme, caps: caps }));
119
+ return frame(toolEntryGlyph(entry.name, caps), statusColor(entry.status, theme), _jsx(ToolCard, { name: entry.name, label: entry.label, argsRaw: entry.argsRaw, args: entry.args, status: entry.status, preview: entry.preview, durationMs: entry.durationMs, isError: entry.isError, expanded: expanded, reducedMotion: reducedMotion, liveClampRows: liveClampRows, theme: theme, caps: caps }));
100
120
  case 'team':
101
121
  return frame(glyphs.teamAgent, teamCardColor(entry.runs, entry.active, entry.aborted, theme), _jsx(TeamCard, { requested: entry.requested, runs: entry.runs, aborted: entry.aborted, durationMs: entry.durationMs, active: entry.active, expanded: expanded, reducedMotion: reducedMotion, theme: theme, caps: caps }));
102
122
  case 'todo':
@@ -107,6 +127,31 @@ function EntryView({ entry, prev, expanded, thinkingVisible, reducedMotion, dens
107
127
  return null;
108
128
  }
109
129
  }
130
+ /**
131
+ * THE memo boundary that makes a long transcript cheap (L2 / R3).
132
+ *
133
+ * The comparator is written out rather than defaulted because `prev` is the one
134
+ * prop whose identity matters for a reason the type does not show: it feeds
135
+ * `separationRows`, so an entry whose PREDECESSOR changed kind must re-render
136
+ * even though the entry itself did not.
137
+ *
138
+ * I-L2-1 — `theme` and `caps` are `useMemo`d in `App.tsx` and are therefore
139
+ * referentially stable across frames. If a future change makes either a fresh
140
+ * object per render, EVERY boundary below becomes a no-op AND NOTHING FAILS;
141
+ * `render-memo.test.tsx` asserts that stability directly so the trap is a red
142
+ * test rather than a silent regression.
143
+ */
144
+ export const EntryView = React.memo(EntryViewImpl, (a, b) => {
145
+ return (a.entry === b.entry &&
146
+ a.prev === b.prev &&
147
+ a.expanded === b.expanded &&
148
+ a.thinkingVisible === b.thinkingVisible &&
149
+ a.reducedMotion === b.reducedMotion &&
150
+ a.density === b.density &&
151
+ a.theme === b.theme &&
152
+ a.caps === b.caps &&
153
+ a.liveClampRows === b.liveClampRows);
154
+ });
110
155
  /**
111
156
  * Full-screen transcript body — the same entry renderers with NO `<Static>`.
112
157
  *
@@ -121,20 +166,59 @@ function EntryView({ entry, prev, expanded, thinkingVisible, reducedMotion, dens
121
166
  * mark feeds nothing else — `/save` serializes `entries` in full — so leaving it
122
167
  * un-advanced has no side effect.
123
168
  *
124
- * Rendering budget (I-3): yoga lays out every child even when `overflow: hidden`
125
- * clips it, so cost grows linearly with the entry count. Only the last
126
- * `windowSize` entries are rendered; the reducer and `/save` keep everything.
169
+ * VIRTUALISED (L3 / R1). `windowSize` is now the SCROLL HORIZON — how far back
170
+ * the user can scroll — and no longer the rendering budget, because entries
171
+ * outside the viewport are not mounted at all. What bounds the frame is
172
+ * `selectWindow`, and the two spacers are what keep the scroll maths honest
173
+ * while they are absent.
127
174
  */
128
- export function TranscriptList({ entries, expandedToolIds, thinkingVisible, reducedMotion, density, theme, caps, windowSize, }) {
175
+ export function TranscriptList({ entries, expandedToolIds, thinkingVisible, reducedMotion, density, theme, caps, windowSize, cols, heights, mountedSink, }) {
129
176
  const size = Math.max(1, Math.floor(windowSize));
130
177
  const visible = entries.length > size ? entries.slice(-size) : entries;
131
178
  const collapsed = entries.length - visible.length;
132
179
  const glyphs = pickGlyphs(caps);
133
- return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [collapsed > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: [glyphs.ellipsis, " ", collapsed, " earlier ", collapsed === 1 ? 'entry' : 'entries', " collapsed (/save exports the full session)"] })), visible.map((entry, i) => (_jsx(EntryView, { entry: entry, prev: i > 0 ? visible[i - 1] : undefined, expanded: !!expandedToolIds[entry.id], thinkingVisible: thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }, entry.id)))] }));
180
+ const geometry = useViewportGeometry();
181
+ // Ctrl+T changes every assistant entry's height without changing any entry,
182
+ // so it has to reach the cache key or the spacers freeze at the wrong size.
183
+ const flags = thinkingVisible ? 't' : '';
184
+ // ONE key builder, shared by the lookup below and by `MeasuredEntry`'s report.
185
+ // Two spellings of the same key is a SILENT failure and not a compile error:
186
+ // the measurement lands under a key `resolve` never asks for, every entry
187
+ // falls back to its estimate for the rest of the session, and the spacers
188
+ // quietly encode heights nothing will ever correct.
189
+ const keyOf = (entry) => heightKey(entry, cols, !!expandedToolIds[entry.id], density, flags);
190
+ const { resolve, report } = heights;
191
+ const heightOf = useCallback((index) => {
192
+ const entry = visible[index];
193
+ return resolve(keyOf(entry), () => estimateEntryRows(entry, cols, density, !!expandedToolIds[entry.id]));
194
+ },
195
+ // Memoised only incidentally: `visible` IS `entries` while the session is
196
+ // shorter than the horizon and a fresh slice once it is past it, so this
197
+ // rebuilds on some frames and not others. Either is correct — `selectWindow`
198
+ // consumes it synchronously below and it is never handed to a child — and
199
+ // `keyOf` is omitted from the list because every input it closes over is
200
+ // already in it.
201
+ // eslint-disable-next-line react-hooks/exhaustive-deps
202
+ [visible, expandedToolIds, cols, density, flags, resolve]);
203
+ const selection = selectWindow({
204
+ entries: visible,
205
+ heightOf,
206
+ viewportRows: geometry.viewportRows,
207
+ offset: geometry.offset,
208
+ overscan: VIRTUAL_LIMITS.overscan,
209
+ });
210
+ const slice = visible.slice(selection.startIndex, selection.endIndex);
211
+ if (mountedSink)
212
+ mountedSink.current = slice.length;
213
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [collapsed > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: [glyphs.ellipsis, " ", collapsed, " earlier ", collapsed === 1 ? 'entry' : 'entries', " collapsed (/save exports the full session)"] })), selection.leadingRows > 0 && (_jsx(Box, { flexShrink: 0, height: selection.leadingRows })), slice.map((entry, i) => {
214
+ const index = selection.startIndex + i;
215
+ return (_jsx(MeasuredEntry, { heightKey: keyOf(entry), onMeasure: report, children: _jsx(EntryView, { entry: entry, prev: index > 0 ? visible[index - 1] : undefined, expanded: !!expandedToolIds[entry.id], thinkingVisible: thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }) }, entry.id));
216
+ }), selection.trailingRows > 0 && (_jsx(Box, { flexShrink: 0, height: selection.trailingRows }))] }));
134
217
  }
135
218
  export function Transcript({ entries, expandedToolIds, thinkingVisible, reducedMotion, density, theme, caps, }) {
136
219
  const highWater = useRef(0);
137
220
  const prevLen = useRef(0);
221
+ const { rows } = useTerminalSize();
138
222
  const raw = computeSettledCount(entries, expandedToolIds);
139
223
  // The transcript shrank (clear / reset / restore) — drop the high-water mark.
140
224
  if (entries.length < prevLen.current)
@@ -144,5 +228,23 @@ export function Transcript({ entries, expandedToolIds, thinkingVisible, reducedM
144
228
  highWater.current = settled;
145
229
  const settledEntries = entries.slice(0, settled);
146
230
  const liveEntries = entries.slice(settled);
147
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: settledEntries, children: (entry, index) => (_jsx(EntryView, { entry: entry, prev: index > 0 ? settledEntries[index - 1] : undefined, expanded: !!expandedToolIds[entry.id], thinkingVisible: thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }, entry.id)) }), _jsx(Box, { flexDirection: "column", children: liveEntries.map((entry, i) => (_jsx(EntryView, { entry: entry, prev: i > 0 ? liveEntries[i - 1] : entries[settled - 1], expanded: !!expandedToolIds[entry.id], thinkingVisible: thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }, entry.id))) })] }));
231
+ // I-L5-1 — the live region must stay strictly below `stdout.rows`, or Ink
232
+ // takes the `ink.js:118-123` branch and writes `clearTerminal +
233
+ // fullStaticOutput + output` on EVERY subsequent frame for the rest of the
234
+ // session.
235
+ //
236
+ // `LIVE_TAIL` is 1, so the common case is the design's `rows - 4` less the one
237
+ // row the clamp marker costs. THE DIVISION IS NOT DECORATION: the settled
238
+ // boundary is held back by an expanded card, a running tool, a live dispatch
239
+ // or a live todo list, and in every one of those cases the region holds
240
+ // SEVERAL entries at once — a flat per-entry ceiling of `rows - 4` would then
241
+ // overrun by a multiple of itself. `- 1` pays for each entry's marker row.
242
+ //
243
+ // The residual: an entry contributes at least its header row whatever the
244
+ // clamp says, so the bound holds while the live region holds fewer than about
245
+ // `rows / 2` entries. Bounding it unconditionally would mean refusing to draw
246
+ // a live entry at all, which is worse than a tall frame.
247
+ const liveBudget = Math.max(INLINE_LIVE_MIN_ROWS, rows - INLINE_LIVE_MARGIN);
248
+ const liveClampRows = Math.max(1, Math.floor(liveBudget / Math.max(1, liveEntries.length)) - 1);
249
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: settledEntries, children: (entry, index) => (_jsx(EntryView, { entry: entry, prev: index > 0 ? settledEntries[index - 1] : undefined, expanded: !!expandedToolIds[entry.id], thinkingVisible: thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }, entry.id)) }), _jsx(Box, { flexDirection: "column", children: liveEntries.map((entry, i) => (_jsx(EntryView, { entry: entry, prev: i > 0 ? liveEntries[i - 1] : entries[settled - 1], expanded: !!expandedToolIds[entry.id], thinkingVisible: thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps, liveClampRows: liveClampRows }, entry.id))) })] }));
148
250
  }