@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
|
+
* `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 { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
|
29
|
+
import { buildScrollbackBudget } from './budget.js';
|
|
30
|
+
import { buildLivePreview } from './live-preview.js';
|
|
31
|
+
import { SCROLLBACK_LIMITS } from './limits.js';
|
|
32
|
+
import { createEntryRenderer } from './renderer.js';
|
|
33
|
+
import { isExcluded, settleAllEntries, settledCount } from './settled-boundary.js';
|
|
34
|
+
export function createScrollbackBridge(parts) {
|
|
35
|
+
return {
|
|
36
|
+
writer: parts.writer,
|
|
37
|
+
drain: parts.drain,
|
|
38
|
+
requestFlush: parts.requestFlush ?? (() => undefined),
|
|
39
|
+
settleAllNow: () => undefined,
|
|
40
|
+
getSummaryRows: () => [],
|
|
41
|
+
onGuardTrip: null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const CLEARED_ROW = '---- conversation cleared ----';
|
|
45
|
+
const RESTORED_ROW = '---- session restored ----';
|
|
46
|
+
function printableBefore(entries, index) {
|
|
47
|
+
for (let i = index - 1; i >= 0; i -= 1) {
|
|
48
|
+
if (!isExcluded(entries[i]))
|
|
49
|
+
return entries[i];
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Ids leave `entries` by retain-ring trimming and never come back (ids are unique
|
|
55
|
+
* per session), so `printedIds` is trimmed to the live list once it grows well past
|
|
56
|
+
* it. Only ids still present can be re-evaluated, so dropping the rest is lossless.
|
|
57
|
+
*/
|
|
58
|
+
function pruneEntryIds(printed, entries) {
|
|
59
|
+
if (printed.size <= Math.max(2000, entries.length * 3))
|
|
60
|
+
return;
|
|
61
|
+
const live = new Set(entries.map((entry) => entry.id));
|
|
62
|
+
for (const id of printed)
|
|
63
|
+
if (!live.has(id))
|
|
64
|
+
printed.delete(id);
|
|
65
|
+
}
|
|
66
|
+
export function useScrollbackView(args) {
|
|
67
|
+
const { enabled, bridge, entries, runActive, theme, caps, density, thinkingVisible, reducedMotion, rows, cols, headerNode, openerNode, overlayOpen, draftRows, popupRows, statusExpanded, forceInactive = false, } = args;
|
|
68
|
+
const contentCols = Math.max(1, cols - 1);
|
|
69
|
+
const startedRef = useRef(false);
|
|
70
|
+
// Everything the asynchronous pass reads travels here, refreshed on every render.
|
|
71
|
+
const latest = useRef({
|
|
72
|
+
entries, runActive, contentCols, headerNode, openerNode,
|
|
73
|
+
options: { theme, caps, density, thinkingVisible, reducedMotion },
|
|
74
|
+
});
|
|
75
|
+
latest.current = {
|
|
76
|
+
entries, runActive, contentCols,
|
|
77
|
+
// The banner nodes only matter until the banner is printed; afterwards keep the
|
|
78
|
+
// old references so a changing element identity is not mistaken for new input.
|
|
79
|
+
headerNode: startedRef.current ? latest.current.headerNode : headerNode,
|
|
80
|
+
openerNode: startedRef.current ? latest.current.openerNode : openerNode,
|
|
81
|
+
options: { theme, caps, density, thinkingVisible, reducedMotion },
|
|
82
|
+
};
|
|
83
|
+
const rendererRef = useRef(null);
|
|
84
|
+
const printedIds = useRef(new Set());
|
|
85
|
+
/** A resume hint only (RV-7): the id of the last entry the previous pass covered. */
|
|
86
|
+
const cursorRef = useRef(null);
|
|
87
|
+
const pendingReset = useRef(null);
|
|
88
|
+
const heldTailRows = useRef(0);
|
|
89
|
+
const pumpQueued = useRef(false);
|
|
90
|
+
const mountedRef = useRef(true);
|
|
91
|
+
const ensureRenderer = useCallback(() => {
|
|
92
|
+
if (!rendererRef.current)
|
|
93
|
+
rendererRef.current = createEntryRenderer(latest.current.options);
|
|
94
|
+
rendererRef.current.setOptions(latest.current.options);
|
|
95
|
+
return rendererRef.current;
|
|
96
|
+
}, []);
|
|
97
|
+
const enqueue = useCallback((rowsToPrint, id, kind) => {
|
|
98
|
+
if (rowsToPrint.length === 0 || !bridge)
|
|
99
|
+
return;
|
|
100
|
+
bridge.drain.enqueue(rowsToPrint, { id, kind });
|
|
101
|
+
}, [bridge]);
|
|
102
|
+
/** Promote `list[start..end)`; returns true when it stopped early because the feed is full. */
|
|
103
|
+
const promote = useCallback((list, end, limit) => {
|
|
104
|
+
const renderer = ensureRenderer();
|
|
105
|
+
let start = 0;
|
|
106
|
+
const cursor = cursorRef.current;
|
|
107
|
+
if (cursor && list[cursor.index]?.id === cursor.id)
|
|
108
|
+
start = cursor.index + 1;
|
|
109
|
+
let promoted = 0;
|
|
110
|
+
let lastIndex = start - 1;
|
|
111
|
+
const remember = () => {
|
|
112
|
+
cursorRef.current = lastIndex >= 0 ? { id: list[lastIndex].id, index: lastIndex } : null;
|
|
113
|
+
};
|
|
114
|
+
for (let i = start; i < end; i += 1) {
|
|
115
|
+
const entry = list[i];
|
|
116
|
+
if (isExcluded(entry) || printedIds.current.has(entry.id)) {
|
|
117
|
+
lastIndex = i;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (promoted >= limit) {
|
|
121
|
+
remember();
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
const rowsToPrint = renderer.renderEntry(entry, printableBefore(list, i), latest.current.contentCols);
|
|
125
|
+
enqueue(rowsToPrint, entry.id, entry.kind);
|
|
126
|
+
// A rendered-to-nothing entry is registered too, or it would be retried forever.
|
|
127
|
+
printedIds.current.add(entry.id);
|
|
128
|
+
promoted += 1;
|
|
129
|
+
lastIndex = i;
|
|
130
|
+
}
|
|
131
|
+
remember();
|
|
132
|
+
return false;
|
|
133
|
+
}, [ensureRenderer, enqueue]);
|
|
134
|
+
const printBanner = useCallback(() => {
|
|
135
|
+
if (startedRef.current)
|
|
136
|
+
return;
|
|
137
|
+
startedRef.current = true;
|
|
138
|
+
const renderer = ensureRenderer();
|
|
139
|
+
const { headerNode: header, openerNode: opener, contentCols: width } = latest.current;
|
|
140
|
+
enqueue(renderer.renderNode(header, width), 'startup:header', 'header');
|
|
141
|
+
if (opener)
|
|
142
|
+
enqueue(renderer.renderNode(opener, width), 'startup:opener', 'opener');
|
|
143
|
+
}, [ensureRenderer, enqueue]);
|
|
144
|
+
const applyPendingReset = useCallback(() => {
|
|
145
|
+
const reset = pendingReset.current;
|
|
146
|
+
if (!reset || latest.current.entries === reset.captured)
|
|
147
|
+
return;
|
|
148
|
+
pendingReset.current = null;
|
|
149
|
+
printedIds.current = new Set();
|
|
150
|
+
cursorRef.current = null;
|
|
151
|
+
enqueue([reset.kind === 'clear' ? CLEARED_ROW : RESTORED_ROW], `reset:${Date.now()}`, 'notice');
|
|
152
|
+
}, [enqueue]);
|
|
153
|
+
const pumpRef = useRef(() => undefined);
|
|
154
|
+
const schedulePump = useCallback(() => {
|
|
155
|
+
if (pumpQueued.current)
|
|
156
|
+
return;
|
|
157
|
+
pumpQueued.current = true;
|
|
158
|
+
queueMicrotask(() => pumpRef.current());
|
|
159
|
+
}, []);
|
|
160
|
+
/** The promotion pass. Runs in a microtask (see the file header). */
|
|
161
|
+
pumpRef.current = () => {
|
|
162
|
+
pumpQueued.current = false;
|
|
163
|
+
if (!mountedRef.current || !bridge)
|
|
164
|
+
return;
|
|
165
|
+
printBanner();
|
|
166
|
+
applyPendingReset();
|
|
167
|
+
const { entries: list, runActive: active } = latest.current;
|
|
168
|
+
const limit = Math.max(1, SCROLLBACK_LIMITS.feedCapacity - bridge.drain.pendingEntries() - 1);
|
|
169
|
+
const more = promote(list, settledCount(list, active), limit);
|
|
170
|
+
pruneEntryIds(printedIds.current, list);
|
|
171
|
+
if (more) {
|
|
172
|
+
// Continue once the writer has consumed what is queued (a restored transcript
|
|
173
|
+
// longer than the feed is promoted in waves rather than folded into a marker).
|
|
174
|
+
bridge.drain.onDrained = () => {
|
|
175
|
+
bridge.drain.onDrained = null;
|
|
176
|
+
schedulePump();
|
|
177
|
+
};
|
|
178
|
+
bridge.requestFlush();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
useLayoutEffect(() => {
|
|
182
|
+
if (enabled && bridge)
|
|
183
|
+
schedulePump();
|
|
184
|
+
}, [enabled, bridge, entries, runActive, schedulePump]);
|
|
185
|
+
const settleAll = useCallback(() => {
|
|
186
|
+
if (!bridge)
|
|
187
|
+
return;
|
|
188
|
+
const list = latest.current.entries;
|
|
189
|
+
const renderer = ensureRenderer();
|
|
190
|
+
printBanner();
|
|
191
|
+
// Shutdown must not leave anything behind, so no per-pass limit.
|
|
192
|
+
for (const entry of settleAllEntries(list)) {
|
|
193
|
+
if (printedIds.current.has(entry.id))
|
|
194
|
+
continue;
|
|
195
|
+
enqueue(renderer.renderEntry(entry, printableBefore(list, list.indexOf(entry)), latest.current.contentCols), entry.id, entry.kind);
|
|
196
|
+
printedIds.current.add(entry.id);
|
|
197
|
+
}
|
|
198
|
+
}, [bridge, ensureRenderer, printBanner, enqueue]);
|
|
199
|
+
// Shutdown hook for the exit paths. `restore()` runs it AFTER the tree is gone
|
|
200
|
+
// (normal exit) or while it is alive (signal), so it renders synchronously and then
|
|
201
|
+
// releases the offscreen instance. The unmount cleanup deliberately renders nothing:
|
|
202
|
+
// it runs inside the main tree's commit, where an offscreen render is deferred.
|
|
203
|
+
const settleAllRef = useRef(settleAll);
|
|
204
|
+
settleAllRef.current = settleAll;
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
mountedRef.current = true;
|
|
207
|
+
if (!enabled || !bridge)
|
|
208
|
+
return undefined;
|
|
209
|
+
bridge.settleAllNow = () => {
|
|
210
|
+
try {
|
|
211
|
+
settleAllRef.current();
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
rendererRef.current?.dispose();
|
|
215
|
+
rendererRef.current = null;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
return () => { mountedRef.current = false; };
|
|
219
|
+
}, [enabled, bridge]);
|
|
220
|
+
// --- Live region budget and streaming preview. ---------------------------
|
|
221
|
+
const boundary = useMemo(() => settledCount(entries, runActive), [entries, runActive]);
|
|
222
|
+
const unsettled = useMemo(() => entries.slice(boundary).filter((entry) => !isExcluded(entry)), [entries, boundary]);
|
|
223
|
+
const wanted = enabled
|
|
224
|
+
? buildLivePreview({
|
|
225
|
+
unsettled, cols: contentCols, rows: SCROLLBACK_LIMITS.maxLiveTailRows, thinkingVisible,
|
|
226
|
+
}).length
|
|
227
|
+
: 0;
|
|
228
|
+
// D11 · the preview height only grows while anything is unsettled, then resets.
|
|
229
|
+
heldTailRows.current = unsettled.length === 0 ? 0 : Math.max(heldTailRows.current, wanted);
|
|
230
|
+
const budget = useMemo(() => enabled
|
|
231
|
+
? buildScrollbackBudget({
|
|
232
|
+
rows, cols, draftRows, popupRows, statusExpanded, overlayOpen,
|
|
233
|
+
liveTailWanted: heldTailRows.current, forceInactive,
|
|
234
|
+
})
|
|
235
|
+
: null,
|
|
236
|
+
// `heldTailRows.current` is read at memo time; `wanted` is what moves it.
|
|
237
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
238
|
+
[enabled, rows, cols, draftRows, popupRows, statusExpanded, overlayOpen, wanted,
|
|
239
|
+
unsettled.length, forceInactive]);
|
|
240
|
+
const liveTailLines = useMemo(() => enabled && budget && budget.liveTailRows > 0
|
|
241
|
+
? buildLivePreview({ unsettled, cols: contentCols, rows: budget.liveTailRows, thinkingVisible })
|
|
242
|
+
: [], [enabled, budget, unsettled, contentCols, thinkingVisible]);
|
|
243
|
+
const reprintExpanded = useCallback((entryId) => {
|
|
244
|
+
if (!enabled || !bridge)
|
|
245
|
+
return false;
|
|
246
|
+
const list = latest.current.entries;
|
|
247
|
+
const index = list.findIndex((entry) => entry.id === entryId);
|
|
248
|
+
if (index < 0)
|
|
249
|
+
return false;
|
|
250
|
+
const entry = list[index];
|
|
251
|
+
const body = ensureRenderer().renderEntry(entry, undefined, latest.current.contentCols, true);
|
|
252
|
+
if (body.length === 0)
|
|
253
|
+
return false;
|
|
254
|
+
enqueue([`-- expanded: ${entry.kind === 'tool' ? entry.name : entry.kind} --`, ...body], `expanded:${entryId}:${Date.now()}`, entry.kind);
|
|
255
|
+
return true;
|
|
256
|
+
}, [enabled, bridge, ensureRenderer, enqueue]);
|
|
257
|
+
const notifyReset = useCallback((kind) => {
|
|
258
|
+
if (!enabled)
|
|
259
|
+
return;
|
|
260
|
+
pendingReset.current = { kind, captured: latest.current.entries };
|
|
261
|
+
}, [enabled]);
|
|
262
|
+
return { budget, liveTailLines, reprintExpanded, settleAll, notifyReset };
|
|
263
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte shapes of the native-scroll writer (tui-native-terminal-scroll §4.3 /
|
|
3
|
+
* Appendix A). Stateless string assembly only — the state machine that decides
|
|
4
|
+
* WHICH shape to emit lives in `scrollback-writer.ts`.
|
|
5
|
+
*
|
|
6
|
+
* THE WHITELIST (I-S5 / I-S6). Every byte produced here is drawn from: SGR, CUU,
|
|
7
|
+
* CUD, CR, CRLF, ED0, EL0, DEC 2026 and DECTCEM. There is no absolute CUP, no
|
|
8
|
+
* scroll region, no `CSI S/T`, no `ED2/ED3`. Relative addressing is what makes the
|
|
9
|
+
* geometry independent of the terminal scrolling underneath it (spec appendix B).
|
|
10
|
+
*
|
|
11
|
+
* Each row is `SGR0 · text · SGR0`: the leading reset stops one row's style leaking
|
|
12
|
+
* into the next, and the trailing one stops a background-colour-erase terminal from
|
|
13
|
+
* painting the following ED0/EL0 in the row's last colour.
|
|
14
|
+
*/
|
|
15
|
+
export declare const SGR0 = "\u001B[0m";
|
|
16
|
+
/** First write of a session: the cursor is already on a fresh line at column 0. */
|
|
17
|
+
export declare function seedBytes(input: {
|
|
18
|
+
lines: readonly string[];
|
|
19
|
+
statics: readonly string[];
|
|
20
|
+
sync: boolean;
|
|
21
|
+
/** After a foreign write the cursor may sit mid-line; start on a new one (RV-11). */
|
|
22
|
+
reseed: boolean;
|
|
23
|
+
}): string;
|
|
24
|
+
/** Erase exactly the previous live region, print static rows, redraw the region. */
|
|
25
|
+
export declare function fullBytes(input: {
|
|
26
|
+
lines: readonly string[];
|
|
27
|
+
statics: readonly string[];
|
|
28
|
+
/** Rows between the region top and the parked cursor. */
|
|
29
|
+
eraseUp: number;
|
|
30
|
+
sync: boolean;
|
|
31
|
+
}): string;
|
|
32
|
+
/** Same height, no static rows: rewrite only the rows that changed. `''` if none did. */
|
|
33
|
+
export declare function diffBytes(input: {
|
|
34
|
+
lines: readonly string[];
|
|
35
|
+
prev: readonly string[];
|
|
36
|
+
sync: boolean;
|
|
37
|
+
}): string;
|
|
38
|
+
/**
|
|
39
|
+
* Exit: erase the live region, flush leftover static rows, print the summary.
|
|
40
|
+
* `eraseUp < 0` means the geometry is unknown (a foreign write intervened), in
|
|
41
|
+
* which case the only safe move is to start on a fresh line and erase nothing.
|
|
42
|
+
*/
|
|
43
|
+
export declare function finalizeBytes(input: {
|
|
44
|
+
eraseUp: number;
|
|
45
|
+
statics: readonly string[];
|
|
46
|
+
summary: readonly string[];
|
|
47
|
+
}): string;
|
|
48
|
+
/**
|
|
49
|
+
* Cut a row to `maxCols` DISPLAY columns (I-S13), keeping SGR / OSC 8 intact.
|
|
50
|
+
*
|
|
51
|
+
* Returns the input unchanged (same reference) when it already fits, which is the
|
|
52
|
+
* overwhelmingly common case. Width is measured the way Ink measures it
|
|
53
|
+
* (`string-width` over the ANSI-stripped text): that is the number Ink used to
|
|
54
|
+
* wrap, so a disagreement with the terminal is the only way a row can exceed it.
|
|
55
|
+
*/
|
|
56
|
+
export declare function clipLine(line: string, maxCols: number): {
|
|
57
|
+
text: string;
|
|
58
|
+
clipped: boolean;
|
|
59
|
+
};
|
|
60
|
+
/** Rows the previous region occupies after the terminal re-flowed it to `cols` (§4.10). */
|
|
61
|
+
export declare function estimateReflowRows(lines: readonly string[], cols: number): number;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte shapes of the native-scroll writer (tui-native-terminal-scroll §4.3 /
|
|
3
|
+
* Appendix A). Stateless string assembly only — the state machine that decides
|
|
4
|
+
* WHICH shape to emit lives in `scrollback-writer.ts`.
|
|
5
|
+
*
|
|
6
|
+
* THE WHITELIST (I-S5 / I-S6). Every byte produced here is drawn from: SGR, CUU,
|
|
7
|
+
* CUD, CR, CRLF, ED0, EL0, DEC 2026 and DECTCEM. There is no absolute CUP, no
|
|
8
|
+
* scroll region, no `CSI S/T`, no `ED2/ED3`. Relative addressing is what makes the
|
|
9
|
+
* geometry independent of the terminal scrolling underneath it (spec appendix B).
|
|
10
|
+
*
|
|
11
|
+
* Each row is `SGR0 · text · SGR0`: the leading reset stops one row's style leaking
|
|
12
|
+
* into the next, and the trailing one stops a background-colour-erase terminal from
|
|
13
|
+
* painting the following ED0/EL0 in the row's last colour.
|
|
14
|
+
*/
|
|
15
|
+
import stringWidth from 'string-width';
|
|
16
|
+
import stripAnsi from 'strip-ansi';
|
|
17
|
+
const CSI = '\x1b[';
|
|
18
|
+
export const SGR0 = `${CSI}0m`;
|
|
19
|
+
const ED0 = `${CSI}J`;
|
|
20
|
+
const EL0 = `${CSI}K`;
|
|
21
|
+
const SYNC_BEGIN = `${CSI}?2026h`;
|
|
22
|
+
const SYNC_END = `${CSI}?2026l`;
|
|
23
|
+
const SHOW_CURSOR = `${CSI}?25h`;
|
|
24
|
+
const OSC8_CLOSE = '\x1b]8;;\x07';
|
|
25
|
+
const up = (n) => (n > 0 ? `${CSI}${n}A` : '');
|
|
26
|
+
const down = (n) => (n > 0 ? `${CSI}${n}B` : '');
|
|
27
|
+
const row = (text) => SGR0 + text + SGR0 + '\r\n';
|
|
28
|
+
const rows = (list) => list.map(row).join('');
|
|
29
|
+
const wrapSync = (payload, sync) => sync ? SYNC_BEGIN + payload + SYNC_END : payload;
|
|
30
|
+
/** First write of a session: the cursor is already on a fresh line at column 0. */
|
|
31
|
+
export function seedBytes(input) {
|
|
32
|
+
const lead = input.reseed ? '\r\n' : '';
|
|
33
|
+
return wrapSync(lead + '\r' + SGR0 + rows(input.statics) + rows(input.lines), input.sync);
|
|
34
|
+
}
|
|
35
|
+
/** Erase exactly the previous live region, print static rows, redraw the region. */
|
|
36
|
+
export function fullBytes(input) {
|
|
37
|
+
return wrapSync(up(input.eraseUp) + '\r' + SGR0 + ED0 + rows(input.statics) + rows(input.lines), input.sync);
|
|
38
|
+
}
|
|
39
|
+
/** Same height, no static rows: rewrite only the rows that changed. `''` if none did. */
|
|
40
|
+
export function diffBytes(input) {
|
|
41
|
+
const { lines, prev } = input;
|
|
42
|
+
const height = lines.length;
|
|
43
|
+
let out = '';
|
|
44
|
+
let cursor = -1;
|
|
45
|
+
for (let i = 0; i < height; i += 1) {
|
|
46
|
+
if (lines[i] === prev[i])
|
|
47
|
+
continue;
|
|
48
|
+
// Paint, THEN clear the tail: never ESC[2K first, which would blank the row
|
|
49
|
+
// the user is looking at for the instant between the two writes.
|
|
50
|
+
out += (cursor < 0 ? up(height - i) : down(i - cursor)) + '\r' + SGR0 + lines[i] + SGR0 + EL0;
|
|
51
|
+
cursor = i;
|
|
52
|
+
}
|
|
53
|
+
if (cursor < 0)
|
|
54
|
+
return '';
|
|
55
|
+
// Park on the row below the region, column 0 (I-S4).
|
|
56
|
+
return wrapSync(out + down(height - cursor) + '\r', input.sync);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Exit: erase the live region, flush leftover static rows, print the summary.
|
|
60
|
+
* `eraseUp < 0` means the geometry is unknown (a foreign write intervened), in
|
|
61
|
+
* which case the only safe move is to start on a fresh line and erase nothing.
|
|
62
|
+
*/
|
|
63
|
+
export function finalizeBytes(input) {
|
|
64
|
+
const head = input.eraseUp < 0 ? '\r\n' : up(input.eraseUp) + '\r' + SGR0 + ED0;
|
|
65
|
+
return head + rows(input.statics) + rows(input.summary) + SHOW_CURSOR;
|
|
66
|
+
}
|
|
67
|
+
const ESCAPE = /(\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\))/;
|
|
68
|
+
const segmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
|
69
|
+
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
70
|
+
: null;
|
|
71
|
+
function graphemes(text) {
|
|
72
|
+
return segmenter ? Array.from(segmenter.segment(text), (part) => part.segment) : Array.from(text);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Cut a row to `maxCols` DISPLAY columns (I-S13), keeping SGR / OSC 8 intact.
|
|
76
|
+
*
|
|
77
|
+
* Returns the input unchanged (same reference) when it already fits, which is the
|
|
78
|
+
* overwhelmingly common case. Width is measured the way Ink measures it
|
|
79
|
+
* (`string-width` over the ANSI-stripped text): that is the number Ink used to
|
|
80
|
+
* wrap, so a disagreement with the terminal is the only way a row can exceed it.
|
|
81
|
+
*/
|
|
82
|
+
export function clipLine(line, maxCols) {
|
|
83
|
+
if (maxCols < 1)
|
|
84
|
+
return { text: '', clipped: line.length > 0 };
|
|
85
|
+
if (stringWidth(stripAnsi(line)) <= maxCols)
|
|
86
|
+
return { text: line, clipped: false };
|
|
87
|
+
let out = '';
|
|
88
|
+
let width = 0;
|
|
89
|
+
let linkOpen = false;
|
|
90
|
+
for (const part of line.split(ESCAPE)) {
|
|
91
|
+
if (part.startsWith('\x1b')) {
|
|
92
|
+
out += part;
|
|
93
|
+
if (part.startsWith('\x1b]8;'))
|
|
94
|
+
linkOpen = !/^\x1b\]8;[^;]*;(?:\x07|\x1b\\)$/.test(part);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
for (const glyph of graphemes(part)) {
|
|
98
|
+
const glyphWidth = stringWidth(glyph);
|
|
99
|
+
if (width + glyphWidth > maxCols)
|
|
100
|
+
return { text: out + SGR0 + (linkOpen ? OSC8_CLOSE : ''), clipped: true };
|
|
101
|
+
out += glyph;
|
|
102
|
+
width += glyphWidth;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { text: out, clipped: false };
|
|
106
|
+
}
|
|
107
|
+
/** Rows the previous region occupies after the terminal re-flowed it to `cols` (§4.10). */
|
|
108
|
+
export function estimateReflowRows(lines, cols) {
|
|
109
|
+
const width = Math.max(1, cols);
|
|
110
|
+
let total = 0;
|
|
111
|
+
for (const line of lines)
|
|
112
|
+
total += Math.max(1, Math.ceil(stringWidth(stripAnsi(line)) / width));
|
|
113
|
+
return total;
|
|
114
|
+
}
|
|
@@ -97,7 +97,14 @@ export function wrapStdoutForFrames(stdout, differ) {
|
|
|
97
97
|
}
|
|
98
98
|
return writeReal(out, encoding, cb);
|
|
99
99
|
};
|
|
100
|
-
|
|
100
|
+
// Optional differ hooks keep the full-screen path byte-identical: a differ that
|
|
101
|
+
// does not implement them is invalidated exactly as before.
|
|
102
|
+
const onResize = () => {
|
|
103
|
+
if (differ.onResize)
|
|
104
|
+
differ.onResize();
|
|
105
|
+
else
|
|
106
|
+
differ.invalidate();
|
|
107
|
+
};
|
|
101
108
|
stdout.on('resize', onResize);
|
|
102
109
|
let disposed = false;
|
|
103
110
|
const proxy = new Proxy(stdout, {
|
|
@@ -130,7 +137,10 @@ export function wrapStdoutForFrames(stdout, differ) {
|
|
|
130
137
|
return true;
|
|
131
138
|
},
|
|
132
139
|
writeForeign(text) {
|
|
133
|
-
differ.
|
|
140
|
+
if (differ.cursorNeutralWrite)
|
|
141
|
+
differ.cursorNeutralWrite();
|
|
142
|
+
else
|
|
143
|
+
differ.invalidate();
|
|
134
144
|
if (disposed || text.length === 0)
|
|
135
145
|
return;
|
|
136
146
|
writeReal(text);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal plan — the ONE decision point for how the interactive TUI occupies the
|
|
3
|
+
* terminal (tui-native-terminal-scroll §4.1 / D10).
|
|
4
|
+
*
|
|
5
|
+
* `cli.tsx::runInteractive` used to derive `wantMouse`, `mouseOn`, `wantSelect`,
|
|
6
|
+
* `pasteOn` and `enhancedKeysOn` inline, each with its own comment about why the
|
|
7
|
+
* others must agree. Adding a second screen mode multiplies that surface, so the
|
|
8
|
+
* agreement is computed here, once, and the call site only READS the result.
|
|
9
|
+
*
|
|
10
|
+
* PURE: no I/O, no `process` access, so every row of the rules below is a table
|
|
11
|
+
* test with plain literals.
|
|
12
|
+
*
|
|
13
|
+
* I-S9 · `app` reproduces the pre-feature expressions term for term. The
|
|
14
|
+
* regression test diffs this function against a verbatim copy of those
|
|
15
|
+
* expressions, because "the same by inspection" is how mouse and paste drifted
|
|
16
|
+
* apart the last time.
|
|
17
|
+
*/
|
|
18
|
+
import type { ScrollMode } from '../config/schema.js';
|
|
19
|
+
export type { ScrollMode } from '../config/schema.js';
|
|
20
|
+
export interface TerminalPlanInput {
|
|
21
|
+
/** The resolved `scrollMode` config value. */
|
|
22
|
+
scrollMode: ScrollMode;
|
|
23
|
+
configMouse: boolean;
|
|
24
|
+
configMouseSelect: boolean;
|
|
25
|
+
configPaste: boolean;
|
|
26
|
+
configKeyboardEnhancement: boolean;
|
|
27
|
+
configDiffRender: boolean;
|
|
28
|
+
stdinIsTTY: boolean;
|
|
29
|
+
stdoutIsTTY: boolean;
|
|
30
|
+
platform: NodeJS.Platform;
|
|
31
|
+
/** Measured on win32 (`forceVtInputForThisConsole`); irrelevant elsewhere. */
|
|
32
|
+
vtInputSupported: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface TerminalPlan {
|
|
35
|
+
scrollMode: ScrollMode;
|
|
36
|
+
/** `app`: enter the alternate screen. `native`: never. */
|
|
37
|
+
altScreen: boolean;
|
|
38
|
+
/** Whether the terminal is asked for SGR mouse reports (and a mouse filter is wanted). */
|
|
39
|
+
mouseReporting: boolean;
|
|
40
|
+
/** Application-drawn drag selection. Always `false` in native mode (the terminal selects). */
|
|
41
|
+
dragSelect: boolean;
|
|
42
|
+
bracketedPaste: boolean;
|
|
43
|
+
enhancedKeys: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Which frame writer fronts Ink's stdout. `native` ALWAYS needs the scrollback
|
|
46
|
+
* writer, even with `diffRender` off: static rows are injected through it.
|
|
47
|
+
*/
|
|
48
|
+
differ: 'fullscreen' | 'scrollback' | 'none';
|
|
49
|
+
/** Native + `diffRender:false` — every frame rewrites the whole live region. */
|
|
50
|
+
fullEveryFrame: boolean;
|
|
51
|
+
}
|
|
52
|
+
export declare function resolveTerminalPlan(input: TerminalPlanInput): TerminalPlan;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal plan — the ONE decision point for how the interactive TUI occupies the
|
|
3
|
+
* terminal (tui-native-terminal-scroll §4.1 / D10).
|
|
4
|
+
*
|
|
5
|
+
* `cli.tsx::runInteractive` used to derive `wantMouse`, `mouseOn`, `wantSelect`,
|
|
6
|
+
* `pasteOn` and `enhancedKeysOn` inline, each with its own comment about why the
|
|
7
|
+
* others must agree. Adding a second screen mode multiplies that surface, so the
|
|
8
|
+
* agreement is computed here, once, and the call site only READS the result.
|
|
9
|
+
*
|
|
10
|
+
* PURE: no I/O, no `process` access, so every row of the rules below is a table
|
|
11
|
+
* test with plain literals.
|
|
12
|
+
*
|
|
13
|
+
* I-S9 · `app` reproduces the pre-feature expressions term for term. The
|
|
14
|
+
* regression test diffs this function against a verbatim copy of those
|
|
15
|
+
* expressions, because "the same by inspection" is how mouse and paste drifted
|
|
16
|
+
* apart the last time.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveTerminalPlan(input) {
|
|
19
|
+
const bothTty = input.stdinIsTTY && input.stdoutIsTTY;
|
|
20
|
+
// Paste and keyboard enhancement ride the stdin filter and say nothing about
|
|
21
|
+
// scrolling, so the two modes agree on them (rule 3).
|
|
22
|
+
const bracketedPaste = input.configPaste && input.stdinIsTTY;
|
|
23
|
+
const enhancedKeys = input.configKeyboardEnhancement &&
|
|
24
|
+
input.stdinIsTTY &&
|
|
25
|
+
(input.platform !== 'win32' || input.vtInputSupported);
|
|
26
|
+
if (input.scrollMode === 'native') {
|
|
27
|
+
return {
|
|
28
|
+
scrollMode: 'native',
|
|
29
|
+
altScreen: false,
|
|
30
|
+
mouseReporting: false,
|
|
31
|
+
dragSelect: false,
|
|
32
|
+
bracketedPaste,
|
|
33
|
+
enhancedKeys,
|
|
34
|
+
differ: 'scrollback',
|
|
35
|
+
fullEveryFrame: !input.configDiffRender,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const mouseReporting = input.configMouse && bothTty && input.vtInputSupported;
|
|
39
|
+
return {
|
|
40
|
+
scrollMode: 'app',
|
|
41
|
+
altScreen: true,
|
|
42
|
+
mouseReporting,
|
|
43
|
+
dragSelect: mouseReporting && input.configMouseSelect,
|
|
44
|
+
bracketedPaste,
|
|
45
|
+
enhancedKeys,
|
|
46
|
+
differ: input.configDiffRender ? 'fullscreen' : 'none',
|
|
47
|
+
fullEveryFrame: false,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -26,5 +26,16 @@ export interface TranscriptTextOptions {
|
|
|
26
26
|
*/
|
|
27
27
|
droppedEntries?: number;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* The three figures every session summary carries: turns, tokens, cost.
|
|
31
|
+
*
|
|
32
|
+
* One implementation for the exit replay and the native-scroll exit summary
|
|
33
|
+
* (tui-native-terminal-scroll §4.8), so the two can never disagree.
|
|
34
|
+
*/
|
|
35
|
+
export declare function formatSessionStats(entries: readonly Entry[], usageTotal: UsageTotal, g: Pick<Glyphs, 'arrowUp' | 'arrowDown'>): {
|
|
36
|
+
turns: string;
|
|
37
|
+
tokens: string;
|
|
38
|
+
cost: string;
|
|
39
|
+
};
|
|
29
40
|
/** Render the whole session (summary line + entries) as plain text. */
|
|
30
41
|
export declare function renderTranscriptText(entries: Entry[], opts: TranscriptTextOptions): string;
|
|
@@ -198,21 +198,35 @@ function renderEntry(entry, glyphs) {
|
|
|
198
198
|
return [];
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
|
-
/**
|
|
202
|
-
|
|
201
|
+
/**
|
|
202
|
+
* The three figures every session summary carries: turns, tokens, cost.
|
|
203
|
+
*
|
|
204
|
+
* One implementation for the exit replay and the native-scroll exit summary
|
|
205
|
+
* (tui-native-terminal-scroll §4.8), so the two can never disagree.
|
|
206
|
+
*/
|
|
207
|
+
export function formatSessionStats(entries, usageTotal, g) {
|
|
203
208
|
const turns = entries.filter((e) => e.kind === 'user').length;
|
|
204
|
-
const g = opts.glyphs;
|
|
205
209
|
// THE SAME PROMPT-SIDE TOTAL THE STATUS BAR SHOWS (context-usage-gauge-accuracy
|
|
206
210
|
// §3.6). This line is what a user compares against the bar they were watching a
|
|
207
211
|
// second earlier, so a different unit here would look like the exit summary
|
|
208
212
|
// losing tokens.
|
|
209
|
-
const tokens = `${formatTokens(promptTokensOf(
|
|
213
|
+
const tokens = `${formatTokens(promptTokensOf(usageTotal))}${g.arrowUp} ${formatTokens(usageTotal.outputTokens)}${g.arrowDown}`;
|
|
214
|
+
return {
|
|
215
|
+
turns: `${turns} ${turns === 1 ? 'turn' : 'turns'}`,
|
|
216
|
+
tokens,
|
|
217
|
+
cost: formatCost(usageTotal.costUsd),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/** Render the whole session (summary line + entries) as plain text. */
|
|
221
|
+
export function renderTranscriptText(entries, opts) {
|
|
222
|
+
const g = opts.glyphs;
|
|
223
|
+
const stats = formatSessionStats(entries, opts.usageTotal, g);
|
|
210
224
|
const summary = [
|
|
211
225
|
'aragon',
|
|
212
226
|
`${opts.provider}:${opts.model}`,
|
|
213
|
-
|
|
214
|
-
tokens,
|
|
215
|
-
|
|
227
|
+
stats.turns,
|
|
228
|
+
stats.tokens,
|
|
229
|
+
stats.cost,
|
|
216
230
|
formatDuration(Math.max(0, opts.elapsedMs)),
|
|
217
231
|
].join(` ${g.midDot} `);
|
|
218
232
|
const max = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
@@ -53,6 +53,17 @@ export interface RetainNoticeOptions {
|
|
|
53
53
|
requested: number;
|
|
54
54
|
resolved: number;
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* The revision of the native-scroll notice a user must have seen for it to stay
|
|
58
|
+
* quiet (`state.json::scrollModeNoticeVersion`). BUMP IT WHENEVER THE ADVICE
|
|
59
|
+
* CHANGES, never for a rewording (tui-native-terminal-scroll D12).
|
|
60
|
+
*/
|
|
61
|
+
export declare const SCROLL_MODE_NOTICE_VERSION = 1;
|
|
62
|
+
export declare function scrollModeNoticeText(): string;
|
|
63
|
+
export interface ScrollModeNoticeOptions {
|
|
64
|
+
/** Record that `SCROLL_MODE_NOTICE_VERSION` was shown (`state.json`). */
|
|
65
|
+
onSeen: () => void;
|
|
66
|
+
}
|
|
56
67
|
export interface MouseNoticeOptions {
|
|
57
68
|
/**
|
|
58
69
|
* Whether reporting is ACTUALLY in effect: a filter was installed AND the
|
|
@@ -98,4 +109,4 @@ export interface VtInputNoticeOptions {
|
|
|
98
109
|
*/
|
|
99
110
|
onSeen: () => void;
|
|
100
111
|
}
|
|
101
|
-
export declare function useStartupNotices(dispatch: (action: ViewAction) => void, mouseNotice?: MouseNoticeOptions, retainNotice?: RetainNoticeOptions, vtInputNotice?: VtInputNoticeOptions): void;
|
|
112
|
+
export declare function useStartupNotices(dispatch: (action: ViewAction) => void, mouseNotice?: MouseNoticeOptions, retainNotice?: RetainNoticeOptions, vtInputNotice?: VtInputNoticeOptions, scrollModeNotice?: ScrollModeNoticeOptions): void;
|