@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,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static-row feed — the queue between the promotion pipeline (which decides an
|
|
3
|
+
* entry is final) and the scrollback writer (which prints it)
|
|
4
|
+
* (tui-native-terminal-scroll §4.3 "feed").
|
|
5
|
+
*
|
|
6
|
+
* FIFO, bounded. When the writer falls behind and the queue overflows, the OLDEST
|
|
7
|
+
* pending entries are folded into one ASCII marker and counted: a visible, counted
|
|
8
|
+
* omission, never a silent one (I-S2). Entries are taken as whole blocks and never
|
|
9
|
+
* split, so a batch boundary can never land inside one entry's rows.
|
|
10
|
+
*/
|
|
11
|
+
import { SCROLLBACK_LIMITS } from './limits.js';
|
|
12
|
+
export function overflowMarker(count) {
|
|
13
|
+
return `[${count} earlier ${count === 1 ? 'entry' : 'entries'} not printed - scrollback feed overflow]`;
|
|
14
|
+
}
|
|
15
|
+
export function createScrollbackDrain(options = {}) {
|
|
16
|
+
const capacity = Math.max(1, options.capacity ?? SCROLLBACK_LIMITS.feedCapacity);
|
|
17
|
+
const queue = [];
|
|
18
|
+
/** Entries folded since the marker was last printed. */
|
|
19
|
+
let foldedPending = 0;
|
|
20
|
+
let foldedTotal = 0;
|
|
21
|
+
const markerRows = () => (foldedPending > 0 ? 1 : 0);
|
|
22
|
+
const takeWhile = (fits) => {
|
|
23
|
+
const out = [];
|
|
24
|
+
if (foldedPending > 0) {
|
|
25
|
+
out.push(overflowMarker(foldedPending));
|
|
26
|
+
foldedPending = 0;
|
|
27
|
+
}
|
|
28
|
+
while (queue.length > 0) {
|
|
29
|
+
const next = queue[0];
|
|
30
|
+
// Always take the first entry, however tall: a block taller than the cap
|
|
31
|
+
// still has to print, and the writer splits at ENTRY boundaries only.
|
|
32
|
+
if (out.length > 0 && !fits(out.length, next.rows.length))
|
|
33
|
+
break;
|
|
34
|
+
queue.shift();
|
|
35
|
+
for (const row of next.rows)
|
|
36
|
+
out.push(row);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
};
|
|
40
|
+
const drained = (taken) => {
|
|
41
|
+
if (taken.length > 0)
|
|
42
|
+
drain.onDrained?.();
|
|
43
|
+
return taken;
|
|
44
|
+
};
|
|
45
|
+
const drain = {
|
|
46
|
+
onEnqueue: null,
|
|
47
|
+
onDrained: null,
|
|
48
|
+
enqueue(rows, meta) {
|
|
49
|
+
queue.push({ id: meta.id, kind: meta.kind, rows });
|
|
50
|
+
while (queue.length > capacity) {
|
|
51
|
+
queue.shift();
|
|
52
|
+
foldedPending += 1;
|
|
53
|
+
foldedTotal += 1;
|
|
54
|
+
}
|
|
55
|
+
drain.onEnqueue?.();
|
|
56
|
+
},
|
|
57
|
+
takeAll: () => drained(takeWhile(() => true)),
|
|
58
|
+
takeUpTo: (maxRows) => drained(takeWhile((taken, next) => taken + next <= maxRows)),
|
|
59
|
+
pending: () => queue.reduce((sum, entry) => sum + entry.rows.length, markerRows()),
|
|
60
|
+
pendingEntries: () => queue.length,
|
|
61
|
+
elided: () => foldedTotal,
|
|
62
|
+
};
|
|
63
|
+
return drain;
|
|
64
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit summary for native-scroll mode (tui-native-terminal-scroll §4.8 / D9).
|
|
3
|
+
*
|
|
4
|
+
* The conversation is already in the terminal's scrollback, so exit does NOT replay
|
|
5
|
+
* it (that would print every message twice). It prints at most four short lines and
|
|
6
|
+
* leaves the cursor on a fresh line.
|
|
7
|
+
*
|
|
8
|
+
* PURE and ASCII-safe: glyphs come from `pickGlyphs`, and the figures come from the
|
|
9
|
+
* same `formatSessionStats` the full-screen replay uses.
|
|
10
|
+
*/
|
|
11
|
+
import type { Entry, UsageTotal } from '../../agent/reducer.js';
|
|
12
|
+
import type { Glyphs } from '../glyphs.js';
|
|
13
|
+
export interface ExitSummaryInput {
|
|
14
|
+
entries: readonly Entry[];
|
|
15
|
+
usageTotal: UsageTotal;
|
|
16
|
+
provider: string;
|
|
17
|
+
model: string;
|
|
18
|
+
elapsedMs: number;
|
|
19
|
+
/** The session id, when this process has one worth resuming. */
|
|
20
|
+
sessionId?: string;
|
|
21
|
+
glyphs: Pick<Glyphs, 'midDot' | 'arrowUp' | 'arrowDown'>;
|
|
22
|
+
}
|
|
23
|
+
export declare function buildExitSummary(input: ExitSummaryInput): string[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit summary for native-scroll mode (tui-native-terminal-scroll §4.8 / D9).
|
|
3
|
+
*
|
|
4
|
+
* The conversation is already in the terminal's scrollback, so exit does NOT replay
|
|
5
|
+
* it (that would print every message twice). It prints at most four short lines and
|
|
6
|
+
* leaves the cursor on a fresh line.
|
|
7
|
+
*
|
|
8
|
+
* PURE and ASCII-safe: glyphs come from `pickGlyphs`, and the figures come from the
|
|
9
|
+
* same `formatSessionStats` the full-screen replay uses.
|
|
10
|
+
*/
|
|
11
|
+
import { formatDuration } from '../../agent/usage.js';
|
|
12
|
+
import { formatSessionStats } from '../transcript-text.js';
|
|
13
|
+
import { SCROLLBACK_LIMITS } from './limits.js';
|
|
14
|
+
export function buildExitSummary(input) {
|
|
15
|
+
const { glyphs: g } = input;
|
|
16
|
+
const stats = formatSessionStats(input.entries, input.usageTotal, g);
|
|
17
|
+
const rows = [
|
|
18
|
+
['Session ended', `${input.provider}:${input.model}`, formatDuration(Math.max(0, input.elapsedMs))]
|
|
19
|
+
.join(` ${g.midDot} `),
|
|
20
|
+
[stats.turns, stats.tokens, stats.cost].join(` ${g.midDot} `),
|
|
21
|
+
];
|
|
22
|
+
if (input.sessionId)
|
|
23
|
+
rows.push(`Resume: aragon --resume ${input.sessionId}`);
|
|
24
|
+
return rows.slice(0, SCROLLBACK_LIMITS.exitSummaryMaxRows);
|
|
25
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural limits of native-scroll mode (tui-native-terminal-scroll §4.3-§4.6).
|
|
3
|
+
*
|
|
4
|
+
* Structural caps live here; tunable policy lives in `config/schema.ts`. None of
|
|
5
|
+
* these is a user preference: each one bounds a failure (a runaway write, a live
|
|
6
|
+
* region that eats the screen) rather than expressing a taste.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SCROLLBACK_LIMITS: {
|
|
9
|
+
/** Pending static entries the drain holds before folding the oldest into a marker. */
|
|
10
|
+
readonly feedCapacity: 32;
|
|
11
|
+
/** Rows one entry may print; the rest is replaced by one ASCII notice row. */
|
|
12
|
+
readonly maxRowsPerEntry: 400;
|
|
13
|
+
/** Static rows per `stdout.write`; larger feeds are split into consecutive batches. */
|
|
14
|
+
readonly maxRowsPerBatch: 2000;
|
|
15
|
+
/** tall-frame swallows within this window after a resize are expected and uncounted. */
|
|
16
|
+
readonly guardResizeGraceMs: 250;
|
|
17
|
+
/** Consecutive out-of-resize tall frames that degrade the app to the placeholder. */
|
|
18
|
+
readonly guardTripCount: 3;
|
|
19
|
+
/** Session summary rows printed on exit. */
|
|
20
|
+
readonly exitSummaryMaxRows: 4;
|
|
21
|
+
/** Streaming preview rows, absolute cap. */
|
|
22
|
+
readonly maxLiveTailRows: 12;
|
|
23
|
+
/** ... and as a share of the terminal height. */
|
|
24
|
+
readonly liveTailRowShare: 0.4;
|
|
25
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural limits of native-scroll mode (tui-native-terminal-scroll §4.3-§4.6).
|
|
3
|
+
*
|
|
4
|
+
* Structural caps live here; tunable policy lives in `config/schema.ts`. None of
|
|
5
|
+
* these is a user preference: each one bounds a failure (a runaway write, a live
|
|
6
|
+
* region that eats the screen) rather than expressing a taste.
|
|
7
|
+
*/
|
|
8
|
+
export const SCROLLBACK_LIMITS = {
|
|
9
|
+
/** Pending static entries the drain holds before folding the oldest into a marker. */
|
|
10
|
+
feedCapacity: 32,
|
|
11
|
+
/** Rows one entry may print; the rest is replaced by one ASCII notice row. */
|
|
12
|
+
maxRowsPerEntry: 400,
|
|
13
|
+
/** Static rows per `stdout.write`; larger feeds are split into consecutive batches. */
|
|
14
|
+
maxRowsPerBatch: 2000,
|
|
15
|
+
/** tall-frame swallows within this window after a resize are expected and uncounted. */
|
|
16
|
+
guardResizeGraceMs: 250,
|
|
17
|
+
/** Consecutive out-of-resize tall frames that degrade the app to the placeholder. */
|
|
18
|
+
guardTripCount: 3,
|
|
19
|
+
/** Session summary rows printed on exit. */
|
|
20
|
+
exitSummaryMaxRows: 4,
|
|
21
|
+
/** Streaming preview rows, absolute cap. */
|
|
22
|
+
maxLiveTailRows: 12,
|
|
23
|
+
/** ... and as a share of the terminal height. */
|
|
24
|
+
liveTailRowShare: 0.4,
|
|
25
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming preview rows for the native-scroll live region
|
|
3
|
+
* (tui-native-terminal-scroll §4.6).
|
|
4
|
+
*
|
|
5
|
+
* Entries that are not yet final cannot be printed, but the user must still see the
|
|
6
|
+
* answer arrive. This builds a few PLAIN-TEXT rows from the unsettled tail of the
|
|
7
|
+
* transcript; the shell colours them uniformly (`theme.muted`). The preview is a
|
|
8
|
+
* visual buffer only: when an entry settles it is printed in its real, formatted
|
|
9
|
+
* shape and the preview rows disappear (the "settle jump", documented in the README).
|
|
10
|
+
*
|
|
11
|
+
* PURE: no Ink, no theme, no I/O. Every returned row fits `cols` display columns.
|
|
12
|
+
*/
|
|
13
|
+
import type { Entry } from '../../agent/reducer.js';
|
|
14
|
+
export interface LivePreviewInput {
|
|
15
|
+
/** `entries.slice(settledCount)` — may include excluded entries; they are skipped. */
|
|
16
|
+
unsettled: readonly Entry[];
|
|
17
|
+
/** Content width (`cols - 1`). */
|
|
18
|
+
cols: number;
|
|
19
|
+
/** Rows granted by the budget. */
|
|
20
|
+
rows: number;
|
|
21
|
+
thinkingVisible: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** The last `rows` preview rows, oldest first. `[]` when `rows <= 0`. */
|
|
24
|
+
export declare function buildLivePreview(input: LivePreviewInput): string[];
|
|
25
|
+
/** How many preview rows the unsettled entries would like, before budget clamping. */
|
|
26
|
+
export declare function wantedPreviewRows(input: Omit<LivePreviewInput, 'rows'>, cap: number): number;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming preview rows for the native-scroll live region
|
|
3
|
+
* (tui-native-terminal-scroll §4.6).
|
|
4
|
+
*
|
|
5
|
+
* Entries that are not yet final cannot be printed, but the user must still see the
|
|
6
|
+
* answer arrive. This builds a few PLAIN-TEXT rows from the unsettled tail of the
|
|
7
|
+
* transcript; the shell colours them uniformly (`theme.muted`). The preview is a
|
|
8
|
+
* visual buffer only: when an entry settles it is printed in its real, formatted
|
|
9
|
+
* shape and the preview rows disappear (the "settle jump", documented in the README).
|
|
10
|
+
*
|
|
11
|
+
* PURE: no Ink, no theme, no I/O. Every returned row fits `cols` display columns.
|
|
12
|
+
*/
|
|
13
|
+
import { clampLiveText } from '../live-clamp.js';
|
|
14
|
+
import { wrapToRows } from '../layout/wrap-rows.js';
|
|
15
|
+
import { isExcluded } from './settled-boundary.js';
|
|
16
|
+
import { clipLine } from './writer-shapes.js';
|
|
17
|
+
const MAX_TEAM_RUN_ROWS = 3;
|
|
18
|
+
const TOOL_LIVE_ROWS = 6;
|
|
19
|
+
function firstLine(text) {
|
|
20
|
+
const line = text.split('\n').find((candidate) => candidate.trim().length > 0);
|
|
21
|
+
return (line ?? '').trim();
|
|
22
|
+
}
|
|
23
|
+
function fit(text, cols) {
|
|
24
|
+
return clipLine(text, Math.max(1, cols)).text;
|
|
25
|
+
}
|
|
26
|
+
function assistantRows(entry, cols, rows, thinkingVisible) {
|
|
27
|
+
const out = [];
|
|
28
|
+
const thinking = (entry.thinking ?? '').trim();
|
|
29
|
+
if (entry.text.trim().length === 0) {
|
|
30
|
+
if (thinking.length > 0 && thinkingVisible) {
|
|
31
|
+
const last = thinking.split('\n').filter((line) => line.trim().length > 0).pop() ?? '';
|
|
32
|
+
out.push(fit(`thinking: ${last.trim()}`, cols));
|
|
33
|
+
}
|
|
34
|
+
else if (entry.streaming) {
|
|
35
|
+
out.push('thinking...');
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
const clamped = clampLiveText(entry.text, rows * 2);
|
|
40
|
+
out.push(...wrapToRows(clamped.text, cols).map((row) => fit(row, cols)));
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
function toolRows(entry, cols) {
|
|
44
|
+
const head = entry.label && entry.label.length > 0 ? entry.label : entry.name;
|
|
45
|
+
const out = [fit(`${entry.status === 'running' ? '>' : '.'} ${head}`, cols)];
|
|
46
|
+
const live = (entry.live ?? []).slice(-TOOL_LIVE_ROWS);
|
|
47
|
+
for (const row of live)
|
|
48
|
+
out.push(fit(` ${row}`, cols));
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
function teamRows(entry, cols) {
|
|
52
|
+
const running = entry.runs.filter((run) => !['queued', 'done', 'failed', 'aborted'].includes(run.phase)).length;
|
|
53
|
+
const out = [fit(`Team: ${running}/${entry.runs.length} running`, cols)];
|
|
54
|
+
for (const run of entry.runs.slice(0, MAX_TEAM_RUN_ROWS)) {
|
|
55
|
+
out.push(fit(` ${run.label} ${run.phase}`, cols));
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
function entryRows(entry, input) {
|
|
60
|
+
const { cols, rows, thinkingVisible } = input;
|
|
61
|
+
switch (entry.kind) {
|
|
62
|
+
case 'assistant':
|
|
63
|
+
return assistantRows(entry, cols, rows, thinkingVisible);
|
|
64
|
+
case 'tool':
|
|
65
|
+
return toolRows(entry, cols);
|
|
66
|
+
case 'team':
|
|
67
|
+
return teamRows(entry, cols);
|
|
68
|
+
case 'todo':
|
|
69
|
+
return [fit(`Todo: ${entry.doneCount}/${entry.total}`, cols)];
|
|
70
|
+
case 'compaction':
|
|
71
|
+
return [fit(`Compacting context...`, cols)];
|
|
72
|
+
case 'retry':
|
|
73
|
+
return [fit(`Retrying (${entry.attempt}/${entry.maxRetries})...`, cols)];
|
|
74
|
+
case 'fast':
|
|
75
|
+
return [fit(`Fast review ${entry.reviewIndex}...`, cols)];
|
|
76
|
+
case 'queued':
|
|
77
|
+
return [fit(`Queued: ${firstLine(entry.text)}`, cols)];
|
|
78
|
+
case 'user':
|
|
79
|
+
return [fit(`> ${firstLine(entry.text)}`, cols)];
|
|
80
|
+
case 'notice':
|
|
81
|
+
return [fit(firstLine(entry.text), cols)];
|
|
82
|
+
case 'service':
|
|
83
|
+
return [];
|
|
84
|
+
default: {
|
|
85
|
+
const exhaustive = entry;
|
|
86
|
+
return exhaustive;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** The last `rows` preview rows, oldest first. `[]` when `rows <= 0`. */
|
|
91
|
+
export function buildLivePreview(input) {
|
|
92
|
+
const rows = Math.floor(input.rows);
|
|
93
|
+
if (rows <= 0)
|
|
94
|
+
return [];
|
|
95
|
+
const all = [];
|
|
96
|
+
for (const entry of input.unsettled) {
|
|
97
|
+
if (isExcluded(entry))
|
|
98
|
+
continue;
|
|
99
|
+
all.push(...entryRows(entry, input));
|
|
100
|
+
}
|
|
101
|
+
return all.slice(-rows);
|
|
102
|
+
}
|
|
103
|
+
/** How many preview rows the unsettled entries would like, before budget clamping. */
|
|
104
|
+
export function wantedPreviewRows(input, cap) {
|
|
105
|
+
return buildLivePreview({ ...input, rows: cap }).length;
|
|
106
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offscreen entry renderer (tui-native-terminal-scroll §4.5 / D4).
|
|
3
|
+
*
|
|
4
|
+
* Settled entries are rendered ONCE, to ANSI text, by a resident Ink instance that
|
|
5
|
+
* writes to a fake stdout (`debug: true`: Ink then emits the complete layout in one
|
|
6
|
+
* write per render and never touches log-update). The resulting rows go to the feed
|
|
7
|
+
* and are printed; they are never mounted in the live React tree, so the per-frame
|
|
8
|
+
* render cost stays proportional to what is on screen, not to the session length.
|
|
9
|
+
*
|
|
10
|
+
* Failure isolation (I-S12): one entry that throws while rendering degrades to a
|
|
11
|
+
* single placeholder row. It must never block the queue or take the session down.
|
|
12
|
+
*/
|
|
13
|
+
import React from 'react';
|
|
14
|
+
import type { Entry } from '../../agent/reducer.js';
|
|
15
|
+
import type { TermCapabilities } from '../capabilities.js';
|
|
16
|
+
import type { DensityMode } from '../density.js';
|
|
17
|
+
import type { Theme } from '../theme.js';
|
|
18
|
+
export interface EntryRenderOptions {
|
|
19
|
+
theme: Theme;
|
|
20
|
+
caps: TermCapabilities;
|
|
21
|
+
density: DensityMode;
|
|
22
|
+
thinkingVisible: boolean;
|
|
23
|
+
reducedMotion: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface EntryRenderer {
|
|
26
|
+
/** Rows for one entry at `cols` columns; `[]` when it renders as nothing. */
|
|
27
|
+
renderEntry(entry: Entry, prev: Entry | undefined, cols: number, expanded?: boolean): string[];
|
|
28
|
+
/** Rows for an arbitrary node (the startup header and opener). */
|
|
29
|
+
renderNode(node: React.ReactNode, cols: number): string[];
|
|
30
|
+
/** Options for LATER renders; already printed rows never change. */
|
|
31
|
+
setOptions(partial: Partial<EntryRenderOptions>): void;
|
|
32
|
+
stats(): {
|
|
33
|
+
elidedRows: number;
|
|
34
|
+
failures: number;
|
|
35
|
+
};
|
|
36
|
+
dispose(): void;
|
|
37
|
+
}
|
|
38
|
+
export declare function createEntryRenderer(initial: EntryRenderOptions): EntryRenderer;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Offscreen entry renderer (tui-native-terminal-scroll §4.5 / D4).
|
|
4
|
+
*
|
|
5
|
+
* Settled entries are rendered ONCE, to ANSI text, by a resident Ink instance that
|
|
6
|
+
* writes to a fake stdout (`debug: true`: Ink then emits the complete layout in one
|
|
7
|
+
* write per render and never touches log-update). The resulting rows go to the feed
|
|
8
|
+
* and are printed; they are never mounted in the live React tree, so the per-frame
|
|
9
|
+
* render cost stays proportional to what is on screen, not to the session length.
|
|
10
|
+
*
|
|
11
|
+
* Failure isolation (I-S12): one entry that throws while rendering degrades to a
|
|
12
|
+
* single placeholder row. It must never block the queue or take the session down.
|
|
13
|
+
*/
|
|
14
|
+
import React from 'react';
|
|
15
|
+
import { EventEmitter } from 'node:events';
|
|
16
|
+
import { Box, render } from 'ink';
|
|
17
|
+
import { EntryView } from '../Transcript.js';
|
|
18
|
+
import { getLogger } from '../../logging/logger.js';
|
|
19
|
+
import { SCROLLBACK_LIMITS } from './limits.js';
|
|
20
|
+
/**
|
|
21
|
+
* Catches a throwing entry INSIDE the offscreen tree.
|
|
22
|
+
*
|
|
23
|
+
* Without it Ink's own boundary would print an error overview into the frame (which
|
|
24
|
+
* would then be printed into the user's scrollback, forever) and unmount the
|
|
25
|
+
* instance, so every later entry would fail too. With it the failure is a flag the
|
|
26
|
+
* caller reads synchronously and the instance stays healthy (I-S12).
|
|
27
|
+
*/
|
|
28
|
+
class RenderGuard extends React.Component {
|
|
29
|
+
state = { failed: false };
|
|
30
|
+
static getDerivedStateFromError() {
|
|
31
|
+
return { failed: true };
|
|
32
|
+
}
|
|
33
|
+
componentDidCatch(error) {
|
|
34
|
+
this.props.failure.error = error;
|
|
35
|
+
}
|
|
36
|
+
render() {
|
|
37
|
+
return this.state.failed ? null : this.props.children;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const NO_FAILURE = Symbol('no-failure');
|
|
41
|
+
function createOffscreen(cols) {
|
|
42
|
+
const last = { value: null };
|
|
43
|
+
const stdout = Object.assign(new EventEmitter(), {
|
|
44
|
+
columns: cols,
|
|
45
|
+
rows: 1000,
|
|
46
|
+
isTTY: false,
|
|
47
|
+
write(chunk) {
|
|
48
|
+
last.value = chunk;
|
|
49
|
+
return true;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const stdin = Object.assign(new EventEmitter(), {
|
|
53
|
+
isTTY: false,
|
|
54
|
+
setEncoding() { },
|
|
55
|
+
resume() { },
|
|
56
|
+
pause() { },
|
|
57
|
+
ref() { },
|
|
58
|
+
unref() { },
|
|
59
|
+
});
|
|
60
|
+
const instance = render(null, {
|
|
61
|
+
stdout, stdin, stderr: stdout, debug: true, exitOnCtrlC: false, patchConsole: false,
|
|
62
|
+
});
|
|
63
|
+
// A rejected exit promise nobody awaits is an unhandled rejection.
|
|
64
|
+
void instance.waitUntilExit().catch(() => undefined);
|
|
65
|
+
return { cols, stdout, stdin, instance, last, failure: { error: NO_FAILURE } };
|
|
66
|
+
}
|
|
67
|
+
/** A function, so the compiler does not narrow the mutable `last` box to `null`. */
|
|
68
|
+
function readFrame(target) {
|
|
69
|
+
return target.last.value;
|
|
70
|
+
}
|
|
71
|
+
function trimTrailingBlank(rows) {
|
|
72
|
+
let end = rows.length;
|
|
73
|
+
while (end > 0 && rows[end - 1].trim().length === 0)
|
|
74
|
+
end -= 1;
|
|
75
|
+
return end === rows.length ? rows : rows.slice(0, end);
|
|
76
|
+
}
|
|
77
|
+
export function createEntryRenderer(initial) {
|
|
78
|
+
let options = initial;
|
|
79
|
+
let offscreen = null;
|
|
80
|
+
let elidedRows = 0;
|
|
81
|
+
let failures = 0;
|
|
82
|
+
let guardKey = 0;
|
|
83
|
+
const dispose = () => {
|
|
84
|
+
if (!offscreen)
|
|
85
|
+
return;
|
|
86
|
+
const current = offscreen;
|
|
87
|
+
offscreen = null;
|
|
88
|
+
try {
|
|
89
|
+
current.instance.unmount();
|
|
90
|
+
current.instance.cleanup();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Tearing down a fake stream must never throw into an exit path.
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
/** Ink reads `columns` once, at creation, so a width change rebuilds the instance. */
|
|
97
|
+
const ensure = (cols) => {
|
|
98
|
+
if (offscreen && offscreen.cols === cols)
|
|
99
|
+
return offscreen;
|
|
100
|
+
dispose();
|
|
101
|
+
offscreen = createOffscreen(cols);
|
|
102
|
+
return offscreen;
|
|
103
|
+
};
|
|
104
|
+
const capRows = (rows) => {
|
|
105
|
+
const cap = SCROLLBACK_LIMITS.maxRowsPerEntry;
|
|
106
|
+
if (rows.length <= cap)
|
|
107
|
+
return rows;
|
|
108
|
+
const hidden = rows.length - (cap - 1);
|
|
109
|
+
elidedRows += hidden;
|
|
110
|
+
return [...rows.slice(0, cap - 1), `[+${hidden} rows not printed - scrollback entry cap]`];
|
|
111
|
+
};
|
|
112
|
+
const renderNode = (node, cols) => {
|
|
113
|
+
const target = ensure(Math.max(1, Math.floor(cols)));
|
|
114
|
+
target.last.value = null;
|
|
115
|
+
target.failure.error = NO_FAILURE;
|
|
116
|
+
guardKey += 1;
|
|
117
|
+
target.instance.rerender(_jsx(Box, { flexDirection: "column", width: target.cols, children: _jsx(RenderGuard, { failure: target.failure, children: node }, guardKey) }));
|
|
118
|
+
const frame = readFrame(target);
|
|
119
|
+
const failure = target.failure.error;
|
|
120
|
+
target.instance.rerender(null);
|
|
121
|
+
if (failure !== NO_FAILURE)
|
|
122
|
+
throw failure;
|
|
123
|
+
if (frame === null)
|
|
124
|
+
throw new Error('offscreen Ink instance produced no frame');
|
|
125
|
+
return capRows(trimTrailingBlank(frame.split('\n')));
|
|
126
|
+
};
|
|
127
|
+
const failureRow = (label) => {
|
|
128
|
+
failures += 1;
|
|
129
|
+
return [`[aragon] ${label} could not be rendered`];
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
renderEntry(entry, prev, cols, expanded = false) {
|
|
133
|
+
try {
|
|
134
|
+
return renderNode(_jsx(EntryView, { entry: entry, prev: prev, expanded: expanded, thinkingVisible: options.thinkingVisible, reducedMotion: options.reducedMotion, density: options.density, theme: options.theme, caps: options.caps }), cols);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
getLogger().warn('cli', 'entry_render_failed', {
|
|
138
|
+
id: entry.id, kind: entry.kind, error: String(error),
|
|
139
|
+
});
|
|
140
|
+
return failureRow(`entry ${entry.id} (${entry.kind})`);
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
renderNode(node, cols) {
|
|
144
|
+
try {
|
|
145
|
+
return renderNode(node, cols);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
getLogger().warn('cli', 'node_render_failed', { error: String(error) });
|
|
149
|
+
return failureRow('startup banner');
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
setOptions(partial) {
|
|
153
|
+
options = { ...options, ...partial };
|
|
154
|
+
},
|
|
155
|
+
stats: () => ({ elidedRows, failures }),
|
|
156
|
+
dispose,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
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 type { FrameDiffer } from '../frame-differ.js';
|
|
30
|
+
import type { ScrollbackDrain } from './drain.js';
|
|
31
|
+
export interface ScrollbackWriterOptions {
|
|
32
|
+
rows: () => number | undefined;
|
|
33
|
+
cols: () => number | undefined;
|
|
34
|
+
/** DEC 2026 envelope around each batch. */
|
|
35
|
+
sync: boolean;
|
|
36
|
+
/** `diffRender: false` — every batch rewrites the whole region. */
|
|
37
|
+
fullEveryFrame: boolean;
|
|
38
|
+
drain: ScrollbackDrain;
|
|
39
|
+
/** Raised once on the first foreign / unparseable write. */
|
|
40
|
+
onFirstFallback?: () => void;
|
|
41
|
+
/** Raised when out-of-resize tall frames repeat; the App degrades to a placeholder. */
|
|
42
|
+
onGuardTrip?: () => void;
|
|
43
|
+
/**
|
|
44
|
+
* Ask the host to call `repaint()` soon (coalesced here). Used when static rows
|
|
45
|
+
* arrive without a new Ink frame, and to flush the rest of an oversized feed.
|
|
46
|
+
*/
|
|
47
|
+
requestRepaint?: () => void;
|
|
48
|
+
/** Injected clock for the resize grace window. */
|
|
49
|
+
now?: () => number;
|
|
50
|
+
}
|
|
51
|
+
export interface ScrollbackWriterStats {
|
|
52
|
+
framesSeed: number;
|
|
53
|
+
framesDiff: number;
|
|
54
|
+
framesFull: number;
|
|
55
|
+
staticRowsWritten: number;
|
|
56
|
+
bytesWritten: number;
|
|
57
|
+
fallbacks: number;
|
|
58
|
+
/** Tall frames swallowed (the scrollback-erasing `ESC[3J` path), resize or not. */
|
|
59
|
+
guardHits: number;
|
|
60
|
+
/** The subset NOT explained by a recent resize: the real defect signal. */
|
|
61
|
+
guardHitsOutsideResize: number;
|
|
62
|
+
clippedRows: number;
|
|
63
|
+
elidedEntries: number;
|
|
64
|
+
resizes: number;
|
|
65
|
+
repaints: number;
|
|
66
|
+
}
|
|
67
|
+
export interface ScrollbackWriter extends FrameDiffer {
|
|
68
|
+
/** Idempotent. Returns the exit bytes; afterwards every Ink write is swallowed. */
|
|
69
|
+
finalize(summary: readonly string[]): string;
|
|
70
|
+
/** Rows of the live region as last parked. */
|
|
71
|
+
regionRows(): number;
|
|
72
|
+
scrollbackStats(): ScrollbackWriterStats;
|
|
73
|
+
}
|
|
74
|
+
export declare function createScrollbackWriter(o: ScrollbackWriterOptions): ScrollbackWriter;
|