@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
@@ -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 {};
@@ -1,4 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * TeamCard — the settled-dispatch transcript entry (team-subagents §6.3).
4
+ *
5
+ * ASCII ONLY: every glyph comes from `pickGlyphs`.
6
+ *
7
+ * The panel is EPHEMERAL and this card is the HISTORY. It renders like a
8
+ * `ToolCard` — same `EntryFrame` supplied by `EntryView`, same left rail for the
9
+ * expanded body — so a dispatch reads as one more step in the transcript rather
10
+ * than a second kind of object.
11
+ *
12
+ * `Ctrl+O` expands it to the per-agent summaries, which is why `App.tsx`'s
13
+ * Ctrl+O finder had to widen from `kind === 'tool'` to `tool || team`.
14
+ */
15
+ import React from 'react';
2
16
  import { Box, Text } from 'ink';
3
17
  import Spinner from 'ink-spinner';
4
18
  import { pickGlyphs } from '../glyphs.js';
@@ -15,7 +29,7 @@ export function teamCardColor(runs, active, aborted, theme) {
15
29
  return theme.toolError;
16
30
  return theme.toolDone;
17
31
  }
18
- export function TeamCard(props) {
32
+ function TeamCardImpl(props) {
19
33
  const { requested, runs, aborted, durationMs, active, expanded, reducedMotion, theme, caps } = props;
20
34
  const glyphs = pickGlyphs(caps);
21
35
  const color = teamCardColor(runs, active, aborted, theme);
@@ -37,3 +51,10 @@ export function TeamCard(props) {
37
51
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.accent, bold: true, children: "team" }), _jsxs(Text, { color: theme.muted, children: [' ', count, dropped, ' '] }), active && !reducedMotion && caps.unicode ? (_jsxs(Text, { color: color, children: [_jsx(Spinner, { type: "dots" }), " ", headline] })) : (_jsxs(Text, { color: color, children: [active ? `${glyphs.spinnerStill} ` : '', headline] }))] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [runs.map((run) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: isOk(run) ? theme.toolDone : theme.toolError, children: isOk(run) ? glyphs.toolDone : glyphs.toolError }), _jsxs(Text, { color: theme.primary, children: [" ", run.label.padEnd(8).slice(0, 8)] }), _jsxs(Text, { color: theme.muted, children: [' ', run.description, ' ', run.startedAt ? formatDuration(Math.max(0, (run.endedAt ?? 0) - run.startedAt)) : '', ` ${run.turns} turns`, run.filesTouched.length > 0 ? ` ${run.filesTouched.length} files` : '', run.error ? ` ${run.error}` : ''] })] }, run.label))), expanded &&
38
52
  runs.map((run) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.accent, children: [run.label, " ", glyphs.midDot, " ", run.description] }), _jsx(Text, { color: theme.muted, children: run.summary ?? '(no summary)' })] }, `s-${run.label}`))), !expanded && !active && runs.length > 0 && (_jsxs(Text, { color: theme.muted, children: ["+", runs.length, " ", runs.length === 1 ? 'summary' : 'summaries', " (Ctrl+O)"] })), expanded && _jsx(Text, { color: theme.muted, children: "(Ctrl+O to collapse)" })] })] }));
39
53
  }
54
+ /**
55
+ * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
56
+ * `mapEntry` already preserves object identity for untouched entries, so the
57
+ * array props below are stable references on a settled card, and `theme` /
58
+ * `caps` are `useMemo`d in `App.tsx` (I-L2-1).
59
+ */
60
+ export const TeamCard = React.memo(TeamCardImpl);
@@ -28,4 +28,12 @@ export interface TodoCardProps {
28
28
  }
29
29
  /** The card's aggregate colour: complete is done, anything else is in flight. */
30
30
  export declare function todoCardColor(doneCount: number, total: number, live: boolean, theme: Theme): string | undefined;
31
- export declare function TodoCard(props: TodoCardProps): React.ReactElement;
31
+ declare function TodoCardImpl(props: TodoCardProps): 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 TodoCard: React.MemoExoticComponent<typeof TodoCardImpl>;
39
+ export {};
@@ -1,4 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * TodoCard — the transcript checklist entry (todo-plan-execution §6.2).
4
+ *
5
+ * ASCII ONLY: every glyph comes from `pickGlyphs`.
6
+ *
7
+ * ONE CARD PER USER TURN, REWRITTEN IN PLACE. The rail is EPHEMERAL and this is
8
+ * the HISTORY, exactly as `TeamPanel` and `TeamCard` divide the same work. It
9
+ * renders through the shared `EntryFrame` supplied by `EntryView`, so a plan
10
+ * reads as one more step in the transcript rather than a second kind of object.
11
+ *
12
+ * THE FULL LIST, NO WINDOWING (the transcript scrolls; the rail does not), and
13
+ * NEVER ANIMATED even while `live` — a resumed session would otherwise spin
14
+ * forever on a card whose run ended days ago.
15
+ */
16
+ import React from 'react';
2
17
  import { Box, Text } from 'ink';
3
18
  import { pickGlyphs } from '../glyphs.js';
4
19
  import { railBorderProps } from '../layout/Gutter.js';
@@ -24,7 +39,7 @@ function rowColor(item, theme) {
24
39
  return theme.primary;
25
40
  return theme.hintFg ?? theme.muted;
26
41
  }
27
- export function TodoCard(props) {
42
+ function TodoCardImpl(props) {
28
43
  const { items, doneCount, total, live, interrupted, theme, caps } = props;
29
44
  const glyphs = pickGlyphs(caps);
30
45
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.accent, bold: true, children: "Todos" }), _jsxs(Text, { color: todoCardColor(doneCount, total, live, theme), children: [' ', doneCount, "/", total] }), interrupted && (
@@ -33,3 +48,10 @@ export function TodoCard(props) {
33
48
  // dispatch that never got a duration.
34
49
  _jsxs(Text, { color: theme.muted, children: [' ', "interrupted (session resumed)"] }))] }), _jsx(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: items.map((item, index) => (_jsxs(Text, { wrap: "truncate", color: rowColor(item, theme), children: [marker(item, glyphs).padEnd(3), item.status === 'in_progress' ? item.activeForm : item.content] }, `${index}-${item.content}`))) })] }));
35
50
  }
51
+ /**
52
+ * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
53
+ * `mapEntry` already preserves object identity for untouched entries, so the
54
+ * array props below are stable references on a settled card, and `theme` /
55
+ * `caps` are `useMemo`d in `App.tsx` (I-L2-1).
56
+ */
57
+ export const TodoCard = React.memo(TodoCardImpl);
@@ -28,11 +28,23 @@ interface ToolCardProps {
28
28
  isError?: boolean;
29
29
  expanded?: boolean;
30
30
  reducedMotion?: boolean;
31
+ /**
32
+ * Inline mode only (L5): ceiling on the rows a LIVE card contributes to the
33
+ * non-`<Static>` region. `undefined` — what the full-screen branch passes —
34
+ * means no clamp and byte-identical output.
35
+ */
36
+ liveClampRows?: number;
31
37
  theme: Theme;
32
38
  caps: TermCapabilities;
33
39
  }
34
40
  export declare function statusColor(status: ToolStatus, theme: Theme): string | undefined;
35
41
  /** The tool's own glyph, used by `EntryView` as the entry's rail marker. */
36
42
  export declare function toolEntryGlyph(name: string, caps: TermCapabilities): string;
37
- export declare function ToolCard(props: ToolCardProps): React.ReactElement;
43
+ declare function ToolCardImpl(props: ToolCardProps): React.ReactElement;
44
+ /**
45
+ * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
46
+ * Every prop is a scalar except `args` (a stable reference on a settled entry),
47
+ * `theme` and `caps` (both `useMemo`d in `App.tsx` — I-L2-1).
48
+ */
49
+ export declare const ToolCard: React.MemoExoticComponent<typeof ToolCardImpl>;
38
50
  export {};
@@ -1,12 +1,31 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Tool-call card (spec §3.5): a per-tool glyph, the tool name, a compact args
4
+ * summary, a colored status badge, and a duration, over a collapsible rich
5
+ * preview delegated to `<ToolPreview>`. Collapsed to the first 8 lines with a
6
+ * `+M lines (Ctrl+O)` footer; `Ctrl+O` / `/expand` toggle the full preview.
7
+ *
8
+ * Two v0.4.0 changes (§4.1 / §4.3):
9
+ * - capabilities arrive as a prop. This used to infer Unicode support by
10
+ * comparing `theme.symbols.gaugeFull` against a literal block character, so
11
+ * editing an unrelated gauge glyph would have silently sent every tool icon
12
+ * back to ASCII;
13
+ * - the preview's round border is gone. It cost 2 rows and 4 columns per card,
14
+ * and nested visibly inside markdown code blocks. A left rail carries the
15
+ * same grouping in 1 column and 0 rows.
16
+ */
17
+ import React from 'react';
2
18
  import { Box, Text } from 'ink';
3
19
  import Spinner from 'ink-spinner';
4
20
  import { pickGlyphs, toolGlyph } from '../glyphs.js';
5
21
  import { railBorderProps } from '../layout/Gutter.js';
22
+ import { splitLinesCached } from '../render-cache.js';
6
23
  import { formatDuration } from '../../agent/usage.js';
7
24
  import { ToolPreview, bashBadge } from './ToolPreview.js';
8
25
  /** Number of preview lines shown before collapse. */
9
26
  const COLLAPSED_LINES = 8;
27
+ /** Shared empty slice, so an argument-less card allocates nothing per frame. */
28
+ const EMPTY_LINES = [];
10
29
  export function statusColor(status, theme) {
11
30
  switch (status) {
12
31
  case 'pending':
@@ -58,15 +77,30 @@ function badgeLabel(name, status, preview) {
58
77
  export function toolEntryGlyph(name, caps) {
59
78
  return toolGlyph(pickGlyphs(caps), name);
60
79
  }
61
- export function ToolCard(props) {
62
- const { name, argsRaw, args, status, preview, durationMs, isError, expanded, reducedMotion, theme, caps, } = props;
80
+ function ToolCardImpl(props) {
81
+ const { name, argsRaw, args, status, preview, durationMs, isError, expanded, reducedMotion, liveClampRows, theme, caps, } = props;
63
82
  const glyphs = pickGlyphs(caps);
64
83
  const color = statusColor(status, theme);
65
84
  const previewText = preview ?? '';
66
- const previewLines = previewText.length > 0 ? previewText.split('\n') : [];
67
- const visibleLines = expanded ? previewLines : previewLines.slice(0, COLLAPSED_LINES);
85
+ // `splitLinesCached` rather than `previewText.split('\n')`: a stored preview
86
+ // is immutable once written, and this used to reallocate up to 200 strings per
87
+ // card per frame (R3).
88
+ const previewLines = previewText.length > 0 ? splitLinesCached(previewText) : EMPTY_LINES;
89
+ // Head-first, exactly as before: the collapsed card shows the FIRST 8 lines.
90
+ // The inline clamp only lowers the ceiling — it never changes which end is
91
+ // kept, because `Ctrl+O` on a 5 000-line bash result is precisely how an
92
+ // inline session reaches `ink.js:121` (I-L5-1) and the fix must not also
93
+ // reshuffle what a full-screen user has been reading for four releases.
94
+ const ceiling = Math.min(expanded ? previewLines.length : COLLAPSED_LINES, liveClampRows ?? Number.POSITIVE_INFINITY);
95
+ const visibleLines = ceiling >= previewLines.length ? previewLines : previewLines.slice(0, ceiling);
68
96
  const hidden = previewLines.length - visibleLines.length;
69
97
  const showBody = previewLines.length > 0 && (status === 'done' || status === 'error');
70
98
  const badge = status === 'running' && !reducedMotion && caps.unicode ? (_jsxs(Text, { color: color, children: [_jsx(Spinner, { type: "dots" }), " running"] })) : (_jsxs(Text, { color: color, children: [status === 'running' ? `${glyphs.spinnerStill} ` : '', badgeLabel(name, status, previewText)] }));
71
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.accent, bold: true, children: name }), _jsxs(Text, { color: theme.muted, children: [" ", summarizeArgs(name, glyphs.ellipsis, args, argsRaw), " "] }), badge, durationMs !== undefined && (_jsxs(Text, { color: theme.muted, children: [" (", formatDuration(durationMs), ")"] }))] }), showBody && (_jsxs(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, isError ? theme.toolError : theme.border), children: [_jsx(ToolPreview, { name: name, lines: visibleLines, theme: theme, isError: isError }), !expanded && hidden > 0 && (_jsxs(Text, { color: theme.muted, children: ["+", hidden, " ", hidden === 1 ? 'line' : 'lines', " (Ctrl+O)"] })), expanded && previewLines.length > COLLAPSED_LINES && (_jsx(Text, { color: theme.muted, children: "(Ctrl+O to collapse)" }))] }))] }));
99
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.accent, bold: true, children: name }), _jsxs(Text, { color: theme.muted, children: [" ", summarizeArgs(name, glyphs.ellipsis, args, argsRaw), " "] }), badge, durationMs !== undefined && (_jsxs(Text, { color: theme.muted, children: [" (", formatDuration(durationMs), ")"] }))] }), showBody && (_jsxs(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, isError ? theme.toolError : theme.border), children: [_jsx(ToolPreview, { name: name, lines: visibleLines, theme: theme, isError: isError }), !expanded && hidden > 0 && (_jsxs(Text, { color: theme.muted, children: ["+", hidden, " ", hidden === 1 ? 'line' : 'lines', " (Ctrl+O)"] })), expanded && hidden > 0 && (_jsxs(Text, { color: theme.muted, children: ["+", hidden, " ", hidden === 1 ? 'line' : 'lines', " ", glyphs.midDot, " shown in full when this entry finishes"] })), expanded && previewLines.length > COLLAPSED_LINES && (_jsx(Text, { color: theme.muted, children: "(Ctrl+O to collapse)" }))] }))] }));
72
100
  }
101
+ /**
102
+ * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
103
+ * Every prop is a scalar except `args` (a stable reference on a settled entry),
104
+ * `theme` and `caps` (both `useMemo`d in `App.tsx` — I-L2-1).
105
+ */
106
+ export const ToolCard = React.memo(ToolCardImpl);
@@ -35,7 +35,7 @@ export declare function parseWriteFile(text: string): {
35
35
  path: string;
36
36
  } | null;
37
37
  /** `3 questions · Postgres · REST` — or `dismissed`. */
38
- export declare function summarizeAskUser(lines: string[]): string | null;
38
+ export declare function summarizeAskUser(lines: readonly string[]): string | null;
39
39
  export interface ListRow {
40
40
  kind: 'dir' | 'file';
41
41
  name: string;
@@ -51,7 +51,7 @@ export declare function parseListRow(line: string): ListRow | null;
51
51
  */
52
52
  interface ToolPreviewProps {
53
53
  name: string;
54
- lines: string[];
54
+ lines: readonly string[];
55
55
  theme: Theme;
56
56
  isError?: boolean;
57
57
  }
@@ -8,7 +8,15 @@
8
8
  */
9
9
  import React from 'react';
10
10
  import type { Theme } from '../theme.js';
11
- export declare function UserEntry({ text, theme }: {
11
+ declare function UserEntryImpl({ text, theme }: {
12
12
  text: string;
13
13
  theme: Theme;
14
14
  }): React.ReactElement;
15
+ /**
16
+ * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
17
+ * `mapEntry` already preserves object identity for untouched entries, so the
18
+ * array props below are stable references on a settled card, and `theme` /
19
+ * `caps` are `useMemo`d in `App.tsx` (I-L2-1).
20
+ */
21
+ export declare const UserEntry: React.MemoExoticComponent<typeof UserEntryImpl>;
22
+ export {};
@@ -1,5 +1,21 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Render a user message entry.
4
+ *
5
+ * The role glyph, the gutter rail and the inter-entry spacing all belong to
6
+ * `EntryFrame` now (§4.3), so this component renders content and nothing else.
7
+ * That is also why it takes no `caps`: it has no glyph of its own left to
8
+ * degrade.
9
+ */
10
+ import React from 'react';
2
11
  import { Box, Text } from 'ink';
3
- export function UserEntry({ text, theme }) {
12
+ function UserEntryImpl({ text, theme }) {
4
13
  return (_jsx(Box, { flexDirection: "column", children: text.split('\n').map((line, i) => (_jsx(Text, { color: theme.user, bold: i === 0, children: line }, i))) }));
5
14
  }
15
+ /**
16
+ * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
17
+ * `mapEntry` already preserves object identity for untouched entries, so the
18
+ * array props below are stable references on a settled card, and `theme` /
19
+ * `caps` are `useMemo`d in `App.tsx` (I-L2-1).
20
+ */
21
+ export const UserEntry = React.memo(UserEntryImpl);
@@ -18,6 +18,13 @@ export interface ExitSnapshot {
18
18
  provider: string;
19
19
  model: string;
20
20
  startedAt: number;
21
+ /**
22
+ * Entries the retain ring removed (tui-render-performance L1).
23
+ *
24
+ * OPTIONAL so every existing caller and test is unchanged; absent means the
25
+ * replay says nothing, which is correct for a session that dropped nothing.
26
+ */
27
+ droppedEntries?: number;
21
28
  }
22
29
  /** Called by `App` after every view-state change. Synchronous, side-effect free. */
23
30
  export declare function publishExitSnapshot(next: ExitSnapshot): void;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * One transcript entry, wrapped so its rendered height reaches the height store
3
+ * (tui-render-performance L3).
4
+ *
5
+ * IT MUST NOT CONSTRAIN HEIGHT. The natural height IS the value being measured;
6
+ * a `height` prop here would make every entry report back whatever it was told,
7
+ * and the spacers would encode a fiction.
8
+ *
9
+ * Ink computes yoga layout in `onRender`, which React runs BEFORE layout
10
+ * effects, so the measurement below reflects the commit that just happened —
11
+ * the same mechanism `ScrollViewport.tsx:87-88` and `AppShell.tsx:100-102`
12
+ * already document and rely on.
13
+ */
14
+ import React from 'react';
15
+ import type { HeightKey } from './virtual-window.js';
16
+ export interface MeasuredEntryProps {
17
+ heightKey: HeightKey;
18
+ onMeasure: (key: HeightKey, rows: number) => void;
19
+ children: React.ReactNode;
20
+ }
21
+ export declare function MeasuredEntry({ heightKey, onMeasure, children, }: MeasuredEntryProps): React.ReactElement;
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * One transcript entry, wrapped so its rendered height reaches the height store
4
+ * (tui-render-performance L3).
5
+ *
6
+ * IT MUST NOT CONSTRAIN HEIGHT. The natural height IS the value being measured;
7
+ * a `height` prop here would make every entry report back whatever it was told,
8
+ * and the spacers would encode a fiction.
9
+ *
10
+ * Ink computes yoga layout in `onRender`, which React runs BEFORE layout
11
+ * effects, so the measurement below reflects the commit that just happened —
12
+ * the same mechanism `ScrollViewport.tsx:87-88` and `AppShell.tsx:100-102`
13
+ * already document and rely on.
14
+ */
15
+ import { useLayoutEffect, useRef } from 'react';
16
+ import { Box, measureElement } from 'ink';
17
+ export function MeasuredEntry({ heightKey, onMeasure, children, }) {
18
+ const ref = useRef(null);
19
+ // No dependency array on purpose: the entry's content can change without the
20
+ // key changing identity in a way this component can see, and `report` is a
21
+ // no-op when the measurement is unchanged — so an unconditional effect costs
22
+ // one `getComputedHeight()` read and cannot miss a resize.
23
+ useLayoutEffect(() => {
24
+ const node = ref.current;
25
+ if (!node)
26
+ return;
27
+ onMeasure(heightKey, measureElement(node).height);
28
+ });
29
+ return (_jsx(Box, { ref: ref, flexDirection: "column", flexShrink: 0, children: children }));
30
+ }
@@ -13,9 +13,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
13
  * measure → setState → re-render → measure loop whose convergence is provable
14
14
  * but whose frame count is not — expensive inside a 33 ms streaming window.
15
15
  */
16
- import { useEffect, useLayoutEffect, useRef, useState } from 'react';
16
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
17
17
  import { Box, Text, measureElement, useStdout } from 'ink';
18
18
  import { pickGlyphs } from '../glyphs.js';
19
+ import { ViewportGeometryContext } from './viewport-geometry.js';
19
20
  import { applyScrollTimes, clampScroll, } from './scroll.js';
20
21
  import { thumbRange } from './scroll-indicator.js';
21
22
  import { MIN_INDICATOR_COLS, ScrollIndicator } from './ScrollIndicator.js';
@@ -81,7 +82,12 @@ export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesCh
81
82
  // more than the affordance. The routing never reads this flag, which is what
82
83
  // keeps the indicator independently revertible (§13-Q3).
83
84
  const indicatorVisible = showScrollIndicator && cols >= MIN_INDICATOR_COLS;
84
- return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsx(Box, { ref: clipRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Box, { ref: innerRef, flexDirection: "column", flexShrink: 0, marginTop: -shiftUp, children: children }) }), indicatorVisible && (_jsx(ScrollIndicator, { rows: metrics.viewport, thumb: thumbRange(metrics.viewport, metrics.content, offset), theme: theme, caps: caps }))] }), showHint && (
85
+ // PUBLISHED, NOT LIFTED (tui-render-performance L3 / §3.3.2). The three
86
+ // numbers virtualisation needs are already owned here; a `useMemo`d context
87
+ // value hands them down without adding a second owner or a second measure
88
+ // pass, which is exactly what this component's header (P1-7) forbids.
89
+ const geometry = useMemo(() => ({ viewportRows: metrics.viewport, offset, contentRows: metrics.content }), [metrics.viewport, metrics.content, offset]);
90
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsx(Box, { ref: clipRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Box, { ref: innerRef, flexDirection: "column", flexShrink: 0, marginTop: -shiftUp, children: _jsx(ViewportGeometryContext.Provider, { value: geometry, children: children }) }) }), indicatorVisible && (_jsx(ScrollIndicator, { rows: metrics.viewport, thumb: thumbRange(metrics.viewport, metrics.content, offset), theme: theme, caps: caps }))] }), showHint && (
85
91
  // Not an overlay — Ink has no absolute positioning or z-index. This takes
86
92
  // the viewport's last row, trading one line of content for a hint that
87
93
  // cannot drift. Cheaper than stacking with negative margins.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Viewport geometry, published downward (tui-render-performance L3).
3
+ *
4
+ * `ScrollViewport`'s header records (P1-7) why it — and not its parent — owns
5
+ * the metrics and the scroll offset: holding the offset in two places creates a
6
+ * measure -> setState -> re-render -> measure loop whose convergence is provable
7
+ * but whose frame count is not.
8
+ *
9
+ * Virtualisation needs those same three numbers one level DOWN, in
10
+ * `TranscriptList`. So the viewport PUBLISHES rather than lifting: a `useMemo`d
11
+ * context value, no new state, no second owner, no extra layout pass.
12
+ */
13
+ export interface ViewportGeometry {
14
+ /** Visible height of the clipping box, in rows. `0` before the first measure. */
15
+ viewportRows: number;
16
+ /** Rows hidden BELOW the viewport (`layout/scroll.ts` semantics; V-4). */
17
+ offset: number;
18
+ /** Natural (unclipped) height of the content, in rows. */
19
+ contentRows: number;
20
+ }
21
+ /**
22
+ * The default is the UNMEASURED geometry, and consumers must treat it as such:
23
+ * a `TranscriptList` rendered outside a `ScrollViewport` (inline mode, and every
24
+ * test that mounts it bare) sees `viewportRows: 0`, which `selectWindow` reads
25
+ * as "first frame" and answers by pinning the live tail.
26
+ */
27
+ export declare const ViewportGeometryContext: import("react").Context<ViewportGeometry>;
28
+ export declare function useViewportGeometry(): ViewportGeometry;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Viewport geometry, published downward (tui-render-performance L3).
3
+ *
4
+ * `ScrollViewport`'s header records (P1-7) why it — and not its parent — owns
5
+ * the metrics and the scroll offset: holding the offset in two places creates a
6
+ * measure -> setState -> re-render -> measure loop whose convergence is provable
7
+ * but whose frame count is not.
8
+ *
9
+ * Virtualisation needs those same three numbers one level DOWN, in
10
+ * `TranscriptList`. So the viewport PUBLISHES rather than lifting: a `useMemo`d
11
+ * context value, no new state, no second owner, no extra layout pass.
12
+ */
13
+ import { createContext, useContext } from 'react';
14
+ /**
15
+ * The default is the UNMEASURED geometry, and consumers must treat it as such:
16
+ * a `TranscriptList` rendered outside a `ScrollViewport` (inline mode, and every
17
+ * test that mounts it bare) sees `viewportRows: 0`, which `selectWindow` reads
18
+ * as "first frame" and answers by pinning the live tail.
19
+ */
20
+ export const ViewportGeometryContext = createContext({
21
+ viewportRows: 0,
22
+ offset: 0,
23
+ contentRows: 0,
24
+ });
25
+ export function useViewportGeometry() {
26
+ return useContext(ViewportGeometryContext);
27
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Viewport virtualisation maths (tui-render-performance L3 / R1). Pure,
3
+ * React-free, unit-tested.
4
+ *
5
+ * THE STRUCTURAL FIX. Ink's `renderNodeToOutput` has no early-out for clipped
6
+ * subtrees: for every `ink-text` node it runs `squashTextNodes`, `widestLine`
7
+ * and possibly `wrapText` — all O(chars) — and `Output.get()` then evaluates
8
+ * `widestLine` a SECOND time before it is allowed to skip an out-of-clip
9
+ * operation. A clipped 50 000-character entry therefore costs two full string
10
+ * scans per frame and buys nothing. Bounding the ENTRY COUNT (`transcriptWindow`)
11
+ * never bounded that, because one entry can be fifty thousand lines.
12
+ *
13
+ * So entries outside the visible band are not mounted at all. They are replaced
14
+ * by two spacer boxes whose heights come from a height cache, which makes the
15
+ * per-frame cost O(visible rows) instead of O(characters ever produced).
16
+ *
17
+ * V-4 — `offset` counts rows FROM THE BOTTOM (`layout/scroll.ts:1-13`), and that
18
+ * is what makes virtualisation safe here for free: a height correction on an
19
+ * entry ABOVE the viewport changes the content height and therefore
20
+ * `overflowLines`, while `offset` — and hence what the user is looking at — is
21
+ * unchanged. No scroll-anchoring compensation pass is required. Do NOT
22
+ * "simplify" `offset` to count from the top.
23
+ */
24
+ import type { Entry } from '../../agent/reducer.js';
25
+ import type { DensityMode } from '../density.js';
26
+ export declare const VIRTUAL_LIMITS: {
27
+ /**
28
+ * Entries kept mounted on each side of the visible band.
29
+ *
30
+ * V-2 — must stay `>= 2`, so a one-row scroll can never expose an entry whose
31
+ * height has never been measured at the very edge of the viewport.
32
+ */
33
+ readonly overscan: 2;
34
+ /** Height-cache ceiling (LRU). Four thousand numbers, not four thousand strings. */
35
+ readonly heightEntries: 4096;
36
+ };
37
+ export type HeightKey = string;
38
+ /**
39
+ * A cheap content version: it changes exactly when the rendered content does.
40
+ *
41
+ * DERIVED, NEVER HASHED. Hashing a 256 KiB string once per entry per frame would
42
+ * recreate the very cost this module exists to remove, so every field below is
43
+ * an O(1) read of a length or a scalar.
44
+ *
45
+ * I-L3-1 — this must change whenever the rendered output changes. The only lossy
46
+ * case is a same-LENGTH text replacement, which the reducer cannot produce: all
47
+ * text fields are append-only (`appendBounded` only ever elides the middle,
48
+ * which changes the length). A future NON-APPEND mutation MUST extend the
49
+ * revision, or the entry freezes at a stale height AND a stale rendered subtree
50
+ * with nothing anywhere reporting it. `virtual-window.test.ts` enumerates every
51
+ * kind against every mutating action.
52
+ */
53
+ export declare function entryRevision(entry: Entry): string;
54
+ /**
55
+ * The height-cache key.
56
+ *
57
+ * V-5 — `cols` is part of the key, so a resize invalidates every entry. The
58
+ * frame after a resize renders from estimates and re-measures lazily: one
59
+ * reflow, no crash, no stale layout.
60
+ *
61
+ * `flags` carries the render-affecting switches that are neither per-entry nor
62
+ * part of `density` — today only `thinkingVisible` (Ctrl+T). It is a parameter
63
+ * rather than a fifth positional scalar so a future switch costs one character
64
+ * at the call site instead of a signature change at four.
65
+ */
66
+ export declare function heightKey(entry: Entry, cols: number, expanded: boolean, density: DensityMode, flags?: string): HeightKey;
67
+ /**
68
+ * Pure UPPER-BOUND estimate of an entry's height, used until a real measurement
69
+ * exists.
70
+ *
71
+ * Upper bound rather than best guess, and deliberately so: an over-estimate
72
+ * makes the scroll position slightly conservative and self-corrects the frame
73
+ * after the entry is measured, while an under-estimate can put the newest output
74
+ * off the bottom of a viewport the user believes is pinned.
75
+ */
76
+ export declare function estimateEntryRows(entry: Entry, cols: number, density: DensityMode, expanded: boolean): number;
77
+ export interface WindowSelection {
78
+ /** First mounted entry, inclusive. */
79
+ startIndex: number;
80
+ /** One past the last mounted entry. */
81
+ endIndex: number;
82
+ /** Spacer rows above the mounted slice. */
83
+ leadingRows: number;
84
+ /** Spacer rows below the mounted slice. */
85
+ trailingRows: number;
86
+ /** Sum of every entry height, measured or estimated. */
87
+ totalRows: number;
88
+ }
89
+ export interface SelectWindowInput {
90
+ entries: readonly Entry[];
91
+ /** Measured, else estimated; never 0 for a real entry. */
92
+ heightOf: (index: number) => number;
93
+ viewportRows: number;
94
+ /** Rows hidden BELOW the viewport — the existing `scroll.ts` semantic (V-4). */
95
+ offset: number;
96
+ /** `>= 2` (V-2). Lower values are raised rather than honoured. */
97
+ overscan: number;
98
+ }
99
+ /** Choose which entries to mount, and how tall the two spacers must be. */
100
+ export declare function selectWindow(input: SelectWindowInput): WindowSelection;