@aragon-agent/cli 0.5.4 → 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 (100) hide show
  1. package/CHANGELOG.md +747 -577
  2. package/README.md +1163 -995
  3. package/dist/agent/controller.d.ts +71 -1
  4. package/dist/agent/controller.js +135 -1
  5. package/dist/agent/entry-limits.d.ts +65 -0
  6. package/dist/agent/entry-limits.js +133 -0
  7. package/dist/agent/headless.d.ts +29 -0
  8. package/dist/agent/headless.js +86 -1
  9. package/dist/agent/reducer.d.ts +53 -0
  10. package/dist/agent/reducer.js +145 -14
  11. package/dist/agent/system-prompt.d.ts +8 -0
  12. package/dist/agent/system-prompt.js +2 -0
  13. package/dist/cli.js +76 -3
  14. package/dist/commands/builtins.js +171 -0
  15. package/dist/commands/perf.d.ts +45 -0
  16. package/dist/commands/perf.js +78 -0
  17. package/dist/commands/registry.d.ts +27 -1
  18. package/dist/config/env.d.ts +3 -1
  19. package/dist/config/env.js +74 -1
  20. package/dist/config/load.d.ts +41 -0
  21. package/dist/config/load.js +67 -2
  22. package/dist/config/schema.d.ts +108 -8
  23. package/dist/config/schema.js +81 -6
  24. package/dist/config/store.js +10 -1
  25. package/dist/session/persist.d.ts +19 -1
  26. package/dist/session/persist.js +21 -2
  27. package/dist/team/subagent.js +11 -0
  28. package/dist/todo/follow-through.d.ts +117 -0
  29. package/dist/todo/follow-through.js +186 -0
  30. package/dist/todo/limits.d.ts +114 -0
  31. package/dist/todo/limits.js +114 -0
  32. package/dist/todo/normalize.d.ts +33 -0
  33. package/dist/todo/normalize.js +117 -0
  34. package/dist/todo/panel-rows.d.ts +53 -0
  35. package/dist/todo/panel-rows.js +103 -0
  36. package/dist/todo/prompt.d.ts +54 -0
  37. package/dist/todo/prompt.js +76 -0
  38. package/dist/todo/store.d.ts +86 -0
  39. package/dist/todo/store.js +183 -0
  40. package/dist/todo/todo-tool.d.ts +38 -0
  41. package/dist/todo/todo-tool.js +134 -0
  42. package/dist/todo/types.d.ts +62 -0
  43. package/dist/todo/types.js +12 -0
  44. package/dist/tools/index.d.ts +16 -2
  45. package/dist/tools/index.js +13 -0
  46. package/dist/ui/App.js +328 -14
  47. package/dist/ui/Markdown.d.ts +7 -1
  48. package/dist/ui/Markdown.js +15 -48
  49. package/dist/ui/StatusBar.d.ts +31 -0
  50. package/dist/ui/StatusBar.js +7 -2
  51. package/dist/ui/TodoPanel.d.ts +48 -0
  52. package/dist/ui/TodoPanel.js +74 -0
  53. package/dist/ui/TodoStrip.d.ts +36 -0
  54. package/dist/ui/TodoStrip.js +32 -0
  55. package/dist/ui/Transcript.d.ts +56 -4
  56. package/dist/ui/Transcript.js +123 -11
  57. package/dist/ui/entries/AssistantEntry.d.ts +18 -1
  58. package/dist/ui/entries/AssistantEntry.js +33 -3
  59. package/dist/ui/entries/TeamCard.d.ts +9 -1
  60. package/dist/ui/entries/TeamCard.js +22 -1
  61. package/dist/ui/entries/TodoCard.d.ts +39 -0
  62. package/dist/ui/entries/TodoCard.js +57 -0
  63. package/dist/ui/entries/ToolCard.d.ts +13 -1
  64. package/dist/ui/entries/ToolCard.js +39 -5
  65. package/dist/ui/entries/ToolPreview.d.ts +2 -2
  66. package/dist/ui/entries/UserEntry.d.ts +9 -1
  67. package/dist/ui/entries/UserEntry.js +17 -1
  68. package/dist/ui/exit-snapshot.d.ts +7 -0
  69. package/dist/ui/glyphs.d.ts +12 -0
  70. package/dist/ui/glyphs.js +8 -0
  71. package/dist/ui/layout/AppShell.d.ts +34 -1
  72. package/dist/ui/layout/AppShell.js +3 -3
  73. package/dist/ui/layout/MeasuredEntry.d.ts +21 -0
  74. package/dist/ui/layout/MeasuredEntry.js +30 -0
  75. package/dist/ui/layout/ScrollViewport.d.ts +13 -1
  76. package/dist/ui/layout/ScrollViewport.js +10 -4
  77. package/dist/ui/layout/rail.d.ts +56 -0
  78. package/dist/ui/layout/rail.js +68 -0
  79. package/dist/ui/layout/viewport-geometry.d.ts +28 -0
  80. package/dist/ui/layout/viewport-geometry.js +27 -0
  81. package/dist/ui/layout/virtual-window.d.ts +100 -0
  82. package/dist/ui/layout/virtual-window.js +204 -0
  83. package/dist/ui/live-clamp.d.ts +42 -0
  84. package/dist/ui/live-clamp.js +74 -0
  85. package/dist/ui/markdown-blocks.d.ts +25 -1
  86. package/dist/ui/markdown-blocks.js +43 -0
  87. package/dist/ui/overlays/HelpOverlay.js +4 -0
  88. package/dist/ui/render-cache.d.ts +81 -0
  89. package/dist/ui/render-cache.js +195 -0
  90. package/dist/ui/render-governor.d.ts +54 -0
  91. package/dist/ui/render-governor.js +78 -0
  92. package/dist/ui/transcript-text.d.ts +11 -0
  93. package/dist/ui/transcript-text.js +5 -0
  94. package/dist/ui/use-height-store.d.ts +31 -0
  95. package/dist/ui/use-height-store.js +76 -0
  96. package/dist/ui/use-render-governor.d.ts +39 -0
  97. package/dist/ui/use-render-governor.js +57 -0
  98. package/dist/ui/use-startup-notices.d.ts +9 -1
  99. package/dist/ui/use-startup-notices.js +35 -1
  100. package/package.json +3 -2
@@ -5,6 +5,9 @@ import { formatCost, formatTokens, formatDuration } from '../agent/usage.js';
5
5
  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
+ 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;
8
11
  /**
9
12
  * I-5 — DO NOT "CLEAN THIS UP". Ink short-circuits identical output at TWO
10
13
  * gates (`ink.js:132` and `log-update.js:13`), so a fixed frame whose React
@@ -24,7 +27,7 @@ function redrawChar(nonce) {
24
27
  return nonce % 2 === 0 ? ' ' : NBSP;
25
28
  }
26
29
  export function StatusBar(props) {
27
- const { model, provider, usageTotal, contextTokens, contextWindow, contextWindowKnown, status, elapsedMs, thinkingLevel, tokPerSec, theme, caps, scrolledLines = 0, redrawNonce = 0, agentMode = 'build', pendingAgentMode = null, teamActive, } = 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;
28
31
  const { stdout } = useStdout();
29
32
  const cols = stdout?.columns ?? 80;
30
33
  const running = status === 'running';
@@ -50,5 +53,7 @@ export function StatusBar(props) {
50
53
  // land in one predictable place.
51
54
  _jsxs(Box, { flexDirection: "row", justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsxs(Text, { color: running ? theme.toolRunning : theme.toolDone, children: [redrawChar(redrawNonce), statusGlyph, " ", running ? 'running' : 'idle'] }), modeWord && (_jsxs(Text, { color: theme.accent, bold: true, children: [' ', modeWord] })), teamActive && (_jsxs(Text, { color: theme.toolRunning, bold: true, children: [' ', cols >= TEAM_LIMITS.statusCompactCols
52
55
  ? `agents ${teamActive.running}/${teamActive.total}`
53
- : `[${teamActive.running}]`] })), 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)] }))] })] }));
56
+ : `[${teamActive.running}]`] })), todoActive && (_jsxs(Text, { color: theme.accent, children: [' ', cols >= TODO_LIMITS.statusCompactCols
57
+ ? `todo ${todoActive.done}/${todoActive.total}`
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)] }))] })] }));
54
59
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * TodoPanel — the right rail (todo-plan-execution §6.1).
3
+ *
4
+ * ASCII ONLY: every glyph comes from `pickGlyphs`; a literal here bypasses the
5
+ * capability probe and shows mojibake on a legacy console (`glyphs.ts` header).
6
+ *
7
+ * MOUNTED ONLY WHEN A LIST EXISTS. `App` passes `null` the rest of the time and
8
+ * this component is not rendered at all, so a session that never plans has zero
9
+ * columns and zero layout shift (R-g's "no furniture when idle" / AC-24).
10
+ *
11
+ * IT MUST BE MOUNTED IN `AppShell`'s MIDDLE BAND, NEVER INSIDE `bottomRef`
12
+ * (C-6 / I-4). That box is MEASURED and the wheel router reads the composer band
13
+ * boundary from it; a column added there would inflate the measurement and start
14
+ * routing transcript scrolls into prompt history.
15
+ *
16
+ * WIDTH IS MEASURED IN COLUMNS, NEVER IN CODE UNITS (P2-3). This rail is 18-36
17
+ * columns wide and the system prompt's last line is "Respond in the language the
18
+ * user writes in", so CJK item text in an 18-column rail is the NORMAL case
19
+ * rather than the edge case. Every text cell is `wrap="truncate"` and Ink's
20
+ * truncation is display-width aware, so nothing can overflow the box — and NO
21
+ * ARITHMETIC HERE MAY BUDGET USER TEXT BY `.length`. The `padEnd(3)` /
22
+ * `padStart(2)` cells below are ASCII markers and indices, which is why they are
23
+ * safe; a "characters that fit" calculation over `content` would not be. That is
24
+ * the whole of the lesson `TeamPanel`'s `WIDE_CHAR_ALLOWANCE` records at length,
25
+ * and the mitigation here is to have no such budget at all.
26
+ */
27
+ import React from 'react';
28
+ import type { Theme } from './theme.js';
29
+ import type { TermCapabilities } from './capabilities.js';
30
+ import type { TodoSnapshot } from '../todo/types.js';
31
+ export interface TodoPanelProps {
32
+ snapshot: TodoSnapshot;
33
+ /** From `todoRailWidth(cols)`. The rail's whole column budget, separator included. */
34
+ width: number;
35
+ /**
36
+ * From `todoRailRows(viewportBudget, teamActive)` — NOT `viewportBudget`
37
+ * (I-10 / P1-3). The gate that mounts this component and the number it lays
38
+ * out against are ONE value computed once in `App`: a panel that mounts on one
39
+ * number and lays out against another renders nothing and reports nothing.
40
+ */
41
+ rows: number;
42
+ /** Whether the agent is running; gates the spinner (never animate when idle). */
43
+ running: boolean;
44
+ reducedMotion: boolean;
45
+ theme: Theme;
46
+ caps: TermCapabilities;
47
+ }
48
+ export declare function TodoPanel({ snapshot, width, rows, running, reducedMotion, theme, caps, }: TodoPanelProps): React.ReactElement;
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import { pickGlyphs } from './glyphs.js';
5
+ import { buildGauge } from './gauge.js';
6
+ import { railBorderProps } from './layout/Gutter.js';
7
+ import { TODO_RAIL_INDEX_MIN_COLS } from './layout/rail.js';
8
+ import { TODO_LIMITS } from '../todo/limits.js';
9
+ import { selectTodoRows } from '../todo/panel-rows.js';
10
+ /** Header row + the blank row under the gauge. */
11
+ const FIXED_ROWS = 2;
12
+ /** The gauge is the one purely decorative element, so it is the first to go. */
13
+ const GAUGE_MIN_ROWS = 8;
14
+ const GAUGE_MIN_COLS = 20;
15
+ /** Separator column + one space of padding, supplied by `railBorderProps`. */
16
+ const CHROME_COLS = 2;
17
+ /** Marker cell. Three, because the ASCII tier's `[x]` / `[ ]` are three wide. */
18
+ const MARKER_COLS = 3;
19
+ /** Index cell: two digits plus the space that separates it from the text. */
20
+ const INDEX_COLS = 3;
21
+ function marker(item, glyphs) {
22
+ if (item.status === 'completed')
23
+ return glyphs.todoDone;
24
+ if (item.status === 'in_progress')
25
+ return glyphs.todoActive;
26
+ return glyphs.todoPending;
27
+ }
28
+ function rowColor(item, theme) {
29
+ if (item.status === 'completed')
30
+ return theme.muted;
31
+ if (item.status === 'in_progress')
32
+ return theme.primary;
33
+ return theme.hintFg ?? theme.muted;
34
+ }
35
+ export function TodoPanel({ snapshot, width, rows, running, reducedMotion, theme, caps, }) {
36
+ const glyphs = pickGlyphs(caps);
37
+ const showGauge = width >= GAUGE_MIN_COLS && rows >= GAUGE_MIN_ROWS;
38
+ const showIndex = width >= TODO_RAIL_INDEX_MIN_COLS;
39
+ const itemRows = Math.max(1, rows - FIXED_ROWS - (showGauge ? 1 : 0));
40
+ const { visible, hiddenAbove, hiddenBelow } = selectTodoRows(snapshot.items, itemRows);
41
+ const pct = snapshot.total > 0 ? Math.round((snapshot.doneCount / snapshot.total) * 100) : 0;
42
+ const gauge = buildGauge(pct, Math.max(4, width - CHROME_COLS - 2), theme, caps);
43
+ const complete = snapshot.doneCount === snapshot.total;
44
+ return (
45
+ // `flexShrink={0}` IS LOAD-BEARING (P1-4). Ink's `Box.defaultProps` is
46
+ // `{ flexWrap:'nowrap', flexDirection:'row', flexGrow:0, flexShrink:1 }`, so
47
+ // a `width={n}` box inside a shrinking row is SQUEEZED under pressure — and
48
+ // every piece of arithmetic in `rail.ts` silently becomes a suggestion at
49
+ // exactly the narrow widths where the guarantee matters. `ScrollIndicator`
50
+ // spells the same pair one level down. Cross-axis height needs no prop:
51
+ // `alignItems` is unset in this tree and yoga's default is `stretch`.
52
+ _jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, overflow: "hidden", paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { wrap: "truncate", color: theme.accent, bold: true, children: "TODO" }), _jsxs(Text, { wrap: "truncate", color: complete ? theme.toolDone : theme.muted, children: [snapshot.doneCount, "/", snapshot.total, complete ? ' done' : ''] })] }), showGauge && (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: gauge.fillColor, children: gauge.filled }), _jsx(Text, { color: gauge.trackColor, children: gauge.empty })] })), _jsx(Text, { children: " " }), hiddenAbove > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', "+", hiddenAbove, " above"] })), visible.map(({ item, index }) => {
53
+ const active = item.status === 'in_progress';
54
+ // The spinner is gated on `running`: a spinner while the agent is idle
55
+ // is a lie about the state of the world, and this panel outlives the run
56
+ // that filled it. Reduced motion and a non-Unicode terminal fall back to
57
+ // the same static marker, exactly as `AssistantEntry` and `TeamPanel` do.
58
+ const animate = active && running && !reducedMotion && caps.unicode;
59
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexShrink: 0, width: MARKER_COLS, children: _jsx(Text, { color: rowColor(item, theme), bold: active, children: animate ? _jsx(Spinner, { type: "dots" }) : marker(item, glyphs) }) }), showIndex && (_jsx(Box, { flexShrink: 0, width: INDEX_COLS, children: _jsx(Text, { color: theme.muted, children: String(index + 1).padStart(2) }) })), _jsx(Box, { flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Text, { wrap: active ? 'wrap' : 'truncate', color: rowColor(item, theme), bold: active, children: active ? clampWrapped(item.activeForm, width) : item.content }) })] }, `${index}-${item.content}`));
60
+ }), hiddenBelow > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', "+", hiddenBelow, " more"] }))] }));
61
+ }
62
+ /**
63
+ * Keep the wrapping row inside `TODO_LIMITS.activeWrapRows`.
64
+ *
65
+ * A CHARACTER BUDGET IS NOT A COLUMN BUDGET (P2-3), so this is deliberately
66
+ * generous rather than exact: it exists to stop a maximum-length `activeForm`
67
+ * from claiming five rows of an 18-column rail, and Ink's display-width-aware
68
+ * truncation is what actually keeps the row inside the box. Erring long costs
69
+ * nothing — the surrounding `overflow="hidden"` clips it.
70
+ */
71
+ function clampWrapped(text, width) {
72
+ const budget = Math.max(1, width - CHROME_COLS) * TODO_LIMITS.activeWrapRows;
73
+ return text.length > budget ? text.slice(0, budget) : text;
74
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * TodoStrip — the inline-mode plan surface (todo-plan-followthrough §3.7 / W2).
3
+ *
4
+ * ASCII ONLY: every glyph comes from `pickGlyphs`; a literal here bypasses the
5
+ * capability probe and shows mojibake on a legacy console. This file is inside
6
+ * the glyph scanner's scope because it is under `ui/`, NOT because of the
7
+ * `todo/` clause (C-4) — moving it would not move the guard rail with it.
8
+ *
9
+ * WHY A STRIP AND NOT THE RAIL. `decideRenderMode` picks inline for every pipe,
10
+ * every `CI=1`, every `TERM=dumb` and every terminal under 12 rows, and inline
11
+ * has no fixed frame to hang a column off (round 1's non-goal 4). Before this,
12
+ * such a session had the tool and a `todo 3/7` counter in the status bar, and no
13
+ * way at all to see WHICH step was running (OQ-1).
14
+ *
15
+ * WHY NOT IN THE STATUS BAR. The bar's left cluster is `flexShrink={0}` and its
16
+ * own comment records what happens when the row over-subscribes: Ink drops a
17
+ * character from EACH cluster and it renders `idl` / `? hel`. A variable-length
18
+ * step title cannot go there (C-7), which is what forces this into its own row.
19
+ *
20
+ * EXACTLY ONE RENDERED ROW, ALWAYS (AC-29). A strip that wrapped would push the
21
+ * composer down by an unpredictable number of rows on every keystroke, which is
22
+ * the worst possible behaviour for the mode with no frame to absorb it — hence
23
+ * `wrap="truncate"` inside a `flexGrow`/`flexShrink` box and no exceptions.
24
+ */
25
+ import React from 'react';
26
+ import type { Theme } from './theme.js';
27
+ import type { TermCapabilities } from './capabilities.js';
28
+ import type { TodoSnapshot } from '../todo/types.js';
29
+ export interface TodoStripProps {
30
+ snapshot: TodoSnapshot;
31
+ /** Full terminal width; the strip spans the row in inline mode. */
32
+ cols: number;
33
+ theme: Theme;
34
+ caps: TermCapabilities;
35
+ }
36
+ export declare function TodoStrip({ snapshot, cols, theme, caps }: TodoStripProps): React.ReactElement;
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { pickGlyphs } from './glyphs.js';
4
+ import { TODO_LIMITS } from '../todo/limits.js';
5
+ import { todoAnchorIndex } from '../todo/panel-rows.js';
6
+ export function TodoStrip({ snapshot, cols, theme, caps }) {
7
+ const glyphs = pickGlyphs(caps);
8
+ // THE SAME FUNCTION THE RAIL USES (R-9 / AC-30). Two surfaces that each decide
9
+ // for themselves which step is "current" is two surfaces that disagree, and
10
+ // this one is on screen in exactly the mode where the rail is not, so the
11
+ // disagreement would never be seen side by side and never be reported.
12
+ const anchor = todoAnchorIndex(snapshot.items);
13
+ const item = snapshot.items[anchor];
14
+ const active = item?.status === 'in_progress';
15
+ // `activeForm` while in progress, `content` otherwise — `TodoPanel`'s rule.
16
+ const label = item ? (active ? item.activeForm : item.content) : '';
17
+ // The same degradation the status bar already applies, so the two counters can
18
+ // never disagree about how much room they need.
19
+ const counter = cols >= TODO_LIMITS.statusCompactCols
20
+ ? `todo ${snapshot.doneCount}/${snapshot.total}`
21
+ : `[${snapshot.doneCount}/${snapshot.total}]`;
22
+ const showDone = snapshot.doneCount > 0 && cols >= TODO_LIMITS.stripDoneCols;
23
+ return (
24
+ // `width` IS LOAD-BEARING, exactly as it is on `TodoPanel` and for a related
25
+ // reason. Inline mode has no fixed frame, so nothing upstream constrains
26
+ // this row: without an explicit width the flexible middle cell is sized by
27
+ // whatever the parent offers, and a long item overflows the terminal and
28
+ // WRAPS — the one thing this component exists not to do. Pinning it here
29
+ // makes the truncation deterministic and self-contained rather than a
30
+ // property of the tree above.
31
+ _jsxs(Box, { flexDirection: "row", width: cols, overflow: "hidden", children: [_jsx(Box, { flexShrink: 0, children: _jsx(Text, { wrap: "truncate", color: theme.accent, children: counter }) }), _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', glyphs.arrowRight, ' '] }) }), _jsx(Box, { flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Text, { wrap: "truncate", color: active ? theme.primary : theme.muted, bold: active, children: label }) }), showDone && (_jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', "+", snapshot.doneCount, " done"] }) }))] }));
32
+ }
@@ -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,19 +16,40 @@ 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
- import { pickGlyphs } from './glyphs.js';
32
+ import { pickGlyphs, toolGlyph } from './glyphs.js';
24
33
  import { separationRows } from './density.js';
25
34
  import { EntryFrame } from './entries/EntryFrame.js';
26
35
  import { UserEntry } from './entries/UserEntry.js';
27
36
  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';
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';
30
44
  /** Keep the last K entries in the live region so a mutating tail stays hot. */
31
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;
32
53
  /**
33
54
  * Largest prefix length P such that `entries[0..P)` are all terminal, none is
34
55
  * within the last `LIVE_TAIL` entries, and none is currently expanded. Pure and
@@ -55,6 +76,13 @@ export function computeSettledCount(entries, expandedToolIds) {
55
76
  // session — the failure `session/persist.ts` normalizes away on load (P1-5).
56
77
  if (e.kind === 'team' && e.active)
57
78
  break;
79
+ // A live todo card is not settled either: it is rewritten on every
80
+ // `todoUpdate` for the whole turn, and `<Static>` cannot un-print what it has
81
+ // already drawn (C-5 / I-6). `session/persist.ts` forces `live: false` on
82
+ // load, which is the other half — a card that never settles is re-rendered
83
+ // on every frame for the rest of the session.
84
+ if (e.kind === 'todo' && e.live)
85
+ break;
58
86
  count = i + 1;
59
87
  }
60
88
  return count;
@@ -73,7 +101,7 @@ function noticeSymbol(level, theme) {
73
101
  return theme.symbols.warn;
74
102
  return theme.symbols.info;
75
103
  }
76
- function EntryView({ entry, prev, expanded, thinkingVisible, reducedMotion, density, theme, caps, }) {
104
+ function EntryViewImpl({ entry, prev, expanded, thinkingVisible, reducedMotion, density, theme, caps, liveClampRows, }) {
77
105
  const glyphs = pickGlyphs(caps);
78
106
  const separation = separationRows(prev, entry, density);
79
107
  const frame = (glyph, color, children) => (_jsx(EntryFrame, { glyph: glyph, color: color, continuation: glyphs.railVertical, separation: separation, spacerColor: theme.border, children: children }));
@@ -85,18 +113,45 @@ function EntryView({ entry, prev, expanded, thinkingVisible, reducedMotion, dens
85
113
  // both an animation and a Unicode-only glyph, so reduced motion and an
86
114
  // ASCII terminal fall back to the same static marker (P2-11 / A-10).
87
115
  const animate = entry.streaming && !reducedMotion && caps.unicode;
88
- 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 }));
89
117
  }
90
118
  case 'tool':
91
- 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 }));
92
120
  case 'team':
93
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 }));
122
+ case 'todo':
123
+ return frame(toolGlyph(glyphs, 'todo_write'), todoCardColor(entry.doneCount, entry.total, entry.live, theme), _jsx(TodoCard, { items: entry.items, doneCount: entry.doneCount, total: entry.total, live: entry.live, interrupted: entry.interrupted, theme: theme, caps: caps }));
94
124
  case 'notice':
95
125
  return frame(noticeSymbol(entry.level, theme), noticeColor(entry.level, theme), _jsx(Text, { color: noticeColor(entry.level, theme), children: entry.text }));
96
126
  default:
97
127
  return null;
98
128
  }
99
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
+ });
100
155
  /**
101
156
  * Full-screen transcript body — the same entry renderers with NO `<Static>`.
102
157
  *
@@ -111,20 +166,59 @@ function EntryView({ entry, prev, expanded, thinkingVisible, reducedMotion, dens
111
166
  * mark feeds nothing else — `/save` serializes `entries` in full — so leaving it
112
167
  * un-advanced has no side effect.
113
168
  *
114
- * Rendering budget (I-3): yoga lays out every child even when `overflow: hidden`
115
- * clips it, so cost grows linearly with the entry count. Only the last
116
- * `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.
117
174
  */
118
- 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, }) {
119
176
  const size = Math.max(1, Math.floor(windowSize));
120
177
  const visible = entries.length > size ? entries.slice(-size) : entries;
121
178
  const collapsed = entries.length - visible.length;
122
179
  const glyphs = pickGlyphs(caps);
123
- 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 }))] }));
124
217
  }
125
218
  export function Transcript({ entries, expandedToolIds, thinkingVisible, reducedMotion, density, theme, caps, }) {
126
219
  const highWater = useRef(0);
127
220
  const prevLen = useRef(0);
221
+ const { rows } = useTerminalSize();
128
222
  const raw = computeSettledCount(entries, expandedToolIds);
129
223
  // The transcript shrank (clear / reset / restore) — drop the high-water mark.
130
224
  if (entries.length < prevLen.current)
@@ -134,5 +228,23 @@ export function Transcript({ entries, expandedToolIds, thinkingVisible, reducedM
134
228
  highWater.current = settled;
135
229
  const settledEntries = entries.slice(0, settled);
136
230
  const liveEntries = entries.slice(settled);
137
- 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))) })] }));
138
250
  }
@@ -5,6 +5,13 @@
5
5
  *
6
6
  * The role glyph and spacing belong to `EntryFrame` (§4.3) — including the
7
7
  * streaming spinner, which IS the role marker while text is arriving.
8
+ *
9
+ * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3):
10
+ * every prop here is a scalar except `theme` and `caps`, both of which are
11
+ * `useMemo`d in `App.tsx` and therefore referentially stable across frames
12
+ * (I-L2-1, asserted by `render-memo.test.tsx`). If either ever becomes a fresh
13
+ * object per render this boundary silently becomes a no-op — which is exactly
14
+ * why that assertion exists rather than a comment.
8
15
  */
9
16
  import React from 'react';
10
17
  import type { Theme } from '../theme.js';
@@ -16,8 +23,18 @@ interface AssistantEntryProps {
16
23
  thinkingVisible: boolean;
17
24
  streaming: boolean;
18
25
  aborted?: boolean;
26
+ /**
27
+ * Inline mode only (L5): keep at most this many rows of a LIVE entry, so the
28
+ * non-`<Static>` region cannot reach `stdout.rows`. `undefined` means no
29
+ * clamp on the ANSWER BODY, which is what the full-screen branch passes — so
30
+ * the rendered answer there is byte-identical to a pre-feature build. (The
31
+ * thinking block has its own streaming clamp below and is not covered by that
32
+ * statement.)
33
+ */
34
+ liveClampRows?: number;
19
35
  theme: Theme;
20
36
  caps: TermCapabilities;
21
37
  }
22
- export declare function AssistantEntry(props: AssistantEntryProps): React.ReactElement | null;
38
+ declare function AssistantEntryImpl(props: AssistantEntryProps): React.ReactElement | null;
39
+ export declare const AssistantEntry: React.MemoExoticComponent<typeof AssistantEntryImpl>;
23
40
  export {};
@@ -1,13 +1,43 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Render a streaming assistant entry: an optional collapsible thinking block
4
+ * above the markdown-rendered answer text. Empty entries render nothing (a turn
5
+ * that only produced tool calls has no assistant text).
6
+ *
7
+ * The role glyph and spacing belong to `EntryFrame` (§4.3) — including the
8
+ * streaming spinner, which IS the role marker while text is arriving.
9
+ *
10
+ * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3):
11
+ * every prop here is a scalar except `theme` and `caps`, both of which are
12
+ * `useMemo`d in `App.tsx` and therefore referentially stable across frames
13
+ * (I-L2-1, asserted by `render-memo.test.tsx`). If either ever becomes a fresh
14
+ * object per render this boundary silently becomes a no-op — which is exactly
15
+ * why that assertion exists rather than a comment.
16
+ */
17
+ import React from 'react';
2
18
  import { Box, Text } from 'ink';
3
19
  import { pickGlyphs } from '../glyphs.js';
4
20
  import { Markdown } from '../Markdown.js';
5
- export function AssistantEntry(props) {
6
- const { text, thinking, thinkingOpen, thinkingVisible, streaming, aborted, theme, caps } = props;
21
+ import { splitLinesCached } from '../render-cache.js';
22
+ import { clampLiveText } from '../live-clamp.js';
23
+ function AssistantEntryImpl(props) {
24
+ const { text, thinking, thinkingOpen, thinkingVisible, streaming, aborted, liveClampRows, theme, caps, } = props;
7
25
  const glyphs = pickGlyphs(caps);
8
26
  const hasThinking = !!thinking && thinking.trim().length > 0;
9
27
  const hasText = text.trim().length > 0;
10
28
  if (!hasThinking && !hasText && !streaming)
11
29
  return null;
12
- return (_jsxs(Box, { flexDirection: "column", children: [hasThinking && thinkingVisible && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate", color: theme.thinking, children: [glyphs.thinking, " thinking", thinkingOpen ? glyphs.ellipsis : ''] }), _jsx(Box, { flexDirection: "column", marginLeft: 2, children: thinking.split('\n').map((line, i) => (_jsx(Text, { color: theme.thinking, dimColor: true, children: line }, i))) })] })), (hasText || streaming) && (_jsxs(Box, { flexDirection: "column", children: [hasText ? (_jsx(Markdown, { text: text, theme: theme, caps: caps })) : (_jsx(Text, { color: theme.muted, children: glyphs.ellipsis })), aborted && _jsx(Text, { color: theme.noticeWarn, children: "[aborted]" })] }))] }));
30
+ // The thinking block is clamped to its TAIL while streaming even in
31
+ // full-screen mode: it is scratch reasoning the user reads the newest end of,
32
+ // and an unbounded one is the single easiest way to push a live entry past the
33
+ // whole viewport.
34
+ const thinkingRows = liveClampRows ?? (streaming ? STREAMING_THINKING_ROWS : undefined);
35
+ const clampedThinking = hasThinking && thinkingRows !== undefined
36
+ ? clampLiveText(thinking, thinkingRows)
37
+ : { text: thinking ?? '', hiddenRows: 0 };
38
+ const body = liveClampRows === undefined ? { text, hiddenRows: 0 } : clampLiveText(text, liveClampRows);
39
+ return (_jsxs(Box, { flexDirection: "column", children: [hasThinking && thinkingVisible && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate", color: theme.thinking, children: [glyphs.thinking, " thinking", thinkingOpen ? glyphs.ellipsis : ''] }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [clampedThinking.hiddenRows > 0 && (_jsxs(Text, { color: theme.muted, children: [glyphs.ellipsis, " ", clampedThinking.hiddenRows, " earlier", ' ', clampedThinking.hiddenRows === 1 ? 'line' : 'lines'] })), splitLinesCached(clampedThinking.text).map((line, i) => (_jsx(Text, { color: theme.thinking, dimColor: true, children: line }, i)))] })] })), (hasText || streaming) && (_jsxs(Box, { flexDirection: "column", children: [body.hiddenRows > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [glyphs.ellipsis, " ", body.hiddenRows, " earlier", ' ', body.hiddenRows === 1 ? 'line' : 'lines', " ", glyphs.midDot, " shown in full when this entry finishes"] })), hasText ? (_jsx(Markdown, { text: body.text, theme: theme, caps: caps })) : (_jsx(Text, { color: theme.muted, children: glyphs.ellipsis })), aborted && _jsx(Text, { color: theme.noticeWarn, children: "[aborted]" })] }))] }));
13
40
  }
41
+ /** Rows of live thinking kept on screen while an answer streams. */
42
+ const STREAMING_THINKING_ROWS = 24;
43
+ export const AssistantEntry = React.memo(AssistantEntryImpl);
@@ -28,4 +28,12 @@ export interface TeamCardProps {
28
28
  }
29
29
  /** The card's aggregate colour: any failure dominates. */
30
30
  export declare function teamCardColor(runs: SubagentRun[], active: boolean, aborted: boolean, theme: Theme): string | undefined;
31
- export declare function TeamCard(props: TeamCardProps): React.ReactElement;
31
+ declare function TeamCardImpl(props: TeamCardProps): React.ReactElement;
32
+ /**
33
+ * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
34
+ * `mapEntry` already preserves object identity for untouched entries, so the
35
+ * array props below are stable references on a settled card, and `theme` /
36
+ * `caps` are `useMemo`d in `App.tsx` (I-L2-1).
37
+ */
38
+ export declare const TeamCard: React.MemoExoticComponent<typeof TeamCardImpl>;
39
+ export {};