@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.
- package/CHANGELOG.md +747 -577
- package/README.md +1163 -995
- package/dist/agent/controller.d.ts +71 -1
- package/dist/agent/controller.js +135 -1
- package/dist/agent/entry-limits.d.ts +65 -0
- package/dist/agent/entry-limits.js +133 -0
- package/dist/agent/headless.d.ts +29 -0
- package/dist/agent/headless.js +86 -1
- package/dist/agent/reducer.d.ts +53 -0
- package/dist/agent/reducer.js +145 -14
- package/dist/agent/system-prompt.d.ts +8 -0
- package/dist/agent/system-prompt.js +2 -0
- package/dist/cli.js +76 -3
- package/dist/commands/builtins.js +171 -0
- package/dist/commands/perf.d.ts +45 -0
- package/dist/commands/perf.js +78 -0
- package/dist/commands/registry.d.ts +27 -1
- package/dist/config/env.d.ts +3 -1
- package/dist/config/env.js +74 -1
- package/dist/config/load.d.ts +41 -0
- package/dist/config/load.js +67 -2
- package/dist/config/schema.d.ts +108 -8
- package/dist/config/schema.js +81 -6
- package/dist/config/store.js +10 -1
- package/dist/session/persist.d.ts +19 -1
- package/dist/session/persist.js +21 -2
- package/dist/team/subagent.js +11 -0
- package/dist/todo/follow-through.d.ts +117 -0
- package/dist/todo/follow-through.js +186 -0
- package/dist/todo/limits.d.ts +114 -0
- package/dist/todo/limits.js +114 -0
- package/dist/todo/normalize.d.ts +33 -0
- package/dist/todo/normalize.js +117 -0
- package/dist/todo/panel-rows.d.ts +53 -0
- package/dist/todo/panel-rows.js +103 -0
- package/dist/todo/prompt.d.ts +54 -0
- package/dist/todo/prompt.js +76 -0
- package/dist/todo/store.d.ts +86 -0
- package/dist/todo/store.js +183 -0
- package/dist/todo/todo-tool.d.ts +38 -0
- package/dist/todo/todo-tool.js +134 -0
- package/dist/todo/types.d.ts +62 -0
- package/dist/todo/types.js +12 -0
- package/dist/tools/index.d.ts +16 -2
- package/dist/tools/index.js +13 -0
- package/dist/ui/App.js +328 -14
- package/dist/ui/Markdown.d.ts +7 -1
- package/dist/ui/Markdown.js +15 -48
- package/dist/ui/StatusBar.d.ts +31 -0
- package/dist/ui/StatusBar.js +7 -2
- package/dist/ui/TodoPanel.d.ts +48 -0
- package/dist/ui/TodoPanel.js +74 -0
- package/dist/ui/TodoStrip.d.ts +36 -0
- package/dist/ui/TodoStrip.js +32 -0
- package/dist/ui/Transcript.d.ts +56 -4
- package/dist/ui/Transcript.js +123 -11
- 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 +39 -0
- package/dist/ui/entries/TodoCard.js +57 -0
- 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/glyphs.d.ts +12 -0
- package/dist/ui/glyphs.js +8 -0
- package/dist/ui/layout/AppShell.d.ts +34 -1
- package/dist/ui/layout/AppShell.js +3 -3
- package/dist/ui/layout/MeasuredEntry.d.ts +21 -0
- package/dist/ui/layout/MeasuredEntry.js +30 -0
- package/dist/ui/layout/ScrollViewport.d.ts +13 -1
- package/dist/ui/layout/ScrollViewport.js +10 -4
- package/dist/ui/layout/rail.d.ts +56 -0
- package/dist/ui/layout/rail.js +68 -0
- 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/overlays/HelpOverlay.js +4 -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 +3 -2
package/dist/ui/StatusBar.js
CHANGED
|
@@ -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}]`] })),
|
|
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
|
+
}
|
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,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
|
|
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
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {};
|