@aragon-agent/cli 0.6.28 → 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.
Files changed (80) hide show
  1. package/CHANGELOG.md +2123 -1975
  2. package/README.md +81 -14
  3. package/dist/agent/project-guidance-prompt.js +20 -20
  4. package/dist/agent/reducer.js +47 -2
  5. package/dist/cli.js +169 -56
  6. package/dist/commands/builtins.js +4 -0
  7. package/dist/commands/perf.d.ts +25 -1
  8. package/dist/commands/perf.js +26 -2
  9. package/dist/commands/registry.d.ts +6 -0
  10. package/dist/config/env.js +4 -0
  11. package/dist/config/load.d.ts +3 -1
  12. package/dist/config/load.js +12 -1
  13. package/dist/config/schema.d.ts +27 -0
  14. package/dist/config/schema.js +9 -0
  15. package/dist/config/ui-state.d.ts +9 -0
  16. package/dist/config/ui-state.js +9 -0
  17. package/dist/input/limits.d.ts +20 -0
  18. package/dist/input/limits.js +20 -0
  19. package/dist/input/stdin-filter.js +68 -5
  20. package/dist/ui/App.d.ts +11 -1
  21. package/dist/ui/App.js +155 -67
  22. package/dist/ui/AutocompletePopup.js +1 -1
  23. package/dist/ui/Header.js +7 -2
  24. package/dist/ui/Markdown.js +30 -3
  25. package/dist/ui/PromptInput.js +41 -3
  26. package/dist/ui/ScrollbackShell.d.ts +28 -0
  27. package/dist/ui/ScrollbackShell.js +10 -0
  28. package/dist/ui/Transcript.js +7 -0
  29. package/dist/ui/frame-differ.d.ts +12 -0
  30. package/dist/ui/layout/Gutter.js +1 -1
  31. package/dist/ui/layout/OverlayFrame.d.ts +15 -0
  32. package/dist/ui/layout/OverlayFrame.js +18 -0
  33. package/dist/ui/layout/ScrollIndicator.js +10 -3
  34. package/dist/ui/layout/budget.d.ts +15 -0
  35. package/dist/ui/layout/budget.js +15 -0
  36. package/dist/ui/layout/header-hints.d.ts +9 -0
  37. package/dist/ui/layout/header-hints.js +14 -1
  38. package/dist/ui/layout/status-layout.js +63 -2
  39. package/dist/ui/markdown-blocks.d.ts +26 -5
  40. package/dist/ui/markdown-blocks.js +110 -33
  41. package/dist/ui/overlays/HelpOverlay.d.ts +1 -1
  42. package/dist/ui/overlays/HelpOverlay.js +21 -3
  43. package/dist/ui/screen.d.ts +16 -0
  44. package/dist/ui/screen.js +32 -0
  45. package/dist/ui/scrollback/budget.d.ts +43 -0
  46. package/dist/ui/scrollback/budget.js +44 -0
  47. package/dist/ui/scrollback/drain.d.ts +32 -0
  48. package/dist/ui/scrollback/drain.js +64 -0
  49. package/dist/ui/scrollback/exit-summary.d.ts +23 -0
  50. package/dist/ui/scrollback/exit-summary.js +25 -0
  51. package/dist/ui/scrollback/limits.d.ts +25 -0
  52. package/dist/ui/scrollback/limits.js +25 -0
  53. package/dist/ui/scrollback/live-preview.d.ts +26 -0
  54. package/dist/ui/scrollback/live-preview.js +106 -0
  55. package/dist/ui/scrollback/renderer.d.ts +38 -0
  56. package/dist/ui/scrollback/renderer.js +158 -0
  57. package/dist/ui/scrollback/scrollback-writer.d.ts +74 -0
  58. package/dist/ui/scrollback/scrollback-writer.js +263 -0
  59. package/dist/ui/scrollback/settled-boundary.d.ts +42 -0
  60. package/dist/ui/scrollback/settled-boundary.js +106 -0
  61. package/dist/ui/scrollback/use-scrollback-view.d.ts +92 -0
  62. package/dist/ui/scrollback/use-scrollback-view.js +263 -0
  63. package/dist/ui/scrollback/writer-shapes.d.ts +61 -0
  64. package/dist/ui/scrollback/writer-shapes.js +114 -0
  65. package/dist/ui/stdout-frame-writer.js +12 -2
  66. package/dist/ui/terminal-plan.d.ts +52 -0
  67. package/dist/ui/terminal-plan.js +49 -0
  68. package/dist/ui/transcript-text.d.ts +11 -0
  69. package/dist/ui/transcript-text.js +21 -7
  70. package/dist/ui/use-startup-notices.d.ts +12 -1
  71. package/dist/ui/use-startup-notices.js +28 -2
  72. package/dist/ui/use-wheel-routing.d.ts +10 -7
  73. package/dist/ui/use-wheel-routing.js +11 -6
  74. package/package.json +2 -2
  75. package/skills/project-indexer/SKILL.md +163 -163
  76. package/skills/project-indexer/templates/cleancode-overrides.md +46 -46
  77. package/skills/project-indexer/templates/cleancode-template.md +94 -94
  78. package/skills/project-indexer/templates/config-template.md +69 -69
  79. package/skills/project-indexer/templates/index-template.md +129 -129
  80. package/skills/skill-creator/SKILL.md +96 -96
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Height budget of the native-scroll LIVE REGION (tui-native-terminal-scroll §4.6).
3
+ *
4
+ * I-S1 · `liveRows <= max(0, rows - 1)` for every input. Ink writes
5
+ * `clearTerminal` (which contains `ESC[3J`, erasing the user's scrollback) as soon
6
+ * as a frame is as tall as the terminal; a one-row margin is the only thing between
7
+ * a tidy region and data loss, so it is established by construction here and
8
+ * enumerated by a property test rather than trusted.
9
+ *
10
+ * The composer, popup and status rows come from the SAME `buildFrameBudget` the
11
+ * full-screen layout uses, so the two modes cannot disagree about how tall the
12
+ * editor is. What differs is the top: there is no header, so the row it would
13
+ * occupy joins the pool shared by the overlay and the streaming preview.
14
+ */
15
+ import { type FrameBudgetInput } from '../layout/budget.js';
16
+ export interface ScrollbackBudgetInput extends FrameBudgetInput {
17
+ overlayOpen: boolean;
18
+ /** Preview rows the content would like; the budget grants what fits. */
19
+ liveTailWanted: number;
20
+ /** The writer tripped its tall-frame guard: degrade to the placeholder until a resize. */
21
+ forceInactive?: boolean;
22
+ }
23
+ export interface ScrollbackBudget {
24
+ rows: number;
25
+ cols: number;
26
+ /** Width everything is rendered and printed at (`cols - 1`, D5). */
27
+ contentCols: number;
28
+ /** Terminal below the 40x12 floor: the region is a one-row placeholder. */
29
+ inactive: boolean;
30
+ statusRows: 0 | 1 | 2;
31
+ composerRows: number;
32
+ composerSlotRows: number;
33
+ composerCols: number;
34
+ popupRows: number;
35
+ popupMaxHeight: number;
36
+ overlayRows: number;
37
+ liveTailRows: number;
38
+ /** Rows above the composer: overlay or preview. */
39
+ topRows: number;
40
+ /** Total live-region height; what Ink renders. */
41
+ liveRows: number;
42
+ }
43
+ export declare function buildScrollbackBudget(input: ScrollbackBudgetInput): ScrollbackBudget;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Height budget of the native-scroll LIVE REGION (tui-native-terminal-scroll §4.6).
3
+ *
4
+ * I-S1 · `liveRows <= max(0, rows - 1)` for every input. Ink writes
5
+ * `clearTerminal` (which contains `ESC[3J`, erasing the user's scrollback) as soon
6
+ * as a frame is as tall as the terminal; a one-row margin is the only thing between
7
+ * a tidy region and data loss, so it is established by construction here and
8
+ * enumerated by a property test rather than trusted.
9
+ *
10
+ * The composer, popup and status rows come from the SAME `buildFrameBudget` the
11
+ * full-screen layout uses, so the two modes cannot disagree about how tall the
12
+ * editor is. What differs is the top: there is no header, so the row it would
13
+ * occupy joins the pool shared by the overlay and the streaming preview.
14
+ */
15
+ import { buildFrameBudget } from '../layout/budget.js';
16
+ import { SCROLLBACK_LIMITS } from './limits.js';
17
+ export function buildScrollbackBudget(input) {
18
+ const base = buildFrameBudget(input);
19
+ const contentCols = Math.max(1, base.cols - 1);
20
+ if (base.inactive || input.forceInactive === true) {
21
+ return {
22
+ rows: base.rows, cols: base.cols, contentCols, inactive: true, statusRows: 0,
23
+ composerRows: 0, composerSlotRows: 0, composerCols: base.composerCols, popupRows: 0,
24
+ popupMaxHeight: 0, overlayRows: 0, liveTailRows: 0, topRows: 0,
25
+ liveRows: Math.max(0, Math.min(1, base.rows - 1)),
26
+ };
27
+ }
28
+ // The full-screen frame spends one row on the header; here it joins the pool.
29
+ const remaining = Math.max(0, base.viewportRows + base.headerRows);
30
+ const overlayRows = input.overlayOpen ? remaining : 0;
31
+ const tailCap = Math.min(SCROLLBACK_LIMITS.maxLiveTailRows, Math.floor(base.rows * SCROLLBACK_LIMITS.liveTailRowShare));
32
+ const liveTailRows = input.overlayOpen
33
+ ? 0
34
+ : Math.max(0, Math.min(Math.floor(input.liveTailWanted), remaining, tailCap));
35
+ const topRows = overlayRows + liveTailRows;
36
+ const liveRows = topRows + base.composerSlotRows + base.statusRows;
37
+ return {
38
+ rows: base.rows, cols: base.cols, contentCols, inactive: false,
39
+ statusRows: base.statusRows, composerRows: base.composerRows,
40
+ composerSlotRows: base.composerSlotRows, composerCols: base.composerCols,
41
+ popupRows: base.popupRows, popupMaxHeight: base.popupMaxHeight, overlayRows,
42
+ liveTailRows, topRows, liveRows,
43
+ };
44
+ }
@@ -0,0 +1,32 @@
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
+ export interface EntryRef {
12
+ id: string;
13
+ kind: string;
14
+ }
15
+ export interface ScrollbackDrain {
16
+ enqueue(rows: readonly string[], meta: EntryRef): void;
17
+ takeAll(): readonly string[];
18
+ /** Whole entries only; always takes at least one when anything is pending. */
19
+ takeUpTo(maxRows: number): readonly string[];
20
+ /** Rows waiting, including a pending overflow marker. */
21
+ pending(): number;
22
+ /** Whole entries waiting (the overflow marker is not an entry). */
23
+ pendingEntries(): number;
24
+ /** Entries folded away by overflow, cumulative. */
25
+ elided(): number;
26
+ onEnqueue: (() => void) | null;
27
+ onDrained: (() => void) | null;
28
+ }
29
+ export declare function overflowMarker(count: number): string;
30
+ export declare function createScrollbackDrain(options?: {
31
+ capacity?: number;
32
+ }): ScrollbackDrain;
@@ -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
+ }