@aragon-agent/cli 0.5.6 → 0.5.9
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 +356 -0
- package/README.md +1429 -1105
- package/dist/agent/coalesce.d.ts +13 -0
- package/dist/agent/coalesce.js +50 -11
- package/dist/agent/controller.d.ts +141 -2
- package/dist/agent/controller.js +396 -4
- package/dist/agent/entry-limits.d.ts +65 -0
- package/dist/agent/entry-limits.js +133 -0
- package/dist/agent/headless.d.ts +8 -0
- package/dist/agent/headless.js +40 -0
- package/dist/agent/reducer.d.ts +253 -3
- package/dist/agent/reducer.js +553 -28
- package/dist/agent/retry-view.d.ts +68 -0
- package/dist/agent/retry-view.js +95 -0
- package/dist/agent/system-prompt.d.ts +11 -0
- package/dist/agent/system-prompt.js +2 -0
- package/dist/cli.js +122 -4
- package/dist/commands/builtins.d.ts +1 -1
- package/dist/commands/builtins.js +313 -2
- package/dist/commands/perf.d.ts +45 -0
- package/dist/commands/perf.js +78 -0
- package/dist/config/cli-commands.d.ts +53 -2
- package/dist/config/cli-commands.js +136 -3
- package/dist/config/env.d.ts +6 -1
- package/dist/config/env.js +131 -1
- package/dist/config/load.d.ts +63 -0
- package/dist/config/load.js +125 -2
- package/dist/config/schema.d.ts +273 -10
- package/dist/config/schema.js +250 -7
- package/dist/config/store.js +20 -1
- package/dist/fast/critique.d.ts +43 -0
- package/dist/fast/critique.js +62 -0
- package/dist/fast/digest.d.ts +42 -0
- package/dist/fast/digest.js +85 -0
- package/dist/fast/frames.d.ts +99 -0
- package/dist/fast/frames.js +180 -0
- package/dist/fast/limits.d.ts +98 -0
- package/dist/fast/limits.js +98 -0
- package/dist/fast/prompt.d.ts +72 -0
- package/dist/fast/prompt.js +94 -0
- package/dist/fast/resolve.d.ts +43 -0
- package/dist/fast/resolve.js +105 -0
- package/dist/fast/review-call.d.ts +99 -0
- package/dist/fast/review-call.js +130 -0
- package/dist/fast/reviewer.d.ts +248 -0
- package/dist/fast/reviewer.js +664 -0
- package/dist/fast/types.d.ts +128 -0
- package/dist/fast/types.js +14 -0
- package/dist/fast/wiring.d.ts +170 -0
- package/dist/fast/wiring.js +316 -0
- package/dist/logging/install.d.ts +15 -1
- package/dist/logging/install.js +37 -4
- package/dist/logging/logger.d.ts +1 -1
- package/dist/session/persist.js +57 -1
- package/dist/team/normalize.d.ts +26 -1
- package/dist/team/normalize.js +23 -3
- package/dist/team/prompt.d.ts +11 -0
- package/dist/team/prompt.js +10 -0
- package/dist/team/report.d.ts +18 -0
- package/dist/team/report.js +27 -1
- package/dist/team/runtime.d.ts +13 -2
- package/dist/team/runtime.js +33 -6
- package/dist/team/subagent.d.ts +19 -1
- package/dist/team/subagent.js +72 -10
- package/dist/team/task-tool.d.ts +26 -0
- package/dist/team/task-tool.js +48 -4
- package/dist/team/types.d.ts +80 -1
- package/dist/tools/bash-tool.js +19 -3
- package/dist/tools/diff.d.ts +25 -5
- package/dist/tools/diff.js +72 -41
- package/dist/tools/file-change-store.d.ts +34 -0
- package/dist/tools/file-change-store.js +57 -0
- package/dist/tools/fs-tools.d.ts +26 -0
- package/dist/tools/fs-tools.js +62 -5
- package/dist/tools/index.d.ts +31 -0
- package/dist/tools/index.js +7 -1
- package/dist/tools/patch.d.ts +115 -0
- package/dist/tools/patch.js +453 -0
- package/dist/tools/terminal-output.d.ts +107 -0
- package/dist/tools/terminal-output.js +198 -0
- package/dist/tools/tool-output-store.d.ts +68 -0
- package/dist/tools/tool-output-store.js +102 -0
- package/dist/ui/ActivityLine.d.ts +48 -0
- package/dist/ui/ActivityLine.js +22 -0
- package/dist/ui/App.d.ts +1 -4
- package/dist/ui/App.js +357 -44
- package/dist/ui/BottomStatusRow.d.ts +43 -0
- package/dist/ui/BottomStatusRow.js +14 -0
- package/dist/ui/Composer.d.ts +1 -6
- package/dist/ui/Composer.js +2 -2
- package/dist/ui/Markdown.d.ts +7 -1
- package/dist/ui/Markdown.js +15 -48
- package/dist/ui/PromptInput.d.ts +1 -15
- package/dist/ui/PromptInput.js +1 -38
- package/dist/ui/StatusBar.d.ts +50 -0
- package/dist/ui/StatusBar.js +7 -3
- package/dist/ui/TeamPanel.d.ts +5 -4
- package/dist/ui/TeamPanel.js +1 -1
- package/dist/ui/TodoPanel.d.ts +5 -4
- package/dist/ui/Transcript.d.ts +80 -5
- package/dist/ui/Transcript.js +179 -11
- package/dist/ui/activity-phrases.d.ts +33 -0
- package/dist/ui/activity-phrases.js +50 -0
- package/dist/ui/entries/AssistantEntry.d.ts +38 -1
- package/dist/ui/entries/AssistantEntry.js +34 -3
- package/dist/ui/entries/DiffView.d.ts +67 -0
- package/dist/ui/entries/DiffView.js +103 -0
- package/dist/ui/entries/FastCard.d.ts +53 -0
- package/dist/ui/entries/FastCard.js +87 -0
- package/dist/ui/entries/RetryCard.d.ts +56 -0
- package/dist/ui/entries/RetryCard.js +60 -0
- package/dist/ui/entries/TeamCard.d.ts +9 -1
- package/dist/ui/entries/TeamCard.js +28 -2
- package/dist/ui/entries/TodoCard.d.ts +9 -1
- package/dist/ui/entries/TodoCard.js +23 -1
- package/dist/ui/entries/ToolCard.d.ts +50 -1
- package/dist/ui/entries/ToolCard.js +104 -7
- 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 +9 -0
- package/dist/ui/glyphs.js +2 -0
- package/dist/ui/layout/AppShell.d.ts +13 -22
- package/dist/ui/layout/AppShell.js +3 -38
- 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 +308 -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 +16 -2
- package/dist/ui/overlays/SettingsScreen.d.ts +82 -1
- package/dist/ui/overlays/SettingsScreen.js +147 -1
- 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 +32 -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/dist/ui/use-wheel-routing.d.ts +16 -32
- package/dist/ui/use-wheel-routing.js +21 -38
- package/package.json +2 -2
- package/dist/ui/layout/regions.d.ts +0 -55
- package/dist/ui/layout/regions.js +0 -52
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,21 @@ 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 { RetryCard, retryCardColor } from './entries/RetryCard.js';
|
|
41
|
+
import { FastCard, fastCardColor } from './entries/FastCard.js';
|
|
42
|
+
import { MeasuredEntry } from './layout/MeasuredEntry.js';
|
|
43
|
+
import { useViewportGeometry } from './layout/viewport-geometry.js';
|
|
44
|
+
import { estimateEntryRows, heightKey, selectWindow, VIRTUAL_LIMITS, } from './layout/virtual-window.js';
|
|
45
|
+
import { useTerminalSize } from './layout/useTerminalSize.js';
|
|
31
46
|
/** Keep the last K entries in the live region so a mutating tail stays hot. */
|
|
32
47
|
const LIVE_TAIL = 1;
|
|
48
|
+
/**
|
|
49
|
+
* Rows the inline live region leaves for the composer, the prompt echo and the
|
|
50
|
+
* one-row margin Ink needs before `outputHeight >= stdout.rows` (I-L5-1).
|
|
51
|
+
*/
|
|
52
|
+
const INLINE_LIVE_MARGIN = 4;
|
|
53
|
+
/** Floor on the clamp, so a very short terminal still shows something useful. */
|
|
54
|
+
const INLINE_LIVE_MIN_ROWS = 4;
|
|
33
55
|
/**
|
|
34
56
|
* Largest prefix length P such that `entries[0..P)` are all terminal, none is
|
|
35
57
|
* within the last `LIVE_TAIL` entries, and none is currently expanded. Pure and
|
|
@@ -63,10 +85,34 @@ export function computeSettledCount(entries, expandedToolIds) {
|
|
|
63
85
|
// on every frame for the rest of the session.
|
|
64
86
|
if (e.kind === 'todo' && e.live)
|
|
65
87
|
break;
|
|
88
|
+
// Nor is a retry card that is still counting down or still in flight: it is
|
|
89
|
+
// rewritten on every `retryScheduled` / `retryAttempt` and once a second by
|
|
90
|
+
// `retryTick`, and `<Static>` cannot un-print what it has already drawn.
|
|
91
|
+
// `session/persist.ts` forces a loaded card to `interrupted`, which is the
|
|
92
|
+
// other half — a card that never settles is re-rendered on every frame for
|
|
93
|
+
// the rest of the session (R-10).
|
|
94
|
+
if (e.kind === 'retry' && (e.phase === 'waiting' || e.phase === 'retrying'))
|
|
95
|
+
break;
|
|
96
|
+
// A live review card is not settled either: it is rewritten once, when the
|
|
97
|
+
// call comes back, and `<Static>` cannot un-print what it has already drawn
|
|
98
|
+
// (C-5). `session/persist.ts` forces `live: false` on load, which is the
|
|
99
|
+
// other half.
|
|
100
|
+
if (e.kind === 'fast' && e.live)
|
|
101
|
+
break;
|
|
66
102
|
count = i + 1;
|
|
67
103
|
}
|
|
68
104
|
return count;
|
|
69
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* The value `EntryView` should receive for `nowSec` on THIS entry.
|
|
108
|
+
*
|
|
109
|
+
* `undefined` for everything that is not a running tool, which is what keeps
|
|
110
|
+
* every other card's memo boundary intact — `undefined === undefined` in the
|
|
111
|
+
* comparator, on every frame, forever.
|
|
112
|
+
*/
|
|
113
|
+
function nowSecFor(entry, nowSec) {
|
|
114
|
+
return entry.kind === 'tool' && entry.status === 'running' ? nowSec : undefined;
|
|
115
|
+
}
|
|
70
116
|
function noticeColor(level, theme) {
|
|
71
117
|
if (level === 'error')
|
|
72
118
|
return theme.noticeError;
|
|
@@ -81,7 +127,7 @@ function noticeSymbol(level, theme) {
|
|
|
81
127
|
return theme.symbols.warn;
|
|
82
128
|
return theme.symbols.info;
|
|
83
129
|
}
|
|
84
|
-
function
|
|
130
|
+
function EntryViewImpl({ entry, prev, expanded, thinkingVisible, reducedMotion, density, mode, theme, caps, liveClampRows, nowSec, }) {
|
|
85
131
|
const glyphs = pickGlyphs(caps);
|
|
86
132
|
const separation = separationRows(prev, entry, density);
|
|
87
133
|
const frame = (glyph, color, children) => (_jsx(EntryFrame, { glyph: glyph, color: color, continuation: glyphs.railVertical, separation: separation, spacerColor: theme.border, children: children }));
|
|
@@ -93,20 +139,80 @@ function EntryView({ entry, prev, expanded, thinkingVisible, reducedMotion, dens
|
|
|
93
139
|
// both an animation and a Unicode-only glyph, so reduced motion and an
|
|
94
140
|
// ASCII terminal fall back to the same static marker (P2-11 / A-10).
|
|
95
141
|
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,
|
|
142
|
+
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,
|
|
143
|
+
// `EntryViewImpl` hands each renderer EXPLICIT SCALAR PROPS, so
|
|
144
|
+
// nothing on the entry reaches a component by itself (P1-6b).
|
|
145
|
+
thinkingMs: entry.thinkingMs, revealable: mode === 'fullscreen', liveClampRows: liveClampRows, theme: theme, caps: caps }));
|
|
97
146
|
}
|
|
98
147
|
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,
|
|
148
|
+
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, patch: entry.patch, durationMs: entry.durationMs, isError: entry.isError, expanded: expanded, reducedMotion: reducedMotion, liveClampRows: liveClampRows,
|
|
149
|
+
// `entry.live` AND NOT `entry.live ?? []` (P2-4): `ToolCard`'s default
|
|
150
|
+
// comparator sees a fresh `[]` as a changed prop, which is why that
|
|
151
|
+
// file already keeps a shared `EMPTY_LINES`.
|
|
152
|
+
live: entry.live, lastOutputAt: entry.lastOutputAt, nowSec: nowSec, theme: theme, caps: caps }));
|
|
100
153
|
case 'team':
|
|
101
154
|
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
155
|
case 'todo':
|
|
103
156
|
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 }));
|
|
157
|
+
case 'retry':
|
|
158
|
+
return frame(entry.phase === 'exhausted' ? glyphs.error : glyphs.retry, retryCardColor(entry.phase, theme), _jsx(RetryCard, { attempt: entry.attempt, maxRetries: entry.maxRetries, errorType: entry.errorType, phase: entry.phase, resumeAt: entry.resumeAt, totalRetries: entry.totalRetries, elapsedMs: entry.elapsedMs,
|
|
159
|
+
// READ HERE, not threaded from `App`, and that is what keeps the memo
|
|
160
|
+
// boundary intact: a `now` prop on every `EntryView` would change on
|
|
161
|
+
// every frame and re-render the whole transcript. The `retryTick` action
|
|
162
|
+
// refreshes THIS entry's identity once a second, which re-renders this
|
|
163
|
+
// one card and gives it a fresh clock; nothing else moves.
|
|
164
|
+
now: Date.now(),
|
|
165
|
+
// NOT THREADED FROM `App`: reaching this renderer means a TUI is
|
|
166
|
+
// mounted, and the TUI always binds Esc to abort. Headless mode writes
|
|
167
|
+
// its retry lines to stderr and never mounts an entry component, and the
|
|
168
|
+
// exit replay goes through `transcript-text.ts`. `RetryCard` keeps the
|
|
169
|
+
// prop so the render tests can assert the suppressed form.
|
|
170
|
+
reducedMotion: reducedMotion, theme: theme, caps: caps }));
|
|
171
|
+
case 'fast':
|
|
172
|
+
return frame(entry.status === 'failed' ? glyphs.toolError : glyphs.teamAgent, fastCardColor(entry.status, theme), _jsx(FastCard, { reviewIndex: entry.reviewIndex, model: entry.model, status: entry.status, text: entry.text, detail: entry.detail, turn: entry.turn, durationMs: entry.durationMs, live: entry.live, reducedMotion: reducedMotion, theme: theme, caps: caps }));
|
|
104
173
|
case 'notice':
|
|
105
174
|
return frame(noticeSymbol(entry.level, theme), noticeColor(entry.level, theme), _jsx(Text, { color: noticeColor(entry.level, theme), children: entry.text }));
|
|
106
175
|
default:
|
|
107
176
|
return null;
|
|
108
177
|
}
|
|
109
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* THE memo boundary that makes a long transcript cheap (L2 / R3).
|
|
181
|
+
*
|
|
182
|
+
* The comparator is written out rather than defaulted because `prev` is the one
|
|
183
|
+
* prop whose identity matters for a reason the type does not show: it feeds
|
|
184
|
+
* `separationRows`, so an entry whose PREDECESSOR changed kind must re-render
|
|
185
|
+
* even though the entry itself did not.
|
|
186
|
+
*
|
|
187
|
+
* I-L2-1 — `theme` and `caps` are `useMemo`d in `App.tsx` and are therefore
|
|
188
|
+
* referentially stable across frames. If a future change makes either a fresh
|
|
189
|
+
* object per render, EVERY boundary below becomes a no-op AND NOTHING FAILS;
|
|
190
|
+
* `render-memo.test.tsx` asserts that stability directly so the trap is a red
|
|
191
|
+
* test rather than a silent regression.
|
|
192
|
+
*/
|
|
193
|
+
export const EntryView = React.memo(EntryViewImpl, (a, b) => {
|
|
194
|
+
return (a.entry === b.entry &&
|
|
195
|
+
a.prev === b.prev &&
|
|
196
|
+
a.expanded === b.expanded &&
|
|
197
|
+
a.thinkingVisible === b.thinkingVisible &&
|
|
198
|
+
a.reducedMotion === b.reducedMotion &&
|
|
199
|
+
a.density === b.density &&
|
|
200
|
+
a.mode === b.mode &&
|
|
201
|
+
a.theme === b.theme &&
|
|
202
|
+
a.caps === b.caps &&
|
|
203
|
+
a.liveClampRows === b.liveClampRows &&
|
|
204
|
+
/*
|
|
205
|
+
* ELEVENTH TERM, AND OMITTING IT IS THE TRAP I-L2-1 DESCRIBES ONE COMMENT UP
|
|
206
|
+
* (P1-3). This comparator is a CLOSED LIST: a prop absent from it changes
|
|
207
|
+
* without invalidating the boundary, so `ToolCard` is never re-rendered and
|
|
208
|
+
* the stall row freezes at whatever second it first drew — AC-26's "and the
|
|
209
|
+
* seconds advance" failing with nothing reporting it.
|
|
210
|
+
*
|
|
211
|
+
* It is free: `nowSecFor` returns `undefined` for every entry that is not a
|
|
212
|
+
* running tool, and `undefined === undefined` on every frame.
|
|
213
|
+
*/
|
|
214
|
+
a.nowSec === b.nowSec);
|
|
215
|
+
});
|
|
110
216
|
/**
|
|
111
217
|
* Full-screen transcript body — the same entry renderers with NO `<Static>`.
|
|
112
218
|
*
|
|
@@ -121,20 +227,64 @@ function EntryView({ entry, prev, expanded, thinkingVisible, reducedMotion, dens
|
|
|
121
227
|
* mark feeds nothing else — `/save` serializes `entries` in full — so leaving it
|
|
122
228
|
* un-advanced has no side effect.
|
|
123
229
|
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
230
|
+
* VIRTUALISED (L3 / R1). `windowSize` is now the SCROLL HORIZON — how far back
|
|
231
|
+
* the user can scroll — and no longer the rendering budget, because entries
|
|
232
|
+
* outside the viewport are not mounted at all. What bounds the frame is
|
|
233
|
+
* `selectWindow`, and the two spacers are what keep the scroll maths honest
|
|
234
|
+
* while they are absent.
|
|
127
235
|
*/
|
|
128
|
-
export function TranscriptList({ entries, expandedToolIds, thinkingVisible, reducedMotion, density, theme, caps, windowSize, }) {
|
|
236
|
+
export function TranscriptList({ entries, expandedToolIds, thinkingVisible, reducedMotion, density, mode, nowSec, theme, caps, windowSize, cols, heights, mountedSink, }) {
|
|
129
237
|
const size = Math.max(1, Math.floor(windowSize));
|
|
130
238
|
const visible = entries.length > size ? entries.slice(-size) : entries;
|
|
131
239
|
const collapsed = entries.length - visible.length;
|
|
132
240
|
const glyphs = pickGlyphs(caps);
|
|
133
|
-
|
|
241
|
+
const geometry = useViewportGeometry();
|
|
242
|
+
// Ctrl+T changes every assistant entry's height without changing any entry,
|
|
243
|
+
// so it has to reach the cache key or the spacers freeze at the wrong size.
|
|
244
|
+
const flags = thinkingVisible ? 't' : '';
|
|
245
|
+
// ONE key builder, shared by the lookup below and by `MeasuredEntry`'s report.
|
|
246
|
+
// Two spellings of the same key is a SILENT failure and not a compile error:
|
|
247
|
+
// the measurement lands under a key `resolve` never asks for, every entry
|
|
248
|
+
// falls back to its estimate for the rest of the session, and the spacers
|
|
249
|
+
// quietly encode heights nothing will ever correct.
|
|
250
|
+
const keyOf = (entry) => heightKey(entry, cols, !!expandedToolIds[entry.id], density, flags);
|
|
251
|
+
const { resolve, report } = heights;
|
|
252
|
+
const heightOf = useCallback((index) => {
|
|
253
|
+
const entry = visible[index];
|
|
254
|
+
// `thinkingVisible` IS PASSED, not left to the parameter's conservative
|
|
255
|
+
// default (P1-6a / condition 5). With thinking hidden the default would
|
|
256
|
+
// inflate every assistant entry that thought by its whole wrapped length,
|
|
257
|
+
// and an entry estimated at 400 rows against a real height of 1 is exactly
|
|
258
|
+
// the one `selectWindow` then never mounts and never re-measures.
|
|
259
|
+
return resolve(keyOf(entry), () => estimateEntryRows(entry, cols, density, !!expandedToolIds[entry.id], thinkingVisible));
|
|
260
|
+
},
|
|
261
|
+
// Memoised only incidentally: `visible` IS `entries` while the session is
|
|
262
|
+
// shorter than the horizon and a fresh slice once it is past it, so this
|
|
263
|
+
// rebuilds on some frames and not others. Either is correct — `selectWindow`
|
|
264
|
+
// consumes it synchronously below and it is never handed to a child — and
|
|
265
|
+
// `keyOf` is omitted from the list because every input it closes over is
|
|
266
|
+
// already in it.
|
|
267
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
268
|
+
[visible, expandedToolIds, cols, density, flags, resolve, thinkingVisible]);
|
|
269
|
+
const selection = selectWindow({
|
|
270
|
+
entries: visible,
|
|
271
|
+
heightOf,
|
|
272
|
+
viewportRows: geometry.viewportRows,
|
|
273
|
+
offset: geometry.offset,
|
|
274
|
+
overscan: VIRTUAL_LIMITS.overscan,
|
|
275
|
+
});
|
|
276
|
+
const slice = visible.slice(selection.startIndex, selection.endIndex);
|
|
277
|
+
if (mountedSink)
|
|
278
|
+
mountedSink.current = slice.length;
|
|
279
|
+
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) => {
|
|
280
|
+
const index = selection.startIndex + i;
|
|
281
|
+
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, mode: mode, nowSec: nowSecFor(entry, nowSec), theme: theme, caps: caps }) }, entry.id));
|
|
282
|
+
}), selection.trailingRows > 0 && (_jsx(Box, { flexShrink: 0, height: selection.trailingRows }))] }));
|
|
134
283
|
}
|
|
135
|
-
export function Transcript({ entries, expandedToolIds, thinkingVisible, reducedMotion, density, theme, caps, }) {
|
|
284
|
+
export function Transcript({ entries, expandedToolIds, thinkingVisible, reducedMotion, density, mode, nowSec, theme, caps, }) {
|
|
136
285
|
const highWater = useRef(0);
|
|
137
286
|
const prevLen = useRef(0);
|
|
287
|
+
const { rows } = useTerminalSize();
|
|
138
288
|
const raw = computeSettledCount(entries, expandedToolIds);
|
|
139
289
|
// The transcript shrank (clear / reset / restore) — drop the high-water mark.
|
|
140
290
|
if (entries.length < prevLen.current)
|
|
@@ -144,5 +294,23 @@ export function Transcript({ entries, expandedToolIds, thinkingVisible, reducedM
|
|
|
144
294
|
highWater.current = settled;
|
|
145
295
|
const settledEntries = entries.slice(0, settled);
|
|
146
296
|
const liveEntries = entries.slice(settled);
|
|
147
|
-
|
|
297
|
+
// I-L5-1 — the live region must stay strictly below `stdout.rows`, or Ink
|
|
298
|
+
// takes the `ink.js:118-123` branch and writes `clearTerminal +
|
|
299
|
+
// fullStaticOutput + output` on EVERY subsequent frame for the rest of the
|
|
300
|
+
// session.
|
|
301
|
+
//
|
|
302
|
+
// `LIVE_TAIL` is 1, so the common case is the design's `rows - 4` less the one
|
|
303
|
+
// row the clamp marker costs. THE DIVISION IS NOT DECORATION: the settled
|
|
304
|
+
// boundary is held back by an expanded card, a running tool, a live dispatch
|
|
305
|
+
// or a live todo list, and in every one of those cases the region holds
|
|
306
|
+
// SEVERAL entries at once — a flat per-entry ceiling of `rows - 4` would then
|
|
307
|
+
// overrun by a multiple of itself. `- 1` pays for each entry's marker row.
|
|
308
|
+
//
|
|
309
|
+
// The residual: an entry contributes at least its header row whatever the
|
|
310
|
+
// clamp says, so the bound holds while the live region holds fewer than about
|
|
311
|
+
// `rows / 2` entries. Bounding it unconditionally would mean refusing to draw
|
|
312
|
+
// a live entry at all, which is worse than a tall frame.
|
|
313
|
+
const liveBudget = Math.max(INLINE_LIVE_MIN_ROWS, rows - INLINE_LIVE_MARGIN);
|
|
314
|
+
const liveClampRows = Math.max(1, Math.floor(liveBudget / Math.max(1, liveEntries.length)) - 1);
|
|
315
|
+
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, mode: mode, 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, mode: mode, theme: theme, caps: caps, liveClampRows: liveClampRows, nowSec: nowSecFor(entry, nowSec) }, entry.id))) })] }));
|
|
148
316
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The working line's vocabulary (agent-activity-presentation §3.2.1).
|
|
3
|
+
*
|
|
4
|
+
* PURE: no React, no Ink, no clock. Everything here is a function of the two
|
|
5
|
+
* numbers the caller already has, which is what makes the rotation testable
|
|
6
|
+
* without a terminal and without fake timers.
|
|
7
|
+
*/
|
|
8
|
+
/** Rotation cadence. Long enough to read, short enough to prove liveness. */
|
|
9
|
+
export declare const PHRASE_ROTATE_MS = 4000;
|
|
10
|
+
/**
|
|
11
|
+
* ASCII ONLY, by the rule this tree is under (`glyphs.ts` header, enforced by
|
|
12
|
+
* `glyphs.test.ts`'s static scan). Present participles, so the line reads as an
|
|
13
|
+
* action in progress; twelve characters or fewer so the row never fights the
|
|
14
|
+
* terminal's width.
|
|
15
|
+
*/
|
|
16
|
+
export declare const ACTIVITY_PHRASES: readonly string[];
|
|
17
|
+
/**
|
|
18
|
+
* The phrase for a run at a moment.
|
|
19
|
+
*
|
|
20
|
+
* PURE AND TOTAL: the same inputs give the same word, for any finite numbers,
|
|
21
|
+
* including a `now` before `startedAt`.
|
|
22
|
+
*
|
|
23
|
+
* The run's start seeds the sequence, so two consecutive runs do not open on the
|
|
24
|
+
* same word — which is what makes the line read as alive rather than as a fixed
|
|
25
|
+
* label. (The seed's granularity is one second, so two runs starting inside the
|
|
26
|
+
* same second do share an opening word — P2-6.)
|
|
27
|
+
*
|
|
28
|
+
* `rotate: false` is the reduced-motion path: the word is chosen once for the run
|
|
29
|
+
* and then holds. Rotating text IS motion, and a user who asked for
|
|
30
|
+
* `reducedMotion` asked for the screen to stop changing under them; degrading
|
|
31
|
+
* only the spinner would honor the letter of the setting and not its point.
|
|
32
|
+
*/
|
|
33
|
+
export declare function pickActivityPhrase(startedAt: number, now: number, rotate?: boolean): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The working line's vocabulary (agent-activity-presentation §3.2.1).
|
|
3
|
+
*
|
|
4
|
+
* PURE: no React, no Ink, no clock. Everything here is a function of the two
|
|
5
|
+
* numbers the caller already has, which is what makes the rotation testable
|
|
6
|
+
* without a terminal and without fake timers.
|
|
7
|
+
*/
|
|
8
|
+
/** Rotation cadence. Long enough to read, short enough to prove liveness. */
|
|
9
|
+
export const PHRASE_ROTATE_MS = 4_000;
|
|
10
|
+
/**
|
|
11
|
+
* ASCII ONLY, by the rule this tree is under (`glyphs.ts` header, enforced by
|
|
12
|
+
* `glyphs.test.ts`'s static scan). Present participles, so the line reads as an
|
|
13
|
+
* action in progress; twelve characters or fewer so the row never fights the
|
|
14
|
+
* terminal's width.
|
|
15
|
+
*/
|
|
16
|
+
export const ACTIVITY_PHRASES = [
|
|
17
|
+
'Assembling', 'Brewing', 'Calibrating', 'Cogitating', 'Composing',
|
|
18
|
+
'Computing', 'Concocting', 'Conjuring', 'Considering', 'Crunching',
|
|
19
|
+
'Distilling', 'Divining', 'Drafting', 'Excavating', 'Fathoming',
|
|
20
|
+
'Fermenting', 'Finessing', 'Foraging', 'Germinating', 'Herding',
|
|
21
|
+
'Incubating', 'Inferring', 'Marinating', 'Meandering', 'Mulling',
|
|
22
|
+
'Musing', 'Noodling', 'Percolating', 'Pondering', 'Puzzling',
|
|
23
|
+
'Ruminating', 'Scheming', 'Sculpting', 'Simmering', 'Sketching',
|
|
24
|
+
'Spelunking', 'Steeping', 'Stitching', 'Summoning', 'Tinkering',
|
|
25
|
+
'Unfurling', 'Untangling', 'Weaving', 'Whittling', 'Wrangling',
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* The phrase for a run at a moment.
|
|
29
|
+
*
|
|
30
|
+
* PURE AND TOTAL: the same inputs give the same word, for any finite numbers,
|
|
31
|
+
* including a `now` before `startedAt`.
|
|
32
|
+
*
|
|
33
|
+
* The run's start seeds the sequence, so two consecutive runs do not open on the
|
|
34
|
+
* same word — which is what makes the line read as alive rather than as a fixed
|
|
35
|
+
* label. (The seed's granularity is one second, so two runs starting inside the
|
|
36
|
+
* same second do share an opening word — P2-6.)
|
|
37
|
+
*
|
|
38
|
+
* `rotate: false` is the reduced-motion path: the word is chosen once for the run
|
|
39
|
+
* and then holds. Rotating text IS motion, and a user who asked for
|
|
40
|
+
* `reducedMotion` asked for the screen to stop changing under them; degrading
|
|
41
|
+
* only the spinner would honor the letter of the setting and not its point.
|
|
42
|
+
*/
|
|
43
|
+
export function pickActivityPhrase(startedAt, now, rotate = true) {
|
|
44
|
+
const n = ACTIVITY_PHRASES.length;
|
|
45
|
+
if (!Number.isFinite(startedAt) || !Number.isFinite(now))
|
|
46
|
+
return ACTIVITY_PHRASES[0];
|
|
47
|
+
const seed = Math.floor(startedAt / 1000);
|
|
48
|
+
const step = rotate ? Math.floor(Math.max(0, now - startedAt) / PHRASE_ROTATE_MS) : 0;
|
|
49
|
+
return ACTIVITY_PHRASES[(((seed + step) % n) + n) % n];
|
|
50
|
+
}
|
|
@@ -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,38 @@ interface AssistantEntryProps {
|
|
|
16
23
|
thinkingVisible: boolean;
|
|
17
24
|
streaming: boolean;
|
|
18
25
|
aborted?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Sealed reasoning time (§3.1.2). OMITTED WHEN UNKNOWN rather than printed as
|
|
28
|
+
* `0s`: a session restored from a file written by an older build has none, and
|
|
29
|
+
* `thought for 0s` is a lie where a bare `thought` is merely terse.
|
|
30
|
+
*/
|
|
31
|
+
thinkingMs?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Whether pressing `Ctrl+T` would actually reveal THIS entry's body — i.e.
|
|
34
|
+
* `mode === 'fullscreen'` (D-16 / P0-2).
|
|
35
|
+
*
|
|
36
|
+
* In inline mode a settled entry has already been printed into Ink's
|
|
37
|
+
* `<Static>`, which cannot un-print or re-print (`Transcript.tsx:9-10`), and
|
|
38
|
+
* the settled boundary is held monotonic precisely so an entry never flows back
|
|
39
|
+
* out. With thinking hidden by default the body is never printed there AT ALL,
|
|
40
|
+
* so a row reading `ctrl+t to show` would be an instruction that provably does
|
|
41
|
+
* nothing. The row degrades to `* thought for 12s` instead — still honest that
|
|
42
|
+
* reasoning happened and how long it took, and silent about a key that cannot
|
|
43
|
+
* deliver.
|
|
44
|
+
*/
|
|
45
|
+
revealable?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Inline mode only (L5): keep at most this many rows of a LIVE entry, so the
|
|
48
|
+
* non-`<Static>` region cannot reach `stdout.rows`. `undefined` means no
|
|
49
|
+
* clamp on the ANSWER BODY, which is what the full-screen branch passes — so
|
|
50
|
+
* the rendered answer there is byte-identical to a pre-feature build. (The
|
|
51
|
+
* thinking block has its own streaming clamp below and is not covered by that
|
|
52
|
+
* statement.)
|
|
53
|
+
*/
|
|
54
|
+
liveClampRows?: number;
|
|
19
55
|
theme: Theme;
|
|
20
56
|
caps: TermCapabilities;
|
|
21
57
|
}
|
|
22
|
-
|
|
58
|
+
declare function AssistantEntryImpl(props: AssistantEntryProps): React.ReactElement | null;
|
|
59
|
+
export declare const AssistantEntry: React.MemoExoticComponent<typeof AssistantEntryImpl>;
|
|
23
60
|
export {};
|
|
@@ -1,13 +1,44 @@
|
|
|
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
|
+
import { formatDuration } from '../../agent/usage.js';
|
|
24
|
+
function AssistantEntryImpl(props) {
|
|
25
|
+
const { text, thinking, thinkingOpen, thinkingVisible, streaming, aborted, thinkingMs, revealable, liveClampRows, theme, caps, } = props;
|
|
7
26
|
const glyphs = pickGlyphs(caps);
|
|
8
27
|
const hasThinking = !!thinking && thinking.trim().length > 0;
|
|
9
28
|
const hasText = text.trim().length > 0;
|
|
10
29
|
if (!hasThinking && !hasText && !streaming)
|
|
11
30
|
return null;
|
|
12
|
-
|
|
31
|
+
// The thinking block is clamped to its TAIL while streaming even in
|
|
32
|
+
// full-screen mode: it is scratch reasoning the user reads the newest end of,
|
|
33
|
+
// and an unbounded one is the single easiest way to push a live entry past the
|
|
34
|
+
// whole viewport.
|
|
35
|
+
const thinkingRows = liveClampRows ?? (streaming ? STREAMING_THINKING_ROWS : undefined);
|
|
36
|
+
const clampedThinking = hasThinking && thinkingRows !== undefined
|
|
37
|
+
? clampLiveText(thinking, thinkingRows)
|
|
38
|
+
: { text: thinking ?? '', hiddenRows: 0 };
|
|
39
|
+
const body = liveClampRows === undefined ? { text, hiddenRows: 0 } : clampLiveText(text, liveClampRows);
|
|
40
|
+
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)))] })] })), hasThinking && !thinkingVisible && !streaming && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [glyphs.thinking, " thought", thinkingMs !== undefined ? ` for ${formatDuration(thinkingMs)}` : '', revealable ? ` ${glyphs.midDot} ctrl+t to show` : ''] })), (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
41
|
}
|
|
42
|
+
/** Rows of live thinking kept on screen while an answer streams. */
|
|
43
|
+
const STREAMING_THINKING_ROWS = 24;
|
|
44
|
+
export const AssistantEntry = React.memo(AssistantEntryImpl);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `FilePatch` as a diff (agent-activity-presentation §3.4): a `+6 -5` summary,
|
|
3
|
+
* then hunk-separated rows with a right-aligned line-number gutter, a `+`/`-`/
|
|
4
|
+
* space sign column and `theme.diff` colors.
|
|
5
|
+
*
|
|
6
|
+
* `wrap="truncate"` ON EVERY ROW IS A CORRECTNESS REQUIREMENT, NOT A TASTE ONE.
|
|
7
|
+
* `estimateEntryRows` computes a diff card's height by COUNTING rows, so a
|
|
8
|
+
* wrapped patch line would occupy two rows while the estimate charged one — an
|
|
9
|
+
* under-estimate, which `virtual-window.ts` names as the unsafe direction. One
|
|
10
|
+
* patch line is one terminal row, always.
|
|
11
|
+
*
|
|
12
|
+
* No new keybinding and no new state: collapse rides on `expandedToolIds` and
|
|
13
|
+
* the existing `Ctrl+O` handler, which targets the MOST RECENT tool card (P2-4).
|
|
14
|
+
*
|
|
15
|
+
* `caps` is deliberately NOT a prop. Every character this component emits is
|
|
16
|
+
* ASCII — `+`, `-`, `@@`, digits — because `+` and `-` are the diff's own
|
|
17
|
+
* vocabulary and `renderUnifiedDiff` already spells them literally. A glyph
|
|
18
|
+
* lookup here would buy nothing and add a memo dependency.
|
|
19
|
+
*/
|
|
20
|
+
import React from 'react';
|
|
21
|
+
import type { Theme } from '../theme.js';
|
|
22
|
+
import type { FilePatch, PatchLine } from '../../tools/patch.js';
|
|
23
|
+
/**
|
|
24
|
+
* Diff rows shown before collapse.
|
|
25
|
+
*
|
|
26
|
+
* Higher than the generic preview's 8 (D-11): a diff's rows are half as tall in
|
|
27
|
+
* information terms, and 8 rows of a 3-context hunk is barely one change.
|
|
28
|
+
* DUPLICATED AS A NUMBER in `virtual-window.ts`, matching what
|
|
29
|
+
* `TOOL_COLLAPSED_LINES` already does and the comment that justifies it.
|
|
30
|
+
*/
|
|
31
|
+
export declare const DIFF_COLLAPSED_LINES = 12;
|
|
32
|
+
export interface DiffViewProps {
|
|
33
|
+
patch: FilePatch;
|
|
34
|
+
expanded?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Inline mode only (L5): ceiling on the rows a LIVE card contributes to the
|
|
37
|
+
* non-`<Static>` region. `undefined` — what the full-screen branch passes —
|
|
38
|
+
* means no clamp.
|
|
39
|
+
*/
|
|
40
|
+
liveClampRows?: number;
|
|
41
|
+
theme: Theme;
|
|
42
|
+
}
|
|
43
|
+
/** One rendered row: a patch line, or the `@@` separator between two hunks. */
|
|
44
|
+
interface DiffRow {
|
|
45
|
+
kind: PatchLine['kind'] | 'meta';
|
|
46
|
+
/** The gutter number, or `undefined` for a separator and the newline note. */
|
|
47
|
+
num?: number;
|
|
48
|
+
text: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Flatten the hunks into rows, inserting one `@@` separator BETWEEN hunks and
|
|
52
|
+
* none before the first.
|
|
53
|
+
*
|
|
54
|
+
* Exported for `diff-view.test.tsx`, and because the row count it produces is
|
|
55
|
+
* the number `estimateEntryRows` reproduces arithmetically — the two must not
|
|
56
|
+
* disagree.
|
|
57
|
+
*/
|
|
58
|
+
export declare function diffRows(patch: FilePatch): DiffRow[];
|
|
59
|
+
declare function DiffViewImpl({ patch, expanded, liveClampRows, theme, }: DiffViewProps): React.ReactElement;
|
|
60
|
+
/**
|
|
61
|
+
* `React.memo` with the DEFAULT comparator, matching `ToolCard`'s boundary.
|
|
62
|
+
* `patch` qualifies as a prop only because the reducer writes it ONCE at
|
|
63
|
+
* `toolExecEnd` and never rebuilds it (P2-9); `theme` is `useMemo`d in `App.tsx`
|
|
64
|
+
* (I-L2-1).
|
|
65
|
+
*/
|
|
66
|
+
export declare const DiffView: React.MemoExoticComponent<typeof DiffViewImpl>;
|
|
67
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* A `FilePatch` as a diff (agent-activity-presentation §3.4): a `+6 -5` summary,
|
|
4
|
+
* then hunk-separated rows with a right-aligned line-number gutter, a `+`/`-`/
|
|
5
|
+
* space sign column and `theme.diff` colors.
|
|
6
|
+
*
|
|
7
|
+
* `wrap="truncate"` ON EVERY ROW IS A CORRECTNESS REQUIREMENT, NOT A TASTE ONE.
|
|
8
|
+
* `estimateEntryRows` computes a diff card's height by COUNTING rows, so a
|
|
9
|
+
* wrapped patch line would occupy two rows while the estimate charged one — an
|
|
10
|
+
* under-estimate, which `virtual-window.ts` names as the unsafe direction. One
|
|
11
|
+
* patch line is one terminal row, always.
|
|
12
|
+
*
|
|
13
|
+
* No new keybinding and no new state: collapse rides on `expandedToolIds` and
|
|
14
|
+
* the existing `Ctrl+O` handler, which targets the MOST RECENT tool card (P2-4).
|
|
15
|
+
*
|
|
16
|
+
* `caps` is deliberately NOT a prop. Every character this component emits is
|
|
17
|
+
* ASCII — `+`, `-`, `@@`, digits — because `+` and `-` are the diff's own
|
|
18
|
+
* vocabulary and `renderUnifiedDiff` already spells them literally. A glyph
|
|
19
|
+
* lookup here would buy nothing and add a memo dependency.
|
|
20
|
+
*/
|
|
21
|
+
import React from 'react';
|
|
22
|
+
import { Box, Text } from 'ink';
|
|
23
|
+
/**
|
|
24
|
+
* Diff rows shown before collapse.
|
|
25
|
+
*
|
|
26
|
+
* Higher than the generic preview's 8 (D-11): a diff's rows are half as tall in
|
|
27
|
+
* information terms, and 8 rows of a 3-context hunk is barely one change.
|
|
28
|
+
* DUPLICATED AS A NUMBER in `virtual-window.ts`, matching what
|
|
29
|
+
* `TOOL_COLLAPSED_LINES` already does and the comment that justifies it.
|
|
30
|
+
*/
|
|
31
|
+
export const DIFF_COLLAPSED_LINES = 12;
|
|
32
|
+
/** Gutter width bounds; three digits is the floor so narrow files still align. */
|
|
33
|
+
const GUTTER_MIN = 3;
|
|
34
|
+
const GUTTER_MAX = 6;
|
|
35
|
+
/**
|
|
36
|
+
* Flatten the hunks into rows, inserting one `@@` separator BETWEEN hunks and
|
|
37
|
+
* none before the first.
|
|
38
|
+
*
|
|
39
|
+
* Exported for `diff-view.test.tsx`, and because the row count it produces is
|
|
40
|
+
* the number `estimateEntryRows` reproduces arithmetically — the two must not
|
|
41
|
+
* disagree.
|
|
42
|
+
*/
|
|
43
|
+
export function diffRows(patch) {
|
|
44
|
+
const rows = [];
|
|
45
|
+
patch.hunks.forEach((hunk, index) => {
|
|
46
|
+
if (index > 0) {
|
|
47
|
+
rows.push({
|
|
48
|
+
kind: 'meta',
|
|
49
|
+
text: `@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
for (const line of hunk.lines) {
|
|
53
|
+
rows.push({
|
|
54
|
+
kind: line.kind,
|
|
55
|
+
// An `add` shows its new number, a `del` its old, a `ctx` its new.
|
|
56
|
+
...(pickNumber(line) !== undefined ? { num: pickNumber(line) } : {}),
|
|
57
|
+
text: line.text,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
return rows;
|
|
62
|
+
}
|
|
63
|
+
function pickNumber(line) {
|
|
64
|
+
return line.kind === 'del' ? line.oldLine : line.newLine ?? line.oldLine;
|
|
65
|
+
}
|
|
66
|
+
function signOf(kind) {
|
|
67
|
+
if (kind === 'add')
|
|
68
|
+
return '+';
|
|
69
|
+
if (kind === 'del')
|
|
70
|
+
return '-';
|
|
71
|
+
return ' ';
|
|
72
|
+
}
|
|
73
|
+
function colorOf(kind, theme) {
|
|
74
|
+
if (kind === 'add')
|
|
75
|
+
return theme.diff.add;
|
|
76
|
+
if (kind === 'del')
|
|
77
|
+
return theme.diff.remove;
|
|
78
|
+
if (kind === 'meta')
|
|
79
|
+
return theme.diff.meta;
|
|
80
|
+
return theme.diff.context;
|
|
81
|
+
}
|
|
82
|
+
function gutterWidth(rows) {
|
|
83
|
+
let max = 0;
|
|
84
|
+
for (const row of rows)
|
|
85
|
+
if (row.num !== undefined && row.num > max)
|
|
86
|
+
max = row.num;
|
|
87
|
+
return Math.min(GUTTER_MAX, Math.max(GUTTER_MIN, String(max).length));
|
|
88
|
+
}
|
|
89
|
+
function DiffViewImpl({ patch, expanded, liveClampRows, theme, }) {
|
|
90
|
+
const rows = diffRows(patch);
|
|
91
|
+
const ceiling = Math.min(expanded ? rows.length : DIFF_COLLAPSED_LINES, liveClampRows ?? Number.POSITIVE_INFINITY);
|
|
92
|
+
const visible = ceiling >= rows.length ? rows : rows.slice(0, Math.max(0, ceiling));
|
|
93
|
+
const hidden = rows.length - visible.length;
|
|
94
|
+
const width = gutterWidth(rows);
|
|
95
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: theme.diff.add, children: ["+", patch.added] }), _jsx(Text, { color: theme.muted, children: " " }), _jsxs(Text, { color: theme.diff.remove, children: ["-", patch.removed] }), patch.truncated && _jsx(Text, { color: theme.muted, children: " (truncated)" }), patch.degraded && (_jsxs(Text, { color: theme.muted, children: [" old side not read: ", patch.degraded] }))] }), visible.map((row, i) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: theme.muted, children: String(row.num ?? '').padStart(width) }), _jsxs(Text, { color: colorOf(row.kind, theme), children: [' ', signOf(row.kind), " ", row.text] })] }, i))), hidden > 0 ? (_jsxs(Text, { color: theme.muted, children: ["+", hidden, " ", hidden === 1 ? 'line' : 'lines', " (Ctrl+O)"] })) : (rows.length > DIFF_COLLAPSED_LINES && _jsx(Text, { color: theme.muted, children: "(Ctrl+O to collapse)" }))] }));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* `React.memo` with the DEFAULT comparator, matching `ToolCard`'s boundary.
|
|
99
|
+
* `patch` qualifies as a prop only because the reducer writes it ONCE at
|
|
100
|
+
* `toolExecEnd` and never rebuilds it (P2-9); `theme` is `useMemo`d in `App.tsx`
|
|
101
|
+
* (I-L2-1).
|
|
102
|
+
*/
|
|
103
|
+
export const DiffView = React.memo(DiffViewImpl);
|