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