@aragon-agent/cli 0.6.29 → 0.6.31
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 +2123 -2103
- package/README.md +81 -14
- package/dist/agent/project-guidance-prompt.js +20 -20
- package/dist/cli.js +169 -56
- package/dist/commands/builtins.js +4 -0
- package/dist/commands/perf.d.ts +25 -1
- package/dist/commands/perf.js +26 -2
- package/dist/commands/registry.d.ts +6 -0
- package/dist/config/env.js +4 -0
- package/dist/config/load.d.ts +3 -1
- package/dist/config/load.js +12 -1
- package/dist/config/schema.d.ts +27 -0
- package/dist/config/schema.js +9 -0
- package/dist/config/ui-state.d.ts +9 -0
- package/dist/config/ui-state.js +9 -0
- package/dist/ui/App.d.ts +11 -1
- package/dist/ui/App.js +122 -62
- package/dist/ui/ScrollbackShell.d.ts +28 -0
- package/dist/ui/ScrollbackShell.js +10 -0
- package/dist/ui/frame-differ.d.ts +12 -0
- package/dist/ui/overlays/HelpOverlay.d.ts +1 -1
- package/dist/ui/overlays/HelpOverlay.js +21 -3
- package/dist/ui/screen.d.ts +16 -0
- package/dist/ui/screen.js +32 -0
- package/dist/ui/scrollback/budget.d.ts +43 -0
- package/dist/ui/scrollback/budget.js +44 -0
- package/dist/ui/scrollback/drain.d.ts +32 -0
- package/dist/ui/scrollback/drain.js +64 -0
- package/dist/ui/scrollback/exit-summary.d.ts +23 -0
- package/dist/ui/scrollback/exit-summary.js +25 -0
- package/dist/ui/scrollback/limits.d.ts +25 -0
- package/dist/ui/scrollback/limits.js +25 -0
- package/dist/ui/scrollback/live-preview.d.ts +26 -0
- package/dist/ui/scrollback/live-preview.js +106 -0
- package/dist/ui/scrollback/renderer.d.ts +38 -0
- package/dist/ui/scrollback/renderer.js +158 -0
- package/dist/ui/scrollback/scrollback-writer.d.ts +74 -0
- package/dist/ui/scrollback/scrollback-writer.js +263 -0
- package/dist/ui/scrollback/settled-boundary.d.ts +42 -0
- package/dist/ui/scrollback/settled-boundary.js +106 -0
- package/dist/ui/scrollback/use-scrollback-view.d.ts +92 -0
- package/dist/ui/scrollback/use-scrollback-view.js +263 -0
- package/dist/ui/scrollback/writer-shapes.d.ts +61 -0
- package/dist/ui/scrollback/writer-shapes.js +114 -0
- package/dist/ui/stdout-frame-writer.js +12 -2
- package/dist/ui/terminal-plan.d.ts +52 -0
- package/dist/ui/terminal-plan.js +49 -0
- package/dist/ui/transcript-text.d.ts +11 -0
- package/dist/ui/transcript-text.js +21 -7
- package/dist/ui/use-startup-notices.d.ts +12 -1
- package/dist/ui/use-startup-notices.js +28 -2
- package/package.json +2 -2
- package/skills/project-indexer/SKILL.md +163 -163
- package/skills/project-indexer/templates/cleancode-overrides.md +46 -46
- package/skills/project-indexer/templates/cleancode-template.md +94 -94
- package/skills/project-indexer/templates/config-template.md +69 -69
- package/skills/project-indexer/templates/index-template.md +129 -129
- package/skills/skill-creator/SKILL.md +96 -96
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScrollbackWriter — the byte contract of native-scroll mode
|
|
3
|
+
* (tui-native-terminal-scroll §4.3).
|
|
4
|
+
*
|
|
5
|
+
* It implements `FrameDiffer`, so `stdout-frame-writer.ts::wrapStdoutForFrames`
|
|
6
|
+
* binds it to a real stream exactly as it binds the full-screen differ. What it
|
|
7
|
+
* does differently: the screen is NOT the app's. Settled conversation rows are
|
|
8
|
+
* printed into the host terminal's own scrollback, and Ink's frame is only a small
|
|
9
|
+
* LIVE REGION parked directly under the last printed row.
|
|
10
|
+
*
|
|
11
|
+
* GEOMETRY IS RELATIVE (D2). A batch is `CUU(H0)` to the top of the previous
|
|
12
|
+
* region, erase down, print static rows, print the new region, park one row below
|
|
13
|
+
* it. Nothing in that sequence depends on the terminal's absolute row count, so a
|
|
14
|
+
* terminal that scrolls underneath it cannot misplace anything.
|
|
15
|
+
*
|
|
16
|
+
* THE INVARIANTS THAT FAIL SILENTLY IF DROPPED:
|
|
17
|
+
* - I-S4 · every batch parks the cursor at (regionBottom + 1, column 0), the same
|
|
18
|
+
* place Ink's `previousLineCount = H + 1` expects, so falling back to a raw Ink
|
|
19
|
+
* write is still correct.
|
|
20
|
+
* - I-S8 · geometry in doubt (foreign write, unparseable chunk) => `prev = null`
|
|
21
|
+
* => next batch is a SEED. Never keep erasing on a stale `H0`.
|
|
22
|
+
* - I-S14 · after `finalize` EVERY Ink write is swallowed. Ink's unmount renders
|
|
23
|
+
* once more (and a 32 ms trailing throttle call can follow); passed through,
|
|
24
|
+
* its `eraseLines(H + 1)` would erase the summary and the end of the
|
|
25
|
+
* conversation. With an unknown or sub-2-row terminal the frame is still
|
|
26
|
+
* parsed so `prev`/`H0` track Ink's own `previousLineCount`.
|
|
27
|
+
* - I-S13 · no emitted row is wider than `cols - 1` (clipped, counted).
|
|
28
|
+
*/
|
|
29
|
+
import { parseInkFrame } from '../frame-parser.js';
|
|
30
|
+
import { SCROLLBACK_LIMITS } from './limits.js';
|
|
31
|
+
import { clipLine, diffBytes, estimateReflowRows, finalizeBytes, fullBytes, seedBytes, } from './writer-shapes.js';
|
|
32
|
+
/** `ansiEscapes.clearTerminal` — Ink's tall-frame write. It contains `ESC[3J`. */
|
|
33
|
+
const CLEAR_TERMINAL = '\x1b[2J\x1b[3J\x1b[H';
|
|
34
|
+
export function createScrollbackWriter(o) {
|
|
35
|
+
const now = o.now ?? Date.now;
|
|
36
|
+
const maxBatch = SCROLLBACK_LIMITS.maxRowsPerBatch;
|
|
37
|
+
/** Region lines as written (clipped), or null when the geometry is in doubt. */
|
|
38
|
+
let prev = null;
|
|
39
|
+
/** The same lines before clipping, so unchanged rows are not re-measured. */
|
|
40
|
+
let prevRaw = null;
|
|
41
|
+
/** Rows between region top and the parked cursor. Tracks Ink's own count. */
|
|
42
|
+
let regionHeight = 0;
|
|
43
|
+
/** Whether anything has ever been written (a re-seed then starts on a new line). */
|
|
44
|
+
let everWritten = false;
|
|
45
|
+
let seenAnyChunk = false;
|
|
46
|
+
let finalized = false;
|
|
47
|
+
let guardTripped = false;
|
|
48
|
+
let consecutiveGuard = 0;
|
|
49
|
+
let lastResizeAt = Number.NEGATIVE_INFINITY;
|
|
50
|
+
let resizePending = false;
|
|
51
|
+
/** Width the previous region was written at, for the reflow estimate. */
|
|
52
|
+
let lastWriteCols;
|
|
53
|
+
let repaintQueued = false;
|
|
54
|
+
const stats = {
|
|
55
|
+
framesSeed: 0, framesDiff: 0, framesFull: 0, staticRowsWritten: 0, bytesWritten: 0,
|
|
56
|
+
fallbacks: 0, guardHits: 0, guardHitsOutsideResize: 0, clippedRows: 0, elidedEntries: 0,
|
|
57
|
+
resizes: 0, repaints: 0,
|
|
58
|
+
};
|
|
59
|
+
const liveCols = () => {
|
|
60
|
+
const cols = o.cols();
|
|
61
|
+
return cols !== undefined && Number.isFinite(cols) && cols >= 1 ? Math.floor(cols) : undefined;
|
|
62
|
+
};
|
|
63
|
+
const liveRows = () => {
|
|
64
|
+
const rows = o.rows();
|
|
65
|
+
return rows !== undefined && Number.isFinite(rows) ? Math.floor(rows) : undefined;
|
|
66
|
+
};
|
|
67
|
+
const scheduleRepaint = () => {
|
|
68
|
+
if (!o.requestRepaint || repaintQueued || finalized)
|
|
69
|
+
return;
|
|
70
|
+
repaintQueued = true;
|
|
71
|
+
// Deferred by the host (setImmediate): this can fire from inside Ink's write.
|
|
72
|
+
o.requestRepaint();
|
|
73
|
+
};
|
|
74
|
+
const invalidate = () => {
|
|
75
|
+
prev = null;
|
|
76
|
+
prevRaw = null;
|
|
77
|
+
resizePending = false;
|
|
78
|
+
};
|
|
79
|
+
/** Clip every row to `cols - 1`, reusing the previous result for unchanged rows (I-S13). */
|
|
80
|
+
const clipRegion = (lines, cols) => {
|
|
81
|
+
if (cols === undefined)
|
|
82
|
+
return [...lines];
|
|
83
|
+
const limit = cols - 1;
|
|
84
|
+
return lines.map((line, i) => {
|
|
85
|
+
if (prevRaw && prev && prevRaw[i] === line && prev[i] !== undefined)
|
|
86
|
+
return prev[i];
|
|
87
|
+
const result = clipLine(line, limit);
|
|
88
|
+
if (result.clipped)
|
|
89
|
+
stats.clippedRows += 1;
|
|
90
|
+
return result.text;
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
const clipStatics = (rows, cols) => {
|
|
94
|
+
if (cols === undefined || rows.length === 0)
|
|
95
|
+
return rows;
|
|
96
|
+
return rows.map((line) => {
|
|
97
|
+
const result = clipLine(line, cols - 1);
|
|
98
|
+
if (result.clipped)
|
|
99
|
+
stats.clippedRows += 1;
|
|
100
|
+
return result.text;
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
/** Rows to move up to reach the region top: the plain height, or a post-resize estimate. */
|
|
104
|
+
const eraseDistance = (rows, cols) => {
|
|
105
|
+
if (!resizePending || prev === null || cols === undefined)
|
|
106
|
+
return regionHeight;
|
|
107
|
+
const reflowed = estimateReflowRows(prev, cols);
|
|
108
|
+
return Math.min(Math.max(0, rows - 1), reflowed);
|
|
109
|
+
};
|
|
110
|
+
const record = (bytes, shape, statics) => {
|
|
111
|
+
if (shape === 'seed')
|
|
112
|
+
stats.framesSeed += 1;
|
|
113
|
+
else if (shape === 'diff')
|
|
114
|
+
stats.framesDiff += 1;
|
|
115
|
+
else if (shape === 'full')
|
|
116
|
+
stats.framesFull += 1;
|
|
117
|
+
else
|
|
118
|
+
stats.repaints += 1;
|
|
119
|
+
stats.staticRowsWritten += statics;
|
|
120
|
+
stats.bytesWritten += bytes.length;
|
|
121
|
+
return bytes;
|
|
122
|
+
};
|
|
123
|
+
const afterBatch = (lines, raw, cols) => {
|
|
124
|
+
prev = lines;
|
|
125
|
+
prevRaw = raw;
|
|
126
|
+
regionHeight = lines.length;
|
|
127
|
+
everWritten = true;
|
|
128
|
+
resizePending = false;
|
|
129
|
+
lastWriteCols = cols;
|
|
130
|
+
// A feed larger than one batch continues on the next tick, region still parked.
|
|
131
|
+
if (o.drain.pending() > 0) {
|
|
132
|
+
repaintQueued = false;
|
|
133
|
+
scheduleRepaint();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const assemble = (lines, raw, rows, cols) => {
|
|
137
|
+
const statics = clipStatics(o.drain.takeUpTo(maxBatch), cols);
|
|
138
|
+
let out;
|
|
139
|
+
if (prev === null) {
|
|
140
|
+
out = record(seedBytes({ lines, statics, sync: o.sync, reseed: everWritten }), 'seed', statics.length);
|
|
141
|
+
}
|
|
142
|
+
else if (statics.length > 0 || lines.length !== prev.length || o.fullEveryFrame || resizePending) {
|
|
143
|
+
out = record(fullBytes({ lines, statics, eraseUp: eraseDistance(rows, cols), sync: o.sync }), 'full', statics.length);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
out = record(diffBytes({ lines, prev, sync: o.sync }), 'diff', 0);
|
|
147
|
+
}
|
|
148
|
+
afterBatch(lines, raw, cols);
|
|
149
|
+
return out;
|
|
150
|
+
};
|
|
151
|
+
const swallowTallFrame = () => {
|
|
152
|
+
stats.guardHits += 1;
|
|
153
|
+
if (now() - lastResizeAt >= SCROLLBACK_LIMITS.guardResizeGraceMs) {
|
|
154
|
+
stats.guardHitsOutsideResize += 1;
|
|
155
|
+
consecutiveGuard += 1;
|
|
156
|
+
if (consecutiveGuard >= SCROLLBACK_LIMITS.guardTripCount && !guardTripped) {
|
|
157
|
+
guardTripped = true;
|
|
158
|
+
o.onGuardTrip?.();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Swallow the whole chunk: it would clear the visible screen AND scrollback.
|
|
162
|
+
return '';
|
|
163
|
+
};
|
|
164
|
+
const countFallback = (counted) => {
|
|
165
|
+
invalidate();
|
|
166
|
+
if (counted) {
|
|
167
|
+
stats.fallbacks += 1;
|
|
168
|
+
if (stats.fallbacks === 1)
|
|
169
|
+
o.onFirstFallback?.();
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
};
|
|
173
|
+
const transform = (chunk) => {
|
|
174
|
+
if (finalized)
|
|
175
|
+
return '';
|
|
176
|
+
if (chunk.startsWith(CLEAR_TERMINAL))
|
|
177
|
+
return swallowTallFrame();
|
|
178
|
+
consecutiveGuard = 0;
|
|
179
|
+
guardTripped = false;
|
|
180
|
+
const firstChunk = !seenAnyChunk;
|
|
181
|
+
seenAnyChunk = true;
|
|
182
|
+
const rows = liveRows();
|
|
183
|
+
const lines = parseInkFrame(chunk);
|
|
184
|
+
if (rows === undefined || rows < 2) {
|
|
185
|
+
// Pass through, but keep `prev`/`H0` in step with Ink's own count (RV-6).
|
|
186
|
+
if (lines) {
|
|
187
|
+
prev = lines;
|
|
188
|
+
prevRaw = lines;
|
|
189
|
+
regionHeight = lines.length;
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
if (lines === null)
|
|
194
|
+
return countFallback(!firstChunk);
|
|
195
|
+
if (lines.length + 1 > rows) {
|
|
196
|
+
// Unreachable by construction (I-S1); never erase on a guess.
|
|
197
|
+
invalidate();
|
|
198
|
+
return '';
|
|
199
|
+
}
|
|
200
|
+
const cols = liveCols();
|
|
201
|
+
return assemble(clipRegion(lines, cols), lines, rows, cols);
|
|
202
|
+
};
|
|
203
|
+
const repaint = () => {
|
|
204
|
+
repaintQueued = false;
|
|
205
|
+
if (finalized || prev === null || prevRaw === null)
|
|
206
|
+
return '';
|
|
207
|
+
if (o.drain.pending() === 0 && !resizePending)
|
|
208
|
+
return '';
|
|
209
|
+
const rows = liveRows();
|
|
210
|
+
if (rows === undefined || rows < 2)
|
|
211
|
+
return '';
|
|
212
|
+
const cols = liveCols();
|
|
213
|
+
const lines = prev;
|
|
214
|
+
const statics = clipStatics(o.drain.takeUpTo(maxBatch), cols);
|
|
215
|
+
const bytes = fullBytes({ lines, statics, eraseUp: eraseDistance(rows, cols), sync: o.sync });
|
|
216
|
+
// A repaint is its own counter: `framesFull` counts frames INK produced.
|
|
217
|
+
record(bytes, 'repaint', statics.length);
|
|
218
|
+
afterBatch(lines, prevRaw, cols);
|
|
219
|
+
return bytes;
|
|
220
|
+
};
|
|
221
|
+
const finalize = (summary) => {
|
|
222
|
+
if (finalized)
|
|
223
|
+
return '';
|
|
224
|
+
finalized = true;
|
|
225
|
+
const statics = clipStatics(o.drain.takeAll(), liveCols());
|
|
226
|
+
// Unknown geometry after a foreign write: no safe erase, start on a fresh line.
|
|
227
|
+
const eraseUp = prev === null && everWritten ? -1 : eraseDistance(liveRows() ?? 24, liveCols());
|
|
228
|
+
const bytes = finalizeBytes({ eraseUp, statics, summary });
|
|
229
|
+
stats.staticRowsWritten += statics.length;
|
|
230
|
+
stats.bytesWritten += bytes.length;
|
|
231
|
+
return bytes;
|
|
232
|
+
};
|
|
233
|
+
o.drain.onEnqueue = scheduleRepaint;
|
|
234
|
+
return {
|
|
235
|
+
transform,
|
|
236
|
+
repaint,
|
|
237
|
+
finalize,
|
|
238
|
+
invalidate,
|
|
239
|
+
onResize() {
|
|
240
|
+
stats.resizes += 1;
|
|
241
|
+
lastResizeAt = now();
|
|
242
|
+
consecutiveGuard = 0;
|
|
243
|
+
guardTripped = false;
|
|
244
|
+
// Keep `prev`: the reflow estimate needs the lines the terminal re-flowed.
|
|
245
|
+
if (prev !== null && !finalized)
|
|
246
|
+
resizePending = true;
|
|
247
|
+
},
|
|
248
|
+
cursorNeutralWrite() {
|
|
249
|
+
// OSC 52 and friends move no cell and no cursor (I-S11).
|
|
250
|
+
},
|
|
251
|
+
regionRows: () => regionHeight,
|
|
252
|
+
scrollbackStats: () => ({ ...stats, elidedEntries: o.drain.elided() }),
|
|
253
|
+
stats: () => ({
|
|
254
|
+
framesTotal: stats.framesSeed + stats.framesFull + stats.framesDiff,
|
|
255
|
+
framesDiffed: stats.framesDiff,
|
|
256
|
+
framesFull: stats.framesSeed + stats.framesFull,
|
|
257
|
+
linesWritten: stats.staticRowsWritten,
|
|
258
|
+
bytesWritten: stats.bytesWritten,
|
|
259
|
+
fallbacks: stats.fallbacks,
|
|
260
|
+
repaints: stats.repaints,
|
|
261
|
+
}),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settled boundary — which transcript entries may be PRINTED
|
|
3
|
+
* (tui-native-terminal-scroll §4.4).
|
|
4
|
+
*
|
|
5
|
+
* Printed rows live in the host terminal's scrollback and can never be rewritten
|
|
6
|
+
* ("printed is forever"). So the only entries that may be printed are the ones the
|
|
7
|
+
* reducer will NEVER write to again. This module derives that from the reducer's
|
|
8
|
+
* real write set, one exhaustive `switch` per entry kind. Adding an `Entry` kind is
|
|
9
|
+
* a compile error here (`never`), which is the whole point; widening the write set
|
|
10
|
+
* of an EXISTING kind is not caught by the compiler, so `agent/reducer.ts` carries
|
|
11
|
+
* a pointer comment beside the `Entry` union and the unit tests pin each row.
|
|
12
|
+
*
|
|
13
|
+
* Three rules that are easy to "simplify" wrongly:
|
|
14
|
+
* - A tool is stable only when `done` or `error`. `pending` is NOT stable: the
|
|
15
|
+
* card is created at `pending` and `toolCallDelta` keeps rewriting `argsRaw`
|
|
16
|
+
* while the model streams its arguments (RV-1).
|
|
17
|
+
* - A trailing `Context length exceeded` error notice is NOT stable while a run is
|
|
18
|
+
* active: `compactionStart` rewrites it in place into an info notice (RV-4).
|
|
19
|
+
* - A non-terminal `service` card is neither printed nor a wall (RV-3). It is
|
|
20
|
+
* rewritten for the whole session; the one-row `terminal: true` record the
|
|
21
|
+
* reducer appends at the end is an ordinary stable entry and carries the news.
|
|
22
|
+
*/
|
|
23
|
+
import type { Entry } from '../../agent/reducer.js';
|
|
24
|
+
/** Never printed and never a wall: rewritten for the whole session (RV-3). */
|
|
25
|
+
export declare function isExcluded(entry: Entry): boolean;
|
|
26
|
+
/** True when the reducer will never write to `entry` again. */
|
|
27
|
+
export declare function isEntryStable(entry: Entry, runActive: boolean, isTail: boolean): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Length of the printable PREFIX of `entries`.
|
|
30
|
+
*
|
|
31
|
+
* The first unstable (non-excluded) entry is a wall: everything before it may
|
|
32
|
+
* print, nothing after it may, which keeps scrollback order equal to conversation
|
|
33
|
+
* order (I-S3). Excluded entries are skipped, so a long-lived service card never
|
|
34
|
+
* blocks the conversation behind it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function settledCount(entries: readonly Entry[], runActive: boolean): number;
|
|
37
|
+
/**
|
|
38
|
+
* Everything printable at shutdown: all entries except those that never settle
|
|
39
|
+
* (`queued`, non-terminal services). The last answer must not be lost to a
|
|
40
|
+
* boundary that was waiting for a frame that will never come.
|
|
41
|
+
*/
|
|
42
|
+
export declare function settleAllEntries(entries: readonly Entry[]): Entry[];
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settled boundary — which transcript entries may be PRINTED
|
|
3
|
+
* (tui-native-terminal-scroll §4.4).
|
|
4
|
+
*
|
|
5
|
+
* Printed rows live in the host terminal's scrollback and can never be rewritten
|
|
6
|
+
* ("printed is forever"). So the only entries that may be printed are the ones the
|
|
7
|
+
* reducer will NEVER write to again. This module derives that from the reducer's
|
|
8
|
+
* real write set, one exhaustive `switch` per entry kind. Adding an `Entry` kind is
|
|
9
|
+
* a compile error here (`never`), which is the whole point; widening the write set
|
|
10
|
+
* of an EXISTING kind is not caught by the compiler, so `agent/reducer.ts` carries
|
|
11
|
+
* a pointer comment beside the `Entry` union and the unit tests pin each row.
|
|
12
|
+
*
|
|
13
|
+
* Three rules that are easy to "simplify" wrongly:
|
|
14
|
+
* - A tool is stable only when `done` or `error`. `pending` is NOT stable: the
|
|
15
|
+
* card is created at `pending` and `toolCallDelta` keeps rewriting `argsRaw`
|
|
16
|
+
* while the model streams its arguments (RV-1).
|
|
17
|
+
* - A trailing `Context length exceeded` error notice is NOT stable while a run is
|
|
18
|
+
* active: `compactionStart` rewrites it in place into an info notice (RV-4).
|
|
19
|
+
* - A non-terminal `service` card is neither printed nor a wall (RV-3). It is
|
|
20
|
+
* rewritten for the whole session; the one-row `terminal: true` record the
|
|
21
|
+
* reducer appends at the end is an ordinary stable entry and carries the news.
|
|
22
|
+
*/
|
|
23
|
+
import { CONTEXT_OVERFLOW_NOTICE_PREFIX } from '../../agent/reducer.js';
|
|
24
|
+
/** Kinds the reducer may still rewrite while a run is active, so a tail one is held. */
|
|
25
|
+
const MUTABLE_TAIL_KINDS = new Set([
|
|
26
|
+
'assistant', 'tool', 'compaction', 'todo', 'team', 'fast', 'retry', 'queued',
|
|
27
|
+
]);
|
|
28
|
+
/** Never printed and never a wall: rewritten for the whole session (RV-3). */
|
|
29
|
+
export function isExcluded(entry) {
|
|
30
|
+
return entry.kind === 'service' && entry.terminal !== true;
|
|
31
|
+
}
|
|
32
|
+
/** True when the reducer will never write to `entry` again. */
|
|
33
|
+
export function isEntryStable(entry, runActive, isTail) {
|
|
34
|
+
switch (entry.kind) {
|
|
35
|
+
case 'user':
|
|
36
|
+
return true;
|
|
37
|
+
case 'notice':
|
|
38
|
+
return !(runActive &&
|
|
39
|
+
isTail &&
|
|
40
|
+
entry.level === 'error' &&
|
|
41
|
+
entry.text.startsWith(CONTEXT_OVERFLOW_NOTICE_PREFIX));
|
|
42
|
+
case 'assistant':
|
|
43
|
+
return !entry.streaming;
|
|
44
|
+
case 'tool':
|
|
45
|
+
return entry.status === 'done' || entry.status === 'error';
|
|
46
|
+
case 'compaction':
|
|
47
|
+
return entry.live !== true;
|
|
48
|
+
case 'todo':
|
|
49
|
+
return entry.live === false;
|
|
50
|
+
case 'team':
|
|
51
|
+
return entry.active === false;
|
|
52
|
+
case 'fast':
|
|
53
|
+
return entry.live === false;
|
|
54
|
+
case 'retry':
|
|
55
|
+
return (entry.phase === 'recovered' ||
|
|
56
|
+
entry.phase === 'exhausted' ||
|
|
57
|
+
entry.phase === 'interrupted');
|
|
58
|
+
case 'queued':
|
|
59
|
+
// Rewritten in place into a `user` entry by the matching receipt.
|
|
60
|
+
return false;
|
|
61
|
+
case 'service':
|
|
62
|
+
// Only the terminal record is ever a candidate; `isExcluded` filters the rest.
|
|
63
|
+
return entry.terminal === true;
|
|
64
|
+
default: {
|
|
65
|
+
const exhaustive = entry;
|
|
66
|
+
return exhaustive;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Length of the printable PREFIX of `entries`.
|
|
72
|
+
*
|
|
73
|
+
* The first unstable (non-excluded) entry is a wall: everything before it may
|
|
74
|
+
* print, nothing after it may, which keeps scrollback order equal to conversation
|
|
75
|
+
* order (I-S3). Excluded entries are skipped, so a long-lived service card never
|
|
76
|
+
* blocks the conversation behind it.
|
|
77
|
+
*/
|
|
78
|
+
export function settledCount(entries, runActive) {
|
|
79
|
+
const last = entries.length - 1;
|
|
80
|
+
let count = entries.length;
|
|
81
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
82
|
+
const entry = entries[i];
|
|
83
|
+
if (isExcluded(entry))
|
|
84
|
+
continue;
|
|
85
|
+
if (!isEntryStable(entry, runActive, i === last)) {
|
|
86
|
+
count = i;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Hold a mutable TAIL while a run is active, but only a mutable one: a `user` or
|
|
91
|
+
// `notice` at the tail prints immediately (a submitted message must appear at once).
|
|
92
|
+
if (runActive && count === entries.length && count > 0) {
|
|
93
|
+
const tail = entries[last];
|
|
94
|
+
if (!isExcluded(tail) && MUTABLE_TAIL_KINDS.has(tail.kind))
|
|
95
|
+
return last;
|
|
96
|
+
}
|
|
97
|
+
return count;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Everything printable at shutdown: all entries except those that never settle
|
|
101
|
+
* (`queued`, non-terminal services). The last answer must not be lost to a
|
|
102
|
+
* boundary that was waiting for a frame that will never come.
|
|
103
|
+
*/
|
|
104
|
+
export function settleAllEntries(entries) {
|
|
105
|
+
return entries.filter((entry) => entry.kind !== 'queued' && !isExcluded(entry));
|
|
106
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useScrollbackView` — the promotion pipeline of native-scroll mode
|
|
3
|
+
* (tui-native-terminal-scroll §4.4 / §6.2).
|
|
4
|
+
*
|
|
5
|
+
* `App` calls it ALWAYS (hooks cannot be conditional); `enabled: false` makes every
|
|
6
|
+
* effect a no-op and keeps `scrollMode: 'app'` byte-identical to the pre-feature
|
|
7
|
+
* build (I-S9).
|
|
8
|
+
*
|
|
9
|
+
* What it does when enabled:
|
|
10
|
+
* 1. Prints the session header (and the opener for an empty session) once.
|
|
11
|
+
* 2. On every entries change, promotes each newly SETTLED entry: renders it once
|
|
12
|
+
* through the offscreen renderer and enqueues the rows for the writer.
|
|
13
|
+
* 3. Computes the live-region budget and the streaming preview rows for the shell.
|
|
14
|
+
*
|
|
15
|
+
* THE PROMOTION RUNS IN A MICROTASK, NOT IN THE EFFECT. The offscreen renderer is a
|
|
16
|
+
* second Ink instance, and both instances share one React reconciler. A `rerender`
|
|
17
|
+
* issued from inside the main tree's commit (a layout effect) is deferred until that
|
|
18
|
+
* commit finishes, so its frame would not exist when the effect reads it. A
|
|
19
|
+
* microtask runs after the commit has unwound, where the offscreen render is
|
|
20
|
+
* synchronous again. Everything the pass needs is read from refs for that reason.
|
|
21
|
+
*
|
|
22
|
+
* `printedIds` is the ONLY authority on "already printed" (RV-7). The cursor is just
|
|
23
|
+
* a hint for where to resume scanning: entries leave the list by retain-ring
|
|
24
|
+
* trimming AND by reducer deletion (empty settled assistants, abandoned tool
|
|
25
|
+
* cards), so an id-keyed cursor can dangle, and then the only correct answer is to
|
|
26
|
+
* rescan and let `printedIds` skip.
|
|
27
|
+
*/
|
|
28
|
+
import React from 'react';
|
|
29
|
+
import type { Entry } from '../../agent/reducer.js';
|
|
30
|
+
import type { TermCapabilities } from '../capabilities.js';
|
|
31
|
+
import type { DensityMode } from '../density.js';
|
|
32
|
+
import type { Theme } from '../theme.js';
|
|
33
|
+
import { type ScrollbackBudget } from './budget.js';
|
|
34
|
+
import type { ScrollbackDrain } from './drain.js';
|
|
35
|
+
import type { ScrollbackWriter } from './scrollback-writer.js';
|
|
36
|
+
/** The channel between `cli.tsx` (which owns the stream) and the React tree. */
|
|
37
|
+
export interface ScrollbackBridge {
|
|
38
|
+
writer: ScrollbackWriter;
|
|
39
|
+
drain: ScrollbackDrain;
|
|
40
|
+
/** Installed by `cli.tsx`: schedule `writer.repaint()` soon. */
|
|
41
|
+
requestFlush: () => void;
|
|
42
|
+
/**
|
|
43
|
+
* Installed by the hook on mount (reads the latest entries through a ref).
|
|
44
|
+
* `restore()` calls it on every exit path; it is idempotent through `printedIds`
|
|
45
|
+
* and it releases the offscreen renderer when done.
|
|
46
|
+
*/
|
|
47
|
+
settleAllNow: () => void;
|
|
48
|
+
/** Installed by `App`; at most `exitSummaryMaxRows` rows. Wrapped in try/catch by the caller. */
|
|
49
|
+
getSummaryRows: () => readonly string[];
|
|
50
|
+
/** Installed by `App`: the writer swallowed repeated tall frames (degrade to the placeholder). */
|
|
51
|
+
onGuardTrip: (() => void) | null;
|
|
52
|
+
}
|
|
53
|
+
export declare function createScrollbackBridge(parts: Pick<ScrollbackBridge, 'writer' | 'drain'> & {
|
|
54
|
+
requestFlush?: () => void;
|
|
55
|
+
}): ScrollbackBridge;
|
|
56
|
+
export type ScrollbackResetKind = 'clear' | 'restore';
|
|
57
|
+
export interface UseScrollbackViewArgs {
|
|
58
|
+
/** `scrollMode === 'native'`. */
|
|
59
|
+
enabled: boolean;
|
|
60
|
+
bridge: ScrollbackBridge | undefined;
|
|
61
|
+
entries: readonly Entry[];
|
|
62
|
+
runActive: boolean;
|
|
63
|
+
theme: Theme;
|
|
64
|
+
caps: TermCapabilities;
|
|
65
|
+
density: DensityMode;
|
|
66
|
+
thinkingVisible: boolean;
|
|
67
|
+
reducedMotion: boolean;
|
|
68
|
+
/** Terminal size. */
|
|
69
|
+
rows: number;
|
|
70
|
+
cols: number;
|
|
71
|
+
/** Printed first, once. */
|
|
72
|
+
headerNode: React.ReactNode;
|
|
73
|
+
/** Printed after the header for an empty session. */
|
|
74
|
+
openerNode: React.ReactNode | null;
|
|
75
|
+
overlayOpen: boolean;
|
|
76
|
+
draftRows: number;
|
|
77
|
+
popupRows: number;
|
|
78
|
+
statusExpanded: boolean;
|
|
79
|
+
/** Degrade the region to the placeholder (the writer's tall-frame guard tripped). */
|
|
80
|
+
forceInactive?: boolean;
|
|
81
|
+
}
|
|
82
|
+
export interface ScrollbackView {
|
|
83
|
+
budget: ScrollbackBudget | null;
|
|
84
|
+
liveTailLines: string[];
|
|
85
|
+
/** Print a fresh EXPANDED copy of an entry; false when it cannot be found / rendered. */
|
|
86
|
+
reprintExpanded(entryId: string): boolean;
|
|
87
|
+
/** Promote everything printable now (shutdown). */
|
|
88
|
+
settleAll(): void;
|
|
89
|
+
/** Called by `App`'s dispatch wrapper BEFORE a reset action reaches the reducer. */
|
|
90
|
+
notifyReset(kind: ScrollbackResetKind): void;
|
|
91
|
+
}
|
|
92
|
+
export declare function useScrollbackView(args: UseScrollbackViewArgs): ScrollbackView;
|