@aragon-agent/cli 0.6.29 → 0.6.32

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 (123) hide show
  1. package/CHANGELOG.md +2223 -2103
  2. package/README.md +2957 -2759
  3. package/dist/agent/controller.d.ts +46 -0
  4. package/dist/agent/controller.js +175 -7
  5. package/dist/agent/job-notices.d.ts +143 -0
  6. package/dist/agent/job-notices.js +182 -0
  7. package/dist/agent/project-guidance-prompt.js +20 -20
  8. package/dist/agent/reducer.d.ts +21 -1
  9. package/dist/agent/reducer.js +7 -0
  10. package/dist/agent/system-prompt.d.ts +12 -0
  11. package/dist/agent/system-prompt.js +2 -0
  12. package/dist/cli.js +399 -62
  13. package/dist/commands/builtins.js +27 -8
  14. package/dist/commands/perf.d.ts +79 -2
  15. package/dist/commands/perf.js +51 -3
  16. package/dist/commands/registry.d.ts +6 -0
  17. package/dist/config/cli-commands.d.ts +27 -0
  18. package/dist/config/cli-commands.js +54 -1
  19. package/dist/config/env.js +42 -0
  20. package/dist/config/load.d.ts +3 -1
  21. package/dist/config/load.js +61 -1
  22. package/dist/config/schema.d.ts +100 -0
  23. package/dist/config/schema.js +43 -0
  24. package/dist/config/store.js +14 -1
  25. package/dist/config/ui-state.d.ts +9 -0
  26. package/dist/config/ui-state.js +9 -0
  27. package/dist/exec/events.d.ts +29 -3
  28. package/dist/exec/events.js +3 -12
  29. package/dist/exec/runner.d.ts +24 -0
  30. package/dist/exec/runner.js +79 -0
  31. package/dist/input/cursor-report.d.ts +61 -0
  32. package/dist/input/cursor-report.js +97 -0
  33. package/dist/input/stdin-filter.d.ts +22 -0
  34. package/dist/input/stdin-filter.js +48 -3
  35. package/dist/proc/limits.d.ts +76 -2
  36. package/dist/proc/limits.js +83 -2
  37. package/dist/proc/log-mirror.d.ts +59 -0
  38. package/dist/proc/log-mirror.js +203 -0
  39. package/dist/proc/log-ring.d.ts +22 -0
  40. package/dist/proc/log-ring.js +20 -1
  41. package/dist/proc/notices.d.ts +57 -0
  42. package/dist/proc/notices.js +101 -0
  43. package/dist/proc/progress.d.ts +40 -0
  44. package/dist/proc/progress.js +135 -0
  45. package/dist/proc/prompt.d.ts +24 -2
  46. package/dist/proc/prompt.js +51 -2
  47. package/dist/proc/supervisor.d.ts +35 -2
  48. package/dist/proc/supervisor.js +142 -43
  49. package/dist/proc/types.d.ts +89 -1
  50. package/dist/proc/wait.d.ts +57 -0
  51. package/dist/proc/wait.js +133 -0
  52. package/dist/session/persist.js +3 -1
  53. package/dist/session/validate-session.js +17 -0
  54. package/dist/tools/bash-tool.d.ts +12 -1
  55. package/dist/tools/bash-tool.js +100 -13
  56. package/dist/tools/index.d.ts +26 -2
  57. package/dist/tools/index.js +43 -2
  58. package/dist/tools/job-tools.d.ts +54 -0
  59. package/dist/tools/job-tools.js +329 -0
  60. package/dist/tools/proc-tools.d.ts +1 -1
  61. package/dist/tools/proc-tools.js +49 -17
  62. package/dist/ui/ActivityLine.js +7 -0
  63. package/dist/ui/App.d.ts +11 -1
  64. package/dist/ui/App.js +181 -63
  65. package/dist/ui/ScrollbackShell.d.ts +54 -0
  66. package/dist/ui/ScrollbackShell.js +15 -0
  67. package/dist/ui/StatusBar.d.ts +5 -0
  68. package/dist/ui/StatusBar.js +1 -0
  69. package/dist/ui/Transcript.d.ts +14 -0
  70. package/dist/ui/Transcript.js +14 -2
  71. package/dist/ui/entries/ServiceCard.d.ts +17 -1
  72. package/dist/ui/entries/ServiceCard.js +39 -4
  73. package/dist/ui/frame-differ.d.ts +12 -0
  74. package/dist/ui/layout/status-layout.d.ts +13 -1
  75. package/dist/ui/layout/status-layout.js +10 -0
  76. package/dist/ui/layout/virtual-window.js +14 -1
  77. package/dist/ui/overlays/HelpOverlay.d.ts +1 -1
  78. package/dist/ui/overlays/HelpOverlay.js +21 -3
  79. package/dist/ui/run-status-row.d.ts +12 -0
  80. package/dist/ui/screen.d.ts +29 -0
  81. package/dist/ui/screen.js +40 -0
  82. package/dist/ui/scrollback/LiveTail.d.ts +64 -0
  83. package/dist/ui/scrollback/LiveTail.js +27 -0
  84. package/dist/ui/scrollback/band.d.ts +105 -0
  85. package/dist/ui/scrollback/band.js +109 -0
  86. package/dist/ui/scrollback/budget.d.ts +72 -0
  87. package/dist/ui/scrollback/budget.js +81 -0
  88. package/dist/ui/scrollback/canvas.d.ts +99 -0
  89. package/dist/ui/scrollback/canvas.js +113 -0
  90. package/dist/ui/scrollback/cursor-probe.d.ts +71 -0
  91. package/dist/ui/scrollback/cursor-probe.js +133 -0
  92. package/dist/ui/scrollback/drain.d.ts +32 -0
  93. package/dist/ui/scrollback/drain.js +64 -0
  94. package/dist/ui/scrollback/exit-summary.d.ts +23 -0
  95. package/dist/ui/scrollback/exit-summary.js +25 -0
  96. package/dist/ui/scrollback/limits.d.ts +74 -0
  97. package/dist/ui/scrollback/limits.js +74 -0
  98. package/dist/ui/scrollback/live-preview.d.ts +26 -0
  99. package/dist/ui/scrollback/live-preview.js +106 -0
  100. package/dist/ui/scrollback/renderer.d.ts +38 -0
  101. package/dist/ui/scrollback/renderer.js +158 -0
  102. package/dist/ui/scrollback/scrollback-writer.d.ts +162 -0
  103. package/dist/ui/scrollback/scrollback-writer.js +549 -0
  104. package/dist/ui/scrollback/settled-boundary.d.ts +42 -0
  105. package/dist/ui/scrollback/settled-boundary.js +106 -0
  106. package/dist/ui/scrollback/use-scrollback-view.d.ts +103 -0
  107. package/dist/ui/scrollback/use-scrollback-view.js +294 -0
  108. package/dist/ui/scrollback/writer-shapes.d.ts +127 -0
  109. package/dist/ui/scrollback/writer-shapes.js +159 -0
  110. package/dist/ui/stdout-frame-writer.js +12 -2
  111. package/dist/ui/terminal-plan.d.ts +52 -0
  112. package/dist/ui/terminal-plan.js +49 -0
  113. package/dist/ui/transcript-text.d.ts +11 -0
  114. package/dist/ui/transcript-text.js +21 -7
  115. package/dist/ui/use-startup-notices.d.ts +12 -1
  116. package/dist/ui/use-startup-notices.js +28 -2
  117. package/package.json +3 -3
  118. package/skills/project-indexer/SKILL.md +163 -163
  119. package/skills/project-indexer/templates/cleancode-overrides.md +46 -46
  120. package/skills/project-indexer/templates/cleancode-template.md +94 -94
  121. package/skills/project-indexer/templates/config-template.md +69 -69
  122. package/skills/project-indexer/templates/index-template.md +129 -129
  123. package/skills/skill-creator/SKILL.md +96 -96
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Settled boundary — which transcript entries may be PRINTED
3
+ * (tui-native-terminal-scroll §4.4).
4
+ *
5
+ * Printed rows live in the host terminal's scrollback and can never be rewritten
6
+ * ("printed is forever"). So the only entries that may be printed are the ones the
7
+ * reducer will NEVER write to again. This module derives that from the reducer's
8
+ * real write set, one exhaustive `switch` per entry kind. Adding an `Entry` kind is
9
+ * a compile error here (`never`), which is the whole point; widening the write set
10
+ * of an EXISTING kind is not caught by the compiler, so `agent/reducer.ts` carries
11
+ * a pointer comment beside the `Entry` union and the unit tests pin each row.
12
+ *
13
+ * Three rules that are easy to "simplify" wrongly:
14
+ * - A tool is stable only when `done` or `error`. `pending` is NOT stable: the
15
+ * card is created at `pending` and `toolCallDelta` keeps rewriting `argsRaw`
16
+ * while the model streams its arguments (RV-1).
17
+ * - A trailing `Context length exceeded` error notice is NOT stable while a run is
18
+ * active: `compactionStart` rewrites it in place into an info notice (RV-4).
19
+ * - A non-terminal `service` card is neither printed nor a wall (RV-3). It is
20
+ * rewritten for the whole session; the one-row `terminal: true` record the
21
+ * reducer appends at the end is an ordinary stable entry and carries the news.
22
+ */
23
+ import { CONTEXT_OVERFLOW_NOTICE_PREFIX } from '../../agent/reducer.js';
24
+ /** Kinds the reducer may still rewrite while a run is active, so a tail one is held. */
25
+ const MUTABLE_TAIL_KINDS = new Set([
26
+ 'assistant', 'tool', 'compaction', 'todo', 'team', 'fast', 'retry', 'queued',
27
+ ]);
28
+ /** Never printed and never a wall: rewritten for the whole session (RV-3). */
29
+ export function isExcluded(entry) {
30
+ return entry.kind === 'service' && entry.terminal !== true;
31
+ }
32
+ /** True when the reducer will never write to `entry` again. */
33
+ export function isEntryStable(entry, runActive, isTail) {
34
+ switch (entry.kind) {
35
+ case 'user':
36
+ return true;
37
+ case 'notice':
38
+ return !(runActive &&
39
+ isTail &&
40
+ entry.level === 'error' &&
41
+ entry.text.startsWith(CONTEXT_OVERFLOW_NOTICE_PREFIX));
42
+ case 'assistant':
43
+ return !entry.streaming;
44
+ case 'tool':
45
+ return entry.status === 'done' || entry.status === 'error';
46
+ case 'compaction':
47
+ return entry.live !== true;
48
+ case 'todo':
49
+ return entry.live === false;
50
+ case 'team':
51
+ return entry.active === false;
52
+ case 'fast':
53
+ return entry.live === false;
54
+ case 'retry':
55
+ return (entry.phase === 'recovered' ||
56
+ entry.phase === 'exhausted' ||
57
+ entry.phase === 'interrupted');
58
+ case 'queued':
59
+ // Rewritten in place into a `user` entry by the matching receipt.
60
+ return false;
61
+ case 'service':
62
+ // Only the terminal record is ever a candidate; `isExcluded` filters the rest.
63
+ return entry.terminal === true;
64
+ default: {
65
+ const exhaustive = entry;
66
+ return exhaustive;
67
+ }
68
+ }
69
+ }
70
+ /**
71
+ * Length of the printable PREFIX of `entries`.
72
+ *
73
+ * The first unstable (non-excluded) entry is a wall: everything before it may
74
+ * print, nothing after it may, which keeps scrollback order equal to conversation
75
+ * order (I-S3). Excluded entries are skipped, so a long-lived service card never
76
+ * blocks the conversation behind it.
77
+ */
78
+ export function settledCount(entries, runActive) {
79
+ const last = entries.length - 1;
80
+ let count = entries.length;
81
+ for (let i = 0; i < entries.length; i += 1) {
82
+ const entry = entries[i];
83
+ if (isExcluded(entry))
84
+ continue;
85
+ if (!isEntryStable(entry, runActive, i === last)) {
86
+ count = i;
87
+ break;
88
+ }
89
+ }
90
+ // Hold a mutable TAIL while a run is active, but only a mutable one: a `user` or
91
+ // `notice` at the tail prints immediately (a submitted message must appear at once).
92
+ if (runActive && count === entries.length && count > 0) {
93
+ const tail = entries[last];
94
+ if (!isExcluded(tail) && MUTABLE_TAIL_KINDS.has(tail.kind))
95
+ return last;
96
+ }
97
+ return count;
98
+ }
99
+ /**
100
+ * Everything printable at shutdown: all entries except those that never settle
101
+ * (`queued`, non-terminal services). The last answer must not be lost to a
102
+ * boundary that was waiting for a frame that will never come.
103
+ */
104
+ export function settleAllEntries(entries) {
105
+ return entries.filter((entry) => entry.kind !== 'queued' && !isExcluded(entry));
106
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `useScrollbackView` — the promotion pipeline of native-scroll mode
3
+ * (tui-native-terminal-scroll §4.4 / §6.2).
4
+ *
5
+ * `App` calls it ALWAYS (hooks cannot be conditional); `enabled: false` makes every
6
+ * effect a no-op and keeps `scrollMode: 'app'` byte-identical to the pre-feature
7
+ * build (I-S9).
8
+ *
9
+ * What it does when enabled:
10
+ * 1. Prints the session header (and the opener for an empty session) once.
11
+ * 2. On every entries change, promotes each newly SETTLED entry: renders it once
12
+ * through the offscreen renderer and enqueues the rows for the writer.
13
+ * 3. Computes the live-region budget and the streaming preview rows for the shell.
14
+ *
15
+ * THE PROMOTION RUNS IN A MICROTASK, NOT IN THE EFFECT. The offscreen renderer is a
16
+ * second Ink instance, and both instances share one React reconciler. A `rerender`
17
+ * issued from inside the main tree's commit (a layout effect) is deferred until that
18
+ * commit finishes, so its frame would not exist when the effect reads it. A
19
+ * microtask runs after the commit has unwound, where the offscreen render is
20
+ * synchronous again. Everything the pass needs is read from refs for that reason.
21
+ *
22
+ * `printedIds` is the ONLY authority on "already printed" (RV-7). The cursor is just
23
+ * a hint for where to resume scanning: entries leave the list by retain-ring
24
+ * trimming AND by reducer deletion (empty settled assistants, abandoned tool
25
+ * cards), so an id-keyed cursor can dangle, and then the only correct answer is to
26
+ * rescan and let `printedIds` skip.
27
+ */
28
+ import React from 'react';
29
+ import type { Entry } from '../../agent/reducer.js';
30
+ import type { TermCapabilities } from '../capabilities.js';
31
+ import type { DensityMode } from '../density.js';
32
+ import type { Theme } from '../theme.js';
33
+ import { type ScrollbackBudget } from './budget.js';
34
+ import type { ScrollbackDrain } from './drain.js';
35
+ import type { ScrollbackWriter } from './scrollback-writer.js';
36
+ /** The channel between `cli.tsx` (which owns the stream) and the React tree. */
37
+ export interface ScrollbackBridge {
38
+ writer: ScrollbackWriter;
39
+ drain: ScrollbackDrain;
40
+ /** Installed by `cli.tsx`: schedule `writer.repaint()` soon. */
41
+ requestFlush: () => void;
42
+ /**
43
+ * Installed by the hook on mount (reads the latest entries through a ref).
44
+ * `restore()` calls it on every exit path; it is idempotent through `printedIds`
45
+ * and it releases the offscreen renderer when done.
46
+ */
47
+ settleAllNow: () => void;
48
+ /** Installed by `App`; at most `exitSummaryMaxRows` rows. Wrapped in try/catch by the caller. */
49
+ getSummaryRows: () => readonly string[];
50
+ /** Installed by `App`: the writer swallowed repeated tall frames (degrade to the placeholder). */
51
+ onGuardTrip: (() => void) | null;
52
+ }
53
+ export declare function createScrollbackBridge(parts: Pick<ScrollbackBridge, 'writer' | 'drain'> & {
54
+ requestFlush?: () => void;
55
+ }): ScrollbackBridge;
56
+ export type ScrollbackResetKind = 'clear' | 'restore';
57
+ export interface UseScrollbackViewArgs {
58
+ /** `scrollMode === 'native'`. */
59
+ enabled: boolean;
60
+ bridge: ScrollbackBridge | undefined;
61
+ entries: readonly Entry[];
62
+ runActive: boolean;
63
+ theme: Theme;
64
+ caps: TermCapabilities;
65
+ density: DensityMode;
66
+ thinkingVisible: boolean;
67
+ reducedMotion: boolean;
68
+ /** Terminal size. */
69
+ rows: number;
70
+ cols: number;
71
+ /** Printed first, once. */
72
+ headerNode: React.ReactNode;
73
+ /** Printed after the header for an empty session. */
74
+ openerNode: React.ReactNode | null;
75
+ overlayOpen: boolean;
76
+ draftRows: number;
77
+ popupRows: number;
78
+ statusExpanded: boolean;
79
+ /** Degrade the region to the placeholder (the writer's tall-frame guard tripped). */
80
+ forceInactive?: boolean;
81
+ /** `state.todos.items.length` - 0 means there is no plan, so the rail has no work to show. */
82
+ itemCount: number;
83
+ /** `cfg.todo.panel`: the user can switch the rail off entirely. */
84
+ panelEnabled: boolean;
85
+ /** `buildTeamPanelLayout().rowCount`; 0 when nothing is dispatched. */
86
+ teamRowsNatural: number;
87
+ }
88
+ export interface ScrollbackView {
89
+ budget: ScrollbackBudget | null;
90
+ liveTailLines: string[];
91
+ /**
92
+ * The exact list `liveTailLines` was built from, so the real-component tail
93
+ * (`LiveTail`) and the plain-text fallback can never disagree about their input.
94
+ */
95
+ unsettledEntries: readonly Entry[];
96
+ /** Print a fresh EXPANDED copy of an entry; false when it cannot be found / rendered. */
97
+ reprintExpanded(entryId: string): boolean;
98
+ /** Promote everything printable now (shutdown). */
99
+ settleAll(): void;
100
+ /** Called by `App`'s dispatch wrapper BEFORE a reset action reaches the reducer. */
101
+ notifyReset(kind: ScrollbackResetKind): void;
102
+ }
103
+ export declare function useScrollbackView(args: UseScrollbackViewArgs): ScrollbackView;
@@ -0,0 +1,294 @@
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, itemCount, panelEnabled, teamRowsNatural, } = 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
+ // Band hysteresis (release-gate-and-native-panels §6.7). Same lifecycle and same
90
+ // reason as `heldTailRows`: the writer re-lays out the canvas on every batch, so a
91
+ // band that shrank for two frames would make everything above the composer jump.
92
+ const heldRailRows = useRef(0);
93
+ const heldTeamRows = useRef(0);
94
+ const heldGeometry = useRef({ rows, cols });
95
+ const pumpQueued = useRef(false);
96
+ const mountedRef = useRef(true);
97
+ const ensureRenderer = useCallback(() => {
98
+ if (!rendererRef.current)
99
+ rendererRef.current = createEntryRenderer(latest.current.options);
100
+ rendererRef.current.setOptions(latest.current.options);
101
+ return rendererRef.current;
102
+ }, []);
103
+ const enqueue = useCallback((rowsToPrint, id, kind) => {
104
+ if (rowsToPrint.length === 0 || !bridge)
105
+ return;
106
+ bridge.drain.enqueue(rowsToPrint, { id, kind });
107
+ }, [bridge]);
108
+ /** Promote `list[start..end)`; returns true when it stopped early because the feed is full. */
109
+ const promote = useCallback((list, end, limit) => {
110
+ const renderer = ensureRenderer();
111
+ let start = 0;
112
+ const cursor = cursorRef.current;
113
+ if (cursor && list[cursor.index]?.id === cursor.id)
114
+ start = cursor.index + 1;
115
+ let promoted = 0;
116
+ let lastIndex = start - 1;
117
+ const remember = () => {
118
+ cursorRef.current = lastIndex >= 0 ? { id: list[lastIndex].id, index: lastIndex } : null;
119
+ };
120
+ for (let i = start; i < end; i += 1) {
121
+ const entry = list[i];
122
+ if (isExcluded(entry) || printedIds.current.has(entry.id)) {
123
+ lastIndex = i;
124
+ continue;
125
+ }
126
+ if (promoted >= limit) {
127
+ remember();
128
+ return true;
129
+ }
130
+ const rowsToPrint = renderer.renderEntry(entry, printableBefore(list, i), latest.current.contentCols);
131
+ enqueue(rowsToPrint, entry.id, entry.kind);
132
+ // A rendered-to-nothing entry is registered too, or it would be retried forever.
133
+ printedIds.current.add(entry.id);
134
+ promoted += 1;
135
+ lastIndex = i;
136
+ }
137
+ remember();
138
+ return false;
139
+ }, [ensureRenderer, enqueue]);
140
+ const printBanner = useCallback(() => {
141
+ if (startedRef.current)
142
+ return;
143
+ startedRef.current = true;
144
+ const renderer = ensureRenderer();
145
+ const { headerNode: header, openerNode: opener, contentCols: width } = latest.current;
146
+ enqueue(renderer.renderNode(header, width), 'startup:header', 'header');
147
+ if (opener)
148
+ enqueue(renderer.renderNode(opener, width), 'startup:opener', 'opener');
149
+ }, [ensureRenderer, enqueue]);
150
+ const applyPendingReset = useCallback(() => {
151
+ const reset = pendingReset.current;
152
+ if (!reset || latest.current.entries === reset.captured)
153
+ return;
154
+ pendingReset.current = null;
155
+ printedIds.current = new Set();
156
+ cursorRef.current = null;
157
+ enqueue([reset.kind === 'clear' ? CLEARED_ROW : RESTORED_ROW], `reset:${Date.now()}`, 'notice');
158
+ }, [enqueue]);
159
+ const pumpRef = useRef(() => undefined);
160
+ const schedulePump = useCallback(() => {
161
+ if (pumpQueued.current)
162
+ return;
163
+ pumpQueued.current = true;
164
+ queueMicrotask(() => pumpRef.current());
165
+ }, []);
166
+ /** The promotion pass. Runs in a microtask (see the file header). */
167
+ pumpRef.current = () => {
168
+ pumpQueued.current = false;
169
+ if (!mountedRef.current || !bridge)
170
+ return;
171
+ printBanner();
172
+ applyPendingReset();
173
+ const { entries: list, runActive: active } = latest.current;
174
+ const limit = Math.max(1, SCROLLBACK_LIMITS.feedCapacity - bridge.drain.pendingEntries() - 1);
175
+ const more = promote(list, settledCount(list, active), limit);
176
+ pruneEntryIds(printedIds.current, list);
177
+ if (more) {
178
+ // Continue once the writer has consumed what is queued (a restored transcript
179
+ // longer than the feed is promoted in waves rather than folded into a marker).
180
+ bridge.drain.onDrained = () => {
181
+ bridge.drain.onDrained = null;
182
+ schedulePump();
183
+ };
184
+ bridge.requestFlush();
185
+ }
186
+ };
187
+ useLayoutEffect(() => {
188
+ if (enabled && bridge)
189
+ schedulePump();
190
+ }, [enabled, bridge, entries, runActive, schedulePump]);
191
+ const settleAll = useCallback(() => {
192
+ if (!bridge)
193
+ return;
194
+ const list = latest.current.entries;
195
+ const renderer = ensureRenderer();
196
+ printBanner();
197
+ // Shutdown must not leave anything behind, so no per-pass limit.
198
+ for (const entry of settleAllEntries(list)) {
199
+ if (printedIds.current.has(entry.id))
200
+ continue;
201
+ enqueue(renderer.renderEntry(entry, printableBefore(list, list.indexOf(entry)), latest.current.contentCols), entry.id, entry.kind);
202
+ printedIds.current.add(entry.id);
203
+ }
204
+ }, [bridge, ensureRenderer, printBanner, enqueue]);
205
+ // Shutdown hook for the exit paths. `restore()` runs it AFTER the tree is gone
206
+ // (normal exit) or while it is alive (signal), so it renders synchronously and then
207
+ // releases the offscreen instance. The unmount cleanup deliberately renders nothing:
208
+ // it runs inside the main tree's commit, where an offscreen render is deferred.
209
+ const settleAllRef = useRef(settleAll);
210
+ settleAllRef.current = settleAll;
211
+ useEffect(() => {
212
+ mountedRef.current = true;
213
+ if (!enabled || !bridge)
214
+ return undefined;
215
+ bridge.settleAllNow = () => {
216
+ try {
217
+ settleAllRef.current();
218
+ }
219
+ finally {
220
+ rendererRef.current?.dispose();
221
+ rendererRef.current = null;
222
+ }
223
+ };
224
+ return () => { mountedRef.current = false; };
225
+ }, [enabled, bridge]);
226
+ // --- Live region budget and streaming preview. ---------------------------
227
+ const boundary = useMemo(() => settledCount(entries, runActive), [entries, runActive]);
228
+ const unsettled = useMemo(() => entries.slice(boundary).filter((entry) => !isExcluded(entry)), [entries, boundary]);
229
+ const wanted = enabled
230
+ ? buildLivePreview({
231
+ unsettled, cols: contentCols, rows: SCROLLBACK_LIMITS.maxLiveTailRows, thinkingVisible,
232
+ }).length
233
+ : 0;
234
+ // D11 · the preview height only grows while anything is unsettled, then resets.
235
+ heldTailRows.current = unsettled.length === 0 ? 0 : Math.max(heldTailRows.current, wanted);
236
+ // The band's hysteresis has exactly THREE reset points and no others: the plan
237
+ // emptied, the dispatch finished, or the geometry changed (a new size is a new
238
+ // measurement - the same treatment `App` gives `guardTripped`).
239
+ if (heldGeometry.current.rows !== rows || heldGeometry.current.cols !== cols) {
240
+ heldGeometry.current = { rows, cols };
241
+ heldRailRows.current = 0;
242
+ heldTeamRows.current = 0;
243
+ }
244
+ if (itemCount === 0)
245
+ heldRailRows.current = 0;
246
+ if (teamRowsNatural === 0)
247
+ heldTeamRows.current = 0;
248
+ const budget = useMemo(() => enabled
249
+ ? buildScrollbackBudget({
250
+ rows, cols, draftRows, popupRows, statusExpanded, overlayOpen,
251
+ liveTailWanted: heldTailRows.current, forceInactive,
252
+ band: {
253
+ itemCount, panelEnabled, teamRowsNatural,
254
+ heldRailRows: heldRailRows.current, heldTeamRows: heldTeamRows.current,
255
+ },
256
+ })
257
+ : null,
258
+ // `heldTailRows.current` and the two band refs are read at memo time; `wanted`,
259
+ // `itemCount` and `teamRowsNatural` are what move them. ONE memo, deliberately:
260
+ // a second one depending on the first would render the previous frame's band.
261
+ // eslint-disable-next-line react-hooks/exhaustive-deps
262
+ [enabled, rows, cols, draftRows, popupRows, statusExpanded, overlayOpen, wanted,
263
+ unsettled.length, forceInactive, itemCount, panelEnabled, teamRowsNatural]);
264
+ if (budget) {
265
+ // Monotone memory, fed back into the memo above on the next frame.
266
+ heldRailRows.current = budget.band.heldRailRows;
267
+ heldTeamRows.current = budget.band.heldTeamRows;
268
+ }
269
+ // Print keeps `contentCols`; only the live preview gives up the rail's columns (I-5).
270
+ const previewCols = budget ? budget.previewCols : contentCols;
271
+ const liveTailLines = useMemo(() => enabled && budget && budget.liveTailRows > 0
272
+ ? buildLivePreview({ unsettled, cols: previewCols, rows: budget.liveTailRows, thinkingVisible })
273
+ : [], [enabled, budget, unsettled, previewCols, thinkingVisible]);
274
+ const reprintExpanded = useCallback((entryId) => {
275
+ if (!enabled || !bridge)
276
+ return false;
277
+ const list = latest.current.entries;
278
+ const index = list.findIndex((entry) => entry.id === entryId);
279
+ if (index < 0)
280
+ return false;
281
+ const entry = list[index];
282
+ const body = ensureRenderer().renderEntry(entry, undefined, latest.current.contentCols, true);
283
+ if (body.length === 0)
284
+ return false;
285
+ enqueue([`-- expanded: ${entry.kind === 'tool' ? entry.name : entry.kind} --`, ...body], `expanded:${entryId}:${Date.now()}`, entry.kind);
286
+ return true;
287
+ }, [enabled, bridge, ensureRenderer, enqueue]);
288
+ const notifyReset = useCallback((kind) => {
289
+ if (!enabled)
290
+ return;
291
+ pendingReset.current = { kind, captured: latest.current.entries };
292
+ }, [enabled]);
293
+ return { budget, liveTailLines, unsettledEntries: unsettled, reprintExpanded, settleAll, notifyReset };
294
+ }
@@ -0,0 +1,127 @@
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
+ /**
25
+ * CUD(n): move down n rows WITHOUT writing a single cell.
26
+ *
27
+ * This is how the canvas spends its `gap` (D3). A blank row that is never written
28
+ * cannot be pushed into the scrollback by a later overflow, which is the whole
29
+ * reason the gap is not a run of `\r\n` (I-B3). The sequence is already whitelisted
30
+ * (`diffBytes` uses it to step between changed rows); this is the same CUD used to
31
+ * skip empty space instead.
32
+ */
33
+ export declare function skipRows(rows: number): string;
34
+ /**
35
+ * First canvas batch (or the first one after a reset): the screen below the cursor
36
+ * is assumed empty, so there is nothing to erase — only `[gap][content][region]` to
37
+ * write, ending with the cursor parked on the last row.
38
+ */
39
+ export declare function canvasSeedBytes(input: {
40
+ /**
41
+ * Rows to descend from the cursor to the canvas top, when the canvas is shorter
42
+ * than the space measured below the cursor.
43
+ *
44
+ * Non-zero only on a terminal taller than the start-of-canvas cap: the canvas then
45
+ * begins BELOW the cursor, and a seed that started writing at the cursor would park
46
+ * the region low and leave the top of the canvas empty for the whole session.
47
+ */
48
+ downToTop: number;
49
+ /** Blank rows to skip before the first content row. */
50
+ gap: number;
51
+ /** Content rows, already clipped, oldest first. */
52
+ held: readonly string[];
53
+ /** The live region. */
54
+ lines: readonly string[];
55
+ sync: boolean;
56
+ /** After a foreign write the cursor may sit mid-line; start on a new one. */
57
+ reseed: boolean;
58
+ }): string;
59
+ /**
60
+ * Redraw the whole canvas: erase the previous one, then write `[gap][content][region]`.
61
+ *
62
+ * ORDER IS SEMANTICS: `ED0`, then the gap, then the content, then the region. Moving
63
+ * the gap after the content puts the blank space back between the reply and the
64
+ * composer, which is the exact defect this feature fixes (M1 in the spec).
65
+ *
66
+ * `held` is the FULL content sequence, not the part that will still be on screen: the
67
+ * rows that do not fit are supposed to leave through the bottom, and the terminal can
68
+ * only scroll away what it was given.
69
+ */
70
+ export declare function canvasBytes(input: {
71
+ /** Previous canvas height: the erase distance. */
72
+ eraseUp: number;
73
+ gap: number;
74
+ held: readonly string[];
75
+ lines: readonly string[];
76
+ sync: boolean;
77
+ }): string;
78
+ /** Erase exactly the previous live region, print static rows, redraw the region. */
79
+ export declare function fullBytes(input: {
80
+ lines: readonly string[];
81
+ statics: readonly string[];
82
+ /** Rows between the region top and the parked cursor. */
83
+ eraseUp: number;
84
+ sync: boolean;
85
+ }): string;
86
+ /**
87
+ * Fill the screen down to the LAST row before the first batch (D4 rung 2 / §4.3).
88
+ *
89
+ * This is the fallback for a terminal that never answers `ESC[6n`: from any row, a
90
+ * run of `rows - 1` line feeds is guaranteed to leave the cursor on the bottom row
91
+ * (extra feeds only scroll, which is why the count is the caller's business). It is
92
+ * written ONCE, before anything else, so the rows it scrolls away are the host's old
93
+ * output — which is still in the scrollback — and never this session's content.
94
+ *
95
+ * The argument is the NUMBER OF LINE FEEDS, not the terminal height: `cli.tsx` passes
96
+ * `rows - 1`. Naming it after the height instead made the caller subtract twice and
97
+ * left the cursor one row short of the bottom.
98
+ */
99
+ export declare function fillBytes(count: number): string;
100
+ /** Same height, no static rows: rewrite only the rows that changed. `''` if none did. */
101
+ export declare function diffBytes(input: {
102
+ lines: readonly string[];
103
+ prev: readonly string[];
104
+ sync: boolean;
105
+ }): string;
106
+ /**
107
+ * Exit: erase the live region, flush leftover static rows, print the summary.
108
+ * `eraseUp < 0` means the geometry is unknown (a foreign write intervened), in
109
+ * which case the only safe move is to start on a fresh line and erase nothing.
110
+ */
111
+ export declare function finalizeBytes(input: {
112
+ eraseUp: number;
113
+ statics: readonly string[];
114
+ summary: readonly string[];
115
+ }): string;
116
+ /**
117
+ * Cut a row to `maxCols` DISPLAY columns (I-S13), keeping SGR / OSC 8 intact.
118
+ *
119
+ * Returns the input unchanged (same reference) when it already fits, which is the
120
+ * overwhelmingly common case. Width is measured the way Ink measures it
121
+ * (`string-width` over the ANSI-stripped text): that is the number Ink used to
122
+ * wrap, so a disagreement with the terminal is the only way a row can exceed it.
123
+ */
124
+ export declare function clipLine(line: string, maxCols: number): {
125
+ text: string;
126
+ clipped: boolean;
127
+ };