@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.
- package/CHANGELOG.md +2223 -2103
- package/README.md +2957 -2759
- package/dist/agent/controller.d.ts +46 -0
- package/dist/agent/controller.js +175 -7
- package/dist/agent/job-notices.d.ts +143 -0
- package/dist/agent/job-notices.js +182 -0
- package/dist/agent/project-guidance-prompt.js +20 -20
- package/dist/agent/reducer.d.ts +21 -1
- package/dist/agent/reducer.js +7 -0
- package/dist/agent/system-prompt.d.ts +12 -0
- package/dist/agent/system-prompt.js +2 -0
- package/dist/cli.js +399 -62
- package/dist/commands/builtins.js +27 -8
- package/dist/commands/perf.d.ts +79 -2
- package/dist/commands/perf.js +51 -3
- package/dist/commands/registry.d.ts +6 -0
- package/dist/config/cli-commands.d.ts +27 -0
- package/dist/config/cli-commands.js +54 -1
- package/dist/config/env.js +42 -0
- package/dist/config/load.d.ts +3 -1
- package/dist/config/load.js +61 -1
- package/dist/config/schema.d.ts +100 -0
- package/dist/config/schema.js +43 -0
- package/dist/config/store.js +14 -1
- package/dist/config/ui-state.d.ts +9 -0
- package/dist/config/ui-state.js +9 -0
- package/dist/exec/events.d.ts +29 -3
- package/dist/exec/events.js +3 -12
- package/dist/exec/runner.d.ts +24 -0
- package/dist/exec/runner.js +79 -0
- package/dist/input/cursor-report.d.ts +61 -0
- package/dist/input/cursor-report.js +97 -0
- package/dist/input/stdin-filter.d.ts +22 -0
- package/dist/input/stdin-filter.js +48 -3
- package/dist/proc/limits.d.ts +76 -2
- package/dist/proc/limits.js +83 -2
- package/dist/proc/log-mirror.d.ts +59 -0
- package/dist/proc/log-mirror.js +203 -0
- package/dist/proc/log-ring.d.ts +22 -0
- package/dist/proc/log-ring.js +20 -1
- package/dist/proc/notices.d.ts +57 -0
- package/dist/proc/notices.js +101 -0
- package/dist/proc/progress.d.ts +40 -0
- package/dist/proc/progress.js +135 -0
- package/dist/proc/prompt.d.ts +24 -2
- package/dist/proc/prompt.js +51 -2
- package/dist/proc/supervisor.d.ts +35 -2
- package/dist/proc/supervisor.js +142 -43
- package/dist/proc/types.d.ts +89 -1
- package/dist/proc/wait.d.ts +57 -0
- package/dist/proc/wait.js +133 -0
- package/dist/session/persist.js +3 -1
- package/dist/session/validate-session.js +17 -0
- package/dist/tools/bash-tool.d.ts +12 -1
- package/dist/tools/bash-tool.js +100 -13
- package/dist/tools/index.d.ts +26 -2
- package/dist/tools/index.js +43 -2
- package/dist/tools/job-tools.d.ts +54 -0
- package/dist/tools/job-tools.js +329 -0
- package/dist/tools/proc-tools.d.ts +1 -1
- package/dist/tools/proc-tools.js +49 -17
- package/dist/ui/ActivityLine.js +7 -0
- package/dist/ui/App.d.ts +11 -1
- package/dist/ui/App.js +181 -63
- package/dist/ui/ScrollbackShell.d.ts +54 -0
- package/dist/ui/ScrollbackShell.js +15 -0
- package/dist/ui/StatusBar.d.ts +5 -0
- package/dist/ui/StatusBar.js +1 -0
- package/dist/ui/Transcript.d.ts +14 -0
- package/dist/ui/Transcript.js +14 -2
- package/dist/ui/entries/ServiceCard.d.ts +17 -1
- package/dist/ui/entries/ServiceCard.js +39 -4
- package/dist/ui/frame-differ.d.ts +12 -0
- package/dist/ui/layout/status-layout.d.ts +13 -1
- package/dist/ui/layout/status-layout.js +10 -0
- package/dist/ui/layout/virtual-window.js +14 -1
- package/dist/ui/overlays/HelpOverlay.d.ts +1 -1
- package/dist/ui/overlays/HelpOverlay.js +21 -3
- package/dist/ui/run-status-row.d.ts +12 -0
- package/dist/ui/screen.d.ts +29 -0
- package/dist/ui/screen.js +40 -0
- package/dist/ui/scrollback/LiveTail.d.ts +64 -0
- package/dist/ui/scrollback/LiveTail.js +27 -0
- package/dist/ui/scrollback/band.d.ts +105 -0
- package/dist/ui/scrollback/band.js +109 -0
- package/dist/ui/scrollback/budget.d.ts +72 -0
- package/dist/ui/scrollback/budget.js +81 -0
- package/dist/ui/scrollback/canvas.d.ts +99 -0
- package/dist/ui/scrollback/canvas.js +113 -0
- package/dist/ui/scrollback/cursor-probe.d.ts +71 -0
- package/dist/ui/scrollback/cursor-probe.js +133 -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 +74 -0
- package/dist/ui/scrollback/limits.js +74 -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 +162 -0
- package/dist/ui/scrollback/scrollback-writer.js +549 -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 +103 -0
- package/dist/ui/scrollback/use-scrollback-view.js +294 -0
- package/dist/ui/scrollback/writer-shapes.d.ts +127 -0
- package/dist/ui/scrollback/writer-shapes.js +159 -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 +3 -3
- 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,72 @@
|
|
|
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, the panel band and the streaming
|
|
14
|
+
* preview.
|
|
15
|
+
*
|
|
16
|
+
* THIS IS THE ONLY CALL SITE OF `buildLiveBand` (I-8). `remaining` is computed here
|
|
17
|
+
* once and handed to the band; an `App` that computed its own copy would make
|
|
18
|
+
* `liveRows <= rows - 1` depend on two numbers happening to agree instead of on the
|
|
19
|
+
* construction that proves it.
|
|
20
|
+
*/
|
|
21
|
+
import { type LiveBand, type LiveBandInput } from './band.js';
|
|
22
|
+
import { type FrameBudgetInput } from '../layout/budget.js';
|
|
23
|
+
/**
|
|
24
|
+
* Everything the band needs that this module derives for itself: `remaining`,
|
|
25
|
+
* `contentCols`, `railCols`, `previewCap` and `previewWanted` are computed below, and
|
|
26
|
+
* `overlayOpen` is taken from `ScrollbackBudgetInput` so the two can never disagree.
|
|
27
|
+
* The caller supplies only the facts it alone knows (plan, panel switch, roster size,
|
|
28
|
+
* hysteresis), never a number this module is supposed to own (I-8).
|
|
29
|
+
*/
|
|
30
|
+
export type ScrollbackBandInput = Omit<LiveBandInput, 'remaining' | 'contentCols' | 'railCols' | 'previewCap' | 'previewWanted' | 'overlayOpen'>;
|
|
31
|
+
export interface ScrollbackBudgetInput extends FrameBudgetInput {
|
|
32
|
+
overlayOpen: boolean;
|
|
33
|
+
/** Preview rows the content would like; the budget grants what fits. */
|
|
34
|
+
liveTailWanted: number;
|
|
35
|
+
/** The writer tripped its tall-frame guard: degrade to the placeholder until a resize. */
|
|
36
|
+
forceInactive?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* REQUIRED, not optional (the same rule `TeamPanel`'s header records as D-13). An
|
|
39
|
+
* optional band would mean "forgot to pass it" renders silently without panels -
|
|
40
|
+
* exactly the defect this feature exists to remove - while a required field is a
|
|
41
|
+
* compile error at the one place that forgets.
|
|
42
|
+
*/
|
|
43
|
+
band: ScrollbackBandInput;
|
|
44
|
+
}
|
|
45
|
+
export interface ScrollbackBudget {
|
|
46
|
+
rows: number;
|
|
47
|
+
cols: number;
|
|
48
|
+
/** Width everything is rendered and printed at (`cols - 1`, D5). */
|
|
49
|
+
contentCols: number;
|
|
50
|
+
/** Terminal below the 40x12 floor: the region is a one-row placeholder. */
|
|
51
|
+
inactive: boolean;
|
|
52
|
+
statusRows: 0 | 1 | 2;
|
|
53
|
+
composerRows: number;
|
|
54
|
+
composerSlotRows: number;
|
|
55
|
+
composerCols: number;
|
|
56
|
+
popupRows: number;
|
|
57
|
+
popupMaxHeight: number;
|
|
58
|
+
overlayRows: number;
|
|
59
|
+
/** Preview rows: the band's projection, 0 while an overlay is open. */
|
|
60
|
+
liveTailRows: number;
|
|
61
|
+
/** Rows above the composer: `= band.bandRows` (the identity below is unchanged). */
|
|
62
|
+
topRows: number;
|
|
63
|
+
/** Total live-region height; what Ink renders (`topRows + composer + status`). */
|
|
64
|
+
liveRows: number;
|
|
65
|
+
/** Height and column allocation of the panel band; the single authority is `band.ts`. */
|
|
66
|
+
band: LiveBand;
|
|
67
|
+
/** Preview/tail width: `contentCols - railCols`. Printing keeps `contentCols`. */
|
|
68
|
+
previewCols: number;
|
|
69
|
+
/** Rows the preview wanted before any clipping (feeds hysteresis and `/perf`). */
|
|
70
|
+
previewWanted: number;
|
|
71
|
+
}
|
|
72
|
+
export declare function buildScrollbackBudget(input: ScrollbackBudgetInput): ScrollbackBudget;
|
|
@@ -0,0 +1,81 @@
|
|
|
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, the panel band and the streaming
|
|
14
|
+
* preview.
|
|
15
|
+
*
|
|
16
|
+
* THIS IS THE ONLY CALL SITE OF `buildLiveBand` (I-8). `remaining` is computed here
|
|
17
|
+
* once and handed to the band; an `App` that computed its own copy would make
|
|
18
|
+
* `liveRows <= rows - 1` depend on two numbers happening to agree instead of on the
|
|
19
|
+
* construction that proves it.
|
|
20
|
+
*/
|
|
21
|
+
import { buildLiveBand } from './band.js';
|
|
22
|
+
import { buildFrameBudget } from '../layout/budget.js';
|
|
23
|
+
import { todoRailWidth } from '../layout/rail.js';
|
|
24
|
+
import { SCROLLBACK_LIMITS } from './limits.js';
|
|
25
|
+
/**
|
|
26
|
+
* The band of a frame that shows no content at all (below the 40x12 floor, or after
|
|
27
|
+
* the writer's tall-frame guard tripped). It is a REAL value rather than `undefined`
|
|
28
|
+
* because `/perf` and the render tests must be able to read the reason on exactly the
|
|
29
|
+
* frames where something went wrong (I-14 / AC-23).
|
|
30
|
+
*/
|
|
31
|
+
function degradedBand(contentCols) {
|
|
32
|
+
return {
|
|
33
|
+
reason: 'short', bandRows: 0, teamRows: 0, previewRows: 0,
|
|
34
|
+
railRows: 0, railCols: 0, previewCols: contentCols, heldRailRows: 0, heldTeamRows: 0,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function buildScrollbackBudget(input) {
|
|
38
|
+
const base = buildFrameBudget(input);
|
|
39
|
+
const contentCols = Math.max(1, base.cols - 1);
|
|
40
|
+
// `liveTailWanted` comes from a measurement, but a caller that never measured passes
|
|
41
|
+
// `undefined`/`NaN`; normalising here keeps `NaN` out of `/perf` and out of the band.
|
|
42
|
+
const previewWanted = Number.isFinite(input.liveTailWanted)
|
|
43
|
+
? Math.max(0, Math.floor(input.liveTailWanted))
|
|
44
|
+
: 0;
|
|
45
|
+
if (base.inactive || input.forceInactive === true) {
|
|
46
|
+
return {
|
|
47
|
+
rows: base.rows, cols: base.cols, contentCols, inactive: true, statusRows: 0,
|
|
48
|
+
composerRows: 0, composerSlotRows: 0, composerCols: base.composerCols, popupRows: 0,
|
|
49
|
+
popupMaxHeight: 0, overlayRows: 0, liveTailRows: 0, topRows: 0,
|
|
50
|
+
liveRows: Math.max(0, Math.min(1, base.rows - 1)),
|
|
51
|
+
band: degradedBand(contentCols), previewCols: contentCols, previewWanted,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// The full-screen frame spends one row on the header; here it joins the pool.
|
|
55
|
+
const remaining = Math.max(0, base.viewportRows + base.headerRows);
|
|
56
|
+
const tailCap = Math.min(SCROLLBACK_LIMITS.maxLiveTailRows, Math.floor(base.rows * SCROLLBACK_LIMITS.liveTailRowShare));
|
|
57
|
+
const band = buildLiveBand({
|
|
58
|
+
...input.band,
|
|
59
|
+
remaining,
|
|
60
|
+
contentCols,
|
|
61
|
+
railCols: todoRailWidth(contentCols),
|
|
62
|
+
previewCap: tailCap,
|
|
63
|
+
previewWanted,
|
|
64
|
+
// Taken from THIS input, never from `input.band`: an overlay that the frame knows
|
|
65
|
+
// about but the band did not would draw no overlay at all, and the shell keys the
|
|
66
|
+
// overlay's rows off the band.
|
|
67
|
+
overlayOpen: input.overlayOpen,
|
|
68
|
+
});
|
|
69
|
+
const overlayRows = band.reason === 'overlay' ? band.bandRows : 0;
|
|
70
|
+
const liveTailRows = band.previewRows;
|
|
71
|
+
const topRows = band.bandRows;
|
|
72
|
+
const liveRows = topRows + base.composerSlotRows + base.statusRows;
|
|
73
|
+
return {
|
|
74
|
+
rows: base.rows, cols: base.cols, contentCols, inactive: false,
|
|
75
|
+
statusRows: base.statusRows, composerRows: base.composerRows,
|
|
76
|
+
composerSlotRows: base.composerSlotRows, composerCols: base.composerCols,
|
|
77
|
+
popupRows: base.popupRows, popupMaxHeight: base.popupMaxHeight, overlayRows,
|
|
78
|
+
liveTailRows, topRows, liveRows,
|
|
79
|
+
band, previewCols: band.previewCols, previewWanted,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas arithmetic for native-scroll mode (tui-canvas-bottom-anchor §5.3).
|
|
3
|
+
*
|
|
4
|
+
* The defect this exists for: the bottom anchor of
|
|
5
|
+
* `tui-bottom-anchored-live-region` pinned the live region to the last two rows by
|
|
6
|
+
* prepending `pad` blank rows to the region's own block, so the blank space of a
|
|
7
|
+
* conversation that does not fill the screen sat BETWEEN the newest printed row and
|
|
8
|
+
* the composer. The user sees a reply that is not attached to the input box.
|
|
9
|
+
*
|
|
10
|
+
* The fix is a position change, not a value change: every row above the parked row
|
|
11
|
+
* becomes a CANVAS the writer owns, laid out top-down as `[gap][content][region]`.
|
|
12
|
+
* The blank space therefore lives ABOVE every visible content row, and the newest
|
|
13
|
+
* content row always touches the top row of the live region. The steady state is
|
|
14
|
+
* unchanged: `gap` equals the old `pad` row for row (the screen holds the same
|
|
15
|
+
* number of content rows either way).
|
|
16
|
+
*
|
|
17
|
+
* EVERYTHING HERE IS PURE: no I/O, no terminal, no clock, no `node:*` import. The
|
|
18
|
+
* writer's state machine decides which measurement is authoritative; this module
|
|
19
|
+
* only does the arithmetic, so the arithmetic can be property-tested over every
|
|
20
|
+
* combination instead of being sampled through a screen.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* How far the canvas may reach and how tall it may be, as far as anything measured.
|
|
24
|
+
*
|
|
25
|
+
* `pinned` carries BOTH numbers because they are not independent: the writer parks
|
|
26
|
+
* the cursor on the last row, so `top + capacity === rows - 1` at all times
|
|
27
|
+
* (invariant LB-3). Storing one and deriving the other is what makes an off-by-one
|
|
28
|
+
* invisible until a blank row lands in the scrollback.
|
|
29
|
+
*/
|
|
30
|
+
export type CanvasAnchor = {
|
|
31
|
+
kind: 'unknown';
|
|
32
|
+
} | {
|
|
33
|
+
kind: 'pinned';
|
|
34
|
+
top: number;
|
|
35
|
+
capacity: number;
|
|
36
|
+
};
|
|
37
|
+
/** Nothing measured yet: the pre-canvas append path, byte for byte (I-A3 / I-A8). */
|
|
38
|
+
export declare const UNKNOWN_CANVAS: CanvasAnchor;
|
|
39
|
+
/** Where a `pinned` anchor came from; `/perf` reports it so a fallback is visible. */
|
|
40
|
+
export type AnchorSource = 'probe' | 'fill' | 'none';
|
|
41
|
+
export interface CanvasLayoutInput {
|
|
42
|
+
/** Rows the canvas may occupy, as measured (`rows - top`). */
|
|
43
|
+
capacity: number;
|
|
44
|
+
/** Canvas top row (0-based). */
|
|
45
|
+
top: number;
|
|
46
|
+
/** Content rows already on screen. */
|
|
47
|
+
heldRows: number;
|
|
48
|
+
/** Content rows arriving in this batch. */
|
|
49
|
+
statRows: number;
|
|
50
|
+
/** Height of the live region in this batch. */
|
|
51
|
+
frameRows: number;
|
|
52
|
+
/** 1 when the batch is a re-seed and starts with its own CRLF, else 0. */
|
|
53
|
+
lead: number;
|
|
54
|
+
}
|
|
55
|
+
/** The whole geometry of one batch, as pure numbers (never strings). */
|
|
56
|
+
export interface CanvasLayout {
|
|
57
|
+
/** Content rows the canvas holds after this batch, i.e. after the trim. */
|
|
58
|
+
heldRows: number;
|
|
59
|
+
/** Blank rows above the content. Skipped with CUD, never written (I-B3). */
|
|
60
|
+
gap: number;
|
|
61
|
+
/** Rows this batch writes past the screen bottom; the terminal scrolls them away. */
|
|
62
|
+
scroll: number;
|
|
63
|
+
/** Content rows dropped from the head because they left the screen for good. */
|
|
64
|
+
dropped: number;
|
|
65
|
+
/** The canvas top after this batch. */
|
|
66
|
+
top: number;
|
|
67
|
+
/** The canvas height after this batch, which is the next erase distance. */
|
|
68
|
+
canvasRows: number;
|
|
69
|
+
}
|
|
70
|
+
/** The capacity above the parked row: `rows - 1`. Degrades to 0, never negative. */
|
|
71
|
+
export declare function screenCapacity(rows: number | undefined): number;
|
|
72
|
+
/**
|
|
73
|
+
* Clamp a measured "rows below the cursor" figure into `[0, rows - 1]`.
|
|
74
|
+
*
|
|
75
|
+
* `rows` unknown is treated as `0`: the caller is then exactly at the bottom, which
|
|
76
|
+
* is the conservative branch (no canvas to open up).
|
|
77
|
+
*/
|
|
78
|
+
export declare function clampSpaceBelow(spaceBelow: number, rows: number | undefined): number;
|
|
79
|
+
/**
|
|
80
|
+
* A terminal that grew adds rows BELOW the cursor, so the capacity grows by the
|
|
81
|
+
* exact difference (D6). Shrinking is NOT handled here: it re-measures instead of
|
|
82
|
+
* guessing (D5), so a smaller value would only hide a lost measurement.
|
|
83
|
+
*/
|
|
84
|
+
export declare function applyResizeGrowth(capacity: number, rowsPrev: number, rowsNow: number): number;
|
|
85
|
+
/**
|
|
86
|
+
* The layout of one batch.
|
|
87
|
+
*
|
|
88
|
+
* `heldRows` / `statRows` / `frameRows` are COUNTS, never strings, so this function
|
|
89
|
+
* can be property-tested over every combination without building a screen.
|
|
90
|
+
*
|
|
91
|
+
* TWO BRANCHES, and the difference is physical rather than cosmetic:
|
|
92
|
+
* - FIT: the content plus the region fits the capacity, so the leftover becomes the
|
|
93
|
+
* `gap` at the TOP of the canvas and nothing scrolls.
|
|
94
|
+
* - SCROLL: the canvas cannot hold everything. The batch writes every content row
|
|
95
|
+
* (the caller passes them untrimmed for exactly this reason), the terminal scrolls
|
|
96
|
+
* `scroll` rows away, and the rows that leave are the oldest content rows plus the
|
|
97
|
+
* host output above the canvas. `dropped` is how many content rows left.
|
|
98
|
+
*/
|
|
99
|
+
export declare function resolveCanvasLayout(input: CanvasLayoutInput): CanvasLayout;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas arithmetic for native-scroll mode (tui-canvas-bottom-anchor §5.3).
|
|
3
|
+
*
|
|
4
|
+
* The defect this exists for: the bottom anchor of
|
|
5
|
+
* `tui-bottom-anchored-live-region` pinned the live region to the last two rows by
|
|
6
|
+
* prepending `pad` blank rows to the region's own block, so the blank space of a
|
|
7
|
+
* conversation that does not fill the screen sat BETWEEN the newest printed row and
|
|
8
|
+
* the composer. The user sees a reply that is not attached to the input box.
|
|
9
|
+
*
|
|
10
|
+
* The fix is a position change, not a value change: every row above the parked row
|
|
11
|
+
* becomes a CANVAS the writer owns, laid out top-down as `[gap][content][region]`.
|
|
12
|
+
* The blank space therefore lives ABOVE every visible content row, and the newest
|
|
13
|
+
* content row always touches the top row of the live region. The steady state is
|
|
14
|
+
* unchanged: `gap` equals the old `pad` row for row (the screen holds the same
|
|
15
|
+
* number of content rows either way).
|
|
16
|
+
*
|
|
17
|
+
* EVERYTHING HERE IS PURE: no I/O, no terminal, no clock, no `node:*` import. The
|
|
18
|
+
* writer's state machine decides which measurement is authoritative; this module
|
|
19
|
+
* only does the arithmetic, so the arithmetic can be property-tested over every
|
|
20
|
+
* combination instead of being sampled through a screen.
|
|
21
|
+
*/
|
|
22
|
+
/** Nothing measured yet: the pre-canvas append path, byte for byte (I-A3 / I-A8). */
|
|
23
|
+
export const UNKNOWN_CANVAS = { kind: 'unknown' };
|
|
24
|
+
/**
|
|
25
|
+
* `Math.floor` with the two answers a measurement must never produce collapsed onto
|
|
26
|
+
* `fallback`: `NaN` and `Infinity`. A `NaN` row count would make `Array(n)` throw and
|
|
27
|
+
* would turn `rows - 1 - n` into a lie, so a broken number degrades to "nothing".
|
|
28
|
+
*/
|
|
29
|
+
function whole(value, fallback) {
|
|
30
|
+
return Number.isFinite(value) ? Math.floor(value) : fallback;
|
|
31
|
+
}
|
|
32
|
+
/** The capacity above the parked row: `rows - 1`. Degrades to 0, never negative. */
|
|
33
|
+
export function screenCapacity(rows) {
|
|
34
|
+
if (rows === undefined || !Number.isFinite(rows))
|
|
35
|
+
return 0;
|
|
36
|
+
return Math.max(0, Math.floor(rows) - 1);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Clamp a measured "rows below the cursor" figure into `[0, rows - 1]`.
|
|
40
|
+
*
|
|
41
|
+
* `rows` unknown is treated as `0`: the caller is then exactly at the bottom, which
|
|
42
|
+
* is the conservative branch (no canvas to open up).
|
|
43
|
+
*/
|
|
44
|
+
export function clampSpaceBelow(spaceBelow, rows) {
|
|
45
|
+
const upper = screenCapacity(rows);
|
|
46
|
+
const value = Number.isFinite(spaceBelow) ? Math.floor(spaceBelow) : 0;
|
|
47
|
+
return Math.min(upper, Math.max(0, value));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A terminal that grew adds rows BELOW the cursor, so the capacity grows by the
|
|
51
|
+
* exact difference (D6). Shrinking is NOT handled here: it re-measures instead of
|
|
52
|
+
* guessing (D5), so a smaller value would only hide a lost measurement.
|
|
53
|
+
*/
|
|
54
|
+
export function applyResizeGrowth(capacity, rowsPrev, rowsNow) {
|
|
55
|
+
// No capacity to grow: a broken number must not become a canvas by arithmetic.
|
|
56
|
+
if (!Number.isFinite(capacity))
|
|
57
|
+
return 0;
|
|
58
|
+
const base = Math.max(0, Math.floor(capacity));
|
|
59
|
+
const delta = Number.isFinite(rowsNow) && Number.isFinite(rowsPrev) ? rowsNow - rowsPrev : 0;
|
|
60
|
+
return base + Math.max(0, Math.floor(delta));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The layout of one batch.
|
|
64
|
+
*
|
|
65
|
+
* `heldRows` / `statRows` / `frameRows` are COUNTS, never strings, so this function
|
|
66
|
+
* can be property-tested over every combination without building a screen.
|
|
67
|
+
*
|
|
68
|
+
* TWO BRANCHES, and the difference is physical rather than cosmetic:
|
|
69
|
+
* - FIT: the content plus the region fits the capacity, so the leftover becomes the
|
|
70
|
+
* `gap` at the TOP of the canvas and nothing scrolls.
|
|
71
|
+
* - SCROLL: the canvas cannot hold everything. The batch writes every content row
|
|
72
|
+
* (the caller passes them untrimmed for exactly this reason), the terminal scrolls
|
|
73
|
+
* `scroll` rows away, and the rows that leave are the oldest content rows plus the
|
|
74
|
+
* host output above the canvas. `dropped` is how many content rows left.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveCanvasLayout(input) {
|
|
77
|
+
// A re-seed writes its own leading CRLF, which eats one row of the space below the
|
|
78
|
+
// cursor and starts the canvas one row lower (P1-1 in the canvas model).
|
|
79
|
+
const lead = Math.max(0, whole(input.lead, 0));
|
|
80
|
+
const capacity = Math.max(0, whole(input.capacity, 0) - lead);
|
|
81
|
+
const held = Math.max(0, whole(input.heldRows, 0));
|
|
82
|
+
const stat = Math.max(0, whole(input.statRows, 0));
|
|
83
|
+
const frame = Math.max(0, whole(input.frameRows, 0));
|
|
84
|
+
const top = Math.max(0, whole(input.top, 0)) + lead;
|
|
85
|
+
const content = held + stat;
|
|
86
|
+
const need = content + frame;
|
|
87
|
+
if (need <= capacity) {
|
|
88
|
+
return {
|
|
89
|
+
heldRows: content,
|
|
90
|
+
gap: capacity - need,
|
|
91
|
+
scroll: 0,
|
|
92
|
+
dropped: 0,
|
|
93
|
+
top,
|
|
94
|
+
canvasRows: capacity,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const scroll = need - capacity;
|
|
98
|
+
// Rows above the canvas are the host's old output: they leave the screen first.
|
|
99
|
+
const hostUsed = Math.min(scroll, top);
|
|
100
|
+
const grown = capacity + hostUsed;
|
|
101
|
+
const dropped = Math.min(scroll - hostUsed, content);
|
|
102
|
+
const keptContent = content - dropped;
|
|
103
|
+
return {
|
|
104
|
+
heldRows: keptContent,
|
|
105
|
+
// Zero whenever anything scrolled, and that is what keeps blank rows out of the
|
|
106
|
+
// scrollback: a skipped gap is un-written cells, a scrolled one is a written row.
|
|
107
|
+
gap: Math.max(0, grown - keptContent - frame),
|
|
108
|
+
scroll,
|
|
109
|
+
dropped,
|
|
110
|
+
top: top - hostUsed,
|
|
111
|
+
canvasRows: grown,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor-position probe — the one measurement the bottom anchor cannot do without
|
|
3
|
+
* (tui-bottom-anchored-live-region §4.4 / §6.3).
|
|
4
|
+
*
|
|
5
|
+
* WHY IT EXISTS. "How many rows are left below the cursor" cannot be derived from
|
|
6
|
+
* anything the process already knows: `CUD` clamps silently at the last row and
|
|
7
|
+
* reports nothing, and the only other way to reach the bottom is to print `rows - 1`
|
|
8
|
+
* newlines, which scrolls the user's screen. `ESC[6n` asks instead of guessing, and
|
|
9
|
+
* every target terminal answers it — Windows Terminal, conhost through ConPTY, tmux,
|
|
10
|
+
* screen, xterm/vte/alacritty/kitty/wezterm/foot/ghostty, the VS Code terminal and an
|
|
11
|
+
* SSH session's far end.
|
|
12
|
+
*
|
|
13
|
+
* WHAT IT DOES NOT DO:
|
|
14
|
+
* - It does not touch raw mode. Raising raw is the HOST's job and must happen BEFORE
|
|
15
|
+
* the query (P0-1 / I-A10): in canonical mode the reply is line-buffered until a
|
|
16
|
+
* newline and the terminal ECHOES it as `^[[12;1R` onto the screen, so the probe
|
|
17
|
+
* would time out forever on Linux/macOS while printing garbage.
|
|
18
|
+
* - It does not consume input. The filter ahead of Ink owns every byte; this module
|
|
19
|
+
* is a second `'data'` listener that never `pause()`s, `read()`s or `unshift()`s
|
|
20
|
+
* (D5 / R2).
|
|
21
|
+
* - It does not retry. One query, one bounded wait, then the caller degrades.
|
|
22
|
+
*
|
|
23
|
+
* FAILURE IS NORMAL, NOT AN ERROR: a terminal that never answers is a terminal that
|
|
24
|
+
* gets the one-off screen fill instead (rung 2 of the ladder). Nothing here throws,
|
|
25
|
+
* and nothing here can keep the process alive: the timer is `unref()`ed and the
|
|
26
|
+
* listener is always removed.
|
|
27
|
+
*/
|
|
28
|
+
export interface CursorProbeOptions {
|
|
29
|
+
/** The REAL stdin — not the filter's PassThrough, and not Ink's stream. */
|
|
30
|
+
stdin: NodeJS.ReadStream;
|
|
31
|
+
stdout: NodeJS.WriteStream;
|
|
32
|
+
/** Terminal height, read at answer time so a mid-wait resize is not fatal. */
|
|
33
|
+
rows: () => number | undefined;
|
|
34
|
+
/** Overrides `SCROLLBACK_LIMITS.anchorProbeTimeoutMs` (tests). */
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Keep the event loop alive while waiting for the answer.
|
|
38
|
+
*
|
|
39
|
+
* THE STARTUP PROBE MUST SET THIS, and the reason is measured rather than
|
|
40
|
+
* theoretical: an `unref()`ed timer is invisible to the event loop, so on a session
|
|
41
|
+
* where nothing is referenced yet — stdin already at EOF, a piped stdin, a console
|
|
42
|
+
* whose reader is paused — the process can exit BEFORE the first frame is ever
|
|
43
|
+
* drawn. `screen.test.ts`'s subprocess probe caught exactly that: exit code 0,
|
|
44
|
+
* `ESC[6n` on screen and no application at all.
|
|
45
|
+
*
|
|
46
|
+
* Background re-probes keep the default (`false`): a teardown must never be
|
|
47
|
+
* delayed, and by then the UI itself is holding the loop.
|
|
48
|
+
*/
|
|
49
|
+
holdProcess?: boolean;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Arm or disarm the switch. `true` is permanent for the process: there is no
|
|
53
|
+
* legitimate reason to probe after the screen has been handed back.
|
|
54
|
+
*/
|
|
55
|
+
export declare function setAnchorProbeDisabled(value: boolean): void;
|
|
56
|
+
/** Test hook — the switch is process-wide by design, so tests must reset it. */
|
|
57
|
+
export declare function resetAnchorProbeForTests(): void;
|
|
58
|
+
/**
|
|
59
|
+
* Ask the terminal where the cursor is and return the rows left below it
|
|
60
|
+
* (`0` = already on the last row). Returns `null` for every failure mode:
|
|
61
|
+
*
|
|
62
|
+
* | Situation | Result |
|
|
63
|
+
* |----------------------------------|---------------------------------|
|
|
64
|
+
* | switches off / not a TTY / rows unknown | `null`, synchronously, zero bytes |
|
|
65
|
+
* | terminal answers | `rows - 1 - (row - 1)` |
|
|
66
|
+
* | timeout (`anchorProbeTimeoutMs`) | `null`, timer and listener removed |
|
|
67
|
+
* | concurrent call | the same promise; one query only |
|
|
68
|
+
* | `stdout.write` throws | `null` |
|
|
69
|
+
* | a late reply after the timeout | ignored here; the filter strips it |
|
|
70
|
+
*/
|
|
71
|
+
export declare function probeAnchorSpaceBelow(o: CursorProbeOptions): Promise<number | null>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor-position probe — the one measurement the bottom anchor cannot do without
|
|
3
|
+
* (tui-bottom-anchored-live-region §4.4 / §6.3).
|
|
4
|
+
*
|
|
5
|
+
* WHY IT EXISTS. "How many rows are left below the cursor" cannot be derived from
|
|
6
|
+
* anything the process already knows: `CUD` clamps silently at the last row and
|
|
7
|
+
* reports nothing, and the only other way to reach the bottom is to print `rows - 1`
|
|
8
|
+
* newlines, which scrolls the user's screen. `ESC[6n` asks instead of guessing, and
|
|
9
|
+
* every target terminal answers it — Windows Terminal, conhost through ConPTY, tmux,
|
|
10
|
+
* screen, xterm/vte/alacritty/kitty/wezterm/foot/ghostty, the VS Code terminal and an
|
|
11
|
+
* SSH session's far end.
|
|
12
|
+
*
|
|
13
|
+
* WHAT IT DOES NOT DO:
|
|
14
|
+
* - It does not touch raw mode. Raising raw is the HOST's job and must happen BEFORE
|
|
15
|
+
* the query (P0-1 / I-A10): in canonical mode the reply is line-buffered until a
|
|
16
|
+
* newline and the terminal ECHOES it as `^[[12;1R` onto the screen, so the probe
|
|
17
|
+
* would time out forever on Linux/macOS while printing garbage.
|
|
18
|
+
* - It does not consume input. The filter ahead of Ink owns every byte; this module
|
|
19
|
+
* is a second `'data'` listener that never `pause()`s, `read()`s or `unshift()`s
|
|
20
|
+
* (D5 / R2).
|
|
21
|
+
* - It does not retry. One query, one bounded wait, then the caller degrades.
|
|
22
|
+
*
|
|
23
|
+
* FAILURE IS NORMAL, NOT AN ERROR: a terminal that never answers is a terminal that
|
|
24
|
+
* gets the one-off screen fill instead (rung 2 of the ladder). Nothing here throws,
|
|
25
|
+
* and nothing here can keep the process alive: the timer is `unref()`ed and the
|
|
26
|
+
* listener is always removed.
|
|
27
|
+
*/
|
|
28
|
+
import { SCROLLBACK_LIMITS } from './limits.js';
|
|
29
|
+
import { formatCursorReportQuery, spaceBelowFromReport, splitCursorReports, } from '../../input/cursor-report.js';
|
|
30
|
+
/** Longest acceptable report prefix we buffer before giving up on the chunk. */
|
|
31
|
+
const MAX_BUFFER_CHARS = 64;
|
|
32
|
+
/**
|
|
33
|
+
* THE DEATH SWITCH (I-A9 / P1-6).
|
|
34
|
+
*
|
|
35
|
+
* Once the session is over — `finalizeNativeScreen()`, `disposeFrameWriter()`, a
|
|
36
|
+
* disposed filter, the process `exit` hook — a query must never reach the terminal
|
|
37
|
+
* again. It would be written at a terminal already handed back to the shell, where
|
|
38
|
+
* nothing strips the reply, and the user's prompt would gain a literal `^[[12;1R`.
|
|
39
|
+
* The flag lives at module level because `cli.tsx`'s four exit paths converge on one
|
|
40
|
+
* call and must not each own a copy of the state.
|
|
41
|
+
*/
|
|
42
|
+
let disabled = false;
|
|
43
|
+
/** Single-flight: a drag can ask twice inside one settle window. */
|
|
44
|
+
let inFlight = null;
|
|
45
|
+
/**
|
|
46
|
+
* Arm or disarm the switch. `true` is permanent for the process: there is no
|
|
47
|
+
* legitimate reason to probe after the screen has been handed back.
|
|
48
|
+
*/
|
|
49
|
+
export function setAnchorProbeDisabled(value) {
|
|
50
|
+
disabled = value;
|
|
51
|
+
if (value)
|
|
52
|
+
inFlight = null;
|
|
53
|
+
}
|
|
54
|
+
/** Test hook — the switch is process-wide by design, so tests must reset it. */
|
|
55
|
+
export function resetAnchorProbeForTests() {
|
|
56
|
+
disabled = false;
|
|
57
|
+
inFlight = null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Ask the terminal where the cursor is and return the rows left below it
|
|
61
|
+
* (`0` = already on the last row). Returns `null` for every failure mode:
|
|
62
|
+
*
|
|
63
|
+
* | Situation | Result |
|
|
64
|
+
* |----------------------------------|---------------------------------|
|
|
65
|
+
* | switches off / not a TTY / rows unknown | `null`, synchronously, zero bytes |
|
|
66
|
+
* | terminal answers | `rows - 1 - (row - 1)` |
|
|
67
|
+
* | timeout (`anchorProbeTimeoutMs`) | `null`, timer and listener removed |
|
|
68
|
+
* | concurrent call | the same promise; one query only |
|
|
69
|
+
* | `stdout.write` throws | `null` |
|
|
70
|
+
* | a late reply after the timeout | ignored here; the filter strips it |
|
|
71
|
+
*/
|
|
72
|
+
export function probeAnchorSpaceBelow(o) {
|
|
73
|
+
if (disabled)
|
|
74
|
+
return Promise.resolve(null);
|
|
75
|
+
if (!o.stdin.isTTY || !o.stdout.isTTY)
|
|
76
|
+
return Promise.resolve(null);
|
|
77
|
+
if (inFlight)
|
|
78
|
+
return inFlight;
|
|
79
|
+
const rowsNow = o.rows();
|
|
80
|
+
if (rowsNow === undefined || !Number.isFinite(rowsNow) || rowsNow < 1) {
|
|
81
|
+
return Promise.resolve(null);
|
|
82
|
+
}
|
|
83
|
+
const promise = new Promise((resolve) => {
|
|
84
|
+
const timeoutMs = o.timeoutMs ?? SCROLLBACK_LIMITS.anchorProbeTimeoutMs;
|
|
85
|
+
let buffer = '';
|
|
86
|
+
let settled = false;
|
|
87
|
+
let timer = null;
|
|
88
|
+
const finish = (value) => {
|
|
89
|
+
if (settled)
|
|
90
|
+
return;
|
|
91
|
+
settled = true;
|
|
92
|
+
if (timer) {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
timer = null;
|
|
95
|
+
}
|
|
96
|
+
o.stdin.removeListener('data', onData);
|
|
97
|
+
resolve(value);
|
|
98
|
+
};
|
|
99
|
+
function onData(chunk) {
|
|
100
|
+
if (settled)
|
|
101
|
+
return;
|
|
102
|
+
buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
103
|
+
if (buffer.length > MAX_BUFFER_CHARS)
|
|
104
|
+
buffer = buffer.slice(-MAX_BUFFER_CHARS);
|
|
105
|
+
const split = splitCursorReports(buffer);
|
|
106
|
+
const report = split.reports[0];
|
|
107
|
+
if (!report)
|
|
108
|
+
return;
|
|
109
|
+
finish(spaceBelowFromReport(report, o.rows() ?? undefined));
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
o.stdout.write(formatCursorReportQuery());
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// A closed or broken stdout is an ordinary failure, not a crash on startup.
|
|
116
|
+
finish(null);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
o.stdin.on('data', onData);
|
|
120
|
+
timer = setTimeout(() => finish(null), Math.max(1, Math.floor(timeoutMs)));
|
|
121
|
+
// `unref()` by default (see `holdProcess`), so a stray re-probe can never keep a
|
|
122
|
+
// teardown alive. The startup probe opts back in: there it is the ONLY thing
|
|
123
|
+
// holding the loop, and losing the session to save 150 ms is not a trade.
|
|
124
|
+
if (!o.holdProcess)
|
|
125
|
+
timer.unref?.();
|
|
126
|
+
});
|
|
127
|
+
inFlight = promise;
|
|
128
|
+
void promise.then(() => {
|
|
129
|
+
if (inFlight === promise)
|
|
130
|
+
inFlight = null;
|
|
131
|
+
});
|
|
132
|
+
return promise;
|
|
133
|
+
}
|
|
@@ -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
|
+
}
|