@prometheus-ai/tui 0.5.0

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 (65) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +704 -0
  3. package/dist/types/autocomplete.d.ts +76 -0
  4. package/dist/types/bracketed-paste.d.ts +26 -0
  5. package/dist/types/components/box.d.ts +17 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +105 -0
  8. package/dist/types/components/image.d.ts +84 -0
  9. package/dist/types/components/input.d.ts +18 -0
  10. package/dist/types/components/loader.d.ts +13 -0
  11. package/dist/types/components/markdown.d.ts +61 -0
  12. package/dist/types/components/scroll-view.d.ts +40 -0
  13. package/dist/types/components/select-list.d.ts +48 -0
  14. package/dist/types/components/settings-list.d.ts +41 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +56 -0
  17. package/dist/types/components/text.d.ts +13 -0
  18. package/dist/types/components/truncated-text.d.ts +10 -0
  19. package/dist/types/deccara.d.ts +49 -0
  20. package/dist/types/editor-component.d.ts +36 -0
  21. package/dist/types/fuzzy.d.ts +15 -0
  22. package/dist/types/index.d.ts +28 -0
  23. package/dist/types/keybindings.d.ts +189 -0
  24. package/dist/types/keys.d.ts +208 -0
  25. package/dist/types/kill-ring.d.ts +27 -0
  26. package/dist/types/kitty-graphics.d.ts +94 -0
  27. package/dist/types/stdin-buffer.d.ts +43 -0
  28. package/dist/types/symbols.d.ts +25 -0
  29. package/dist/types/terminal-capabilities.d.ts +196 -0
  30. package/dist/types/terminal.d.ts +103 -0
  31. package/dist/types/ttyid.d.ts +9 -0
  32. package/dist/types/tui.d.ts +275 -0
  33. package/dist/types/utils.d.ts +89 -0
  34. package/package.json +73 -0
  35. package/src/autocomplete.ts +871 -0
  36. package/src/bracketed-paste.ts +47 -0
  37. package/src/components/box.ts +156 -0
  38. package/src/components/cancellable-loader.ts +40 -0
  39. package/src/components/editor.ts +2695 -0
  40. package/src/components/image.ts +318 -0
  41. package/src/components/input.ts +459 -0
  42. package/src/components/loader.ts +86 -0
  43. package/src/components/markdown.ts +1189 -0
  44. package/src/components/scroll-view.ts +166 -0
  45. package/src/components/select-list.ts +331 -0
  46. package/src/components/settings-list.ts +212 -0
  47. package/src/components/spacer.ts +28 -0
  48. package/src/components/tab-bar.ts +175 -0
  49. package/src/components/text.ts +110 -0
  50. package/src/components/truncated-text.ts +61 -0
  51. package/src/deccara.ts +314 -0
  52. package/src/editor-component.ts +71 -0
  53. package/src/fuzzy.ts +143 -0
  54. package/src/index.ts +44 -0
  55. package/src/keybindings.ts +279 -0
  56. package/src/keys.ts +537 -0
  57. package/src/kill-ring.ts +46 -0
  58. package/src/kitty-graphics.ts +270 -0
  59. package/src/stdin-buffer.ts +423 -0
  60. package/src/symbols.ts +26 -0
  61. package/src/terminal-capabilities.ts +1009 -0
  62. package/src/terminal.ts +1114 -0
  63. package/src/ttyid.ts +70 -0
  64. package/src/tui.ts +2988 -0
  65. package/src/utils.ts +452 -0
@@ -0,0 +1,28 @@
1
+ import type { Component } from "../tui";
2
+
3
+ /**
4
+ * Spacer component that renders empty lines
5
+ */
6
+ export class Spacer implements Component {
7
+ #lines: number;
8
+
9
+ constructor(lines: number = 1) {
10
+ this.#lines = lines;
11
+ }
12
+
13
+ setLines(lines: number): void {
14
+ this.#lines = lines;
15
+ }
16
+
17
+ invalidate(): void {
18
+ // No cached state to invalidate currently
19
+ }
20
+
21
+ render(_width: number): string[] {
22
+ const result: string[] = [];
23
+ for (let i = 0; i < this.#lines; i++) {
24
+ result.push("");
25
+ }
26
+ return result;
27
+ }
28
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Tab Bar Component
3
+ *
4
+ * A horizontal tab bar for switching between views/panels.
5
+ * Renders as: "Label: Tab1 Tab2 Tab3 (tab to cycle)"
6
+ *
7
+ * Navigation:
8
+ * - Tab / Arrow Right: Next tab (wraps around)
9
+ * - Shift+Tab / Arrow Left: Previous tab (wraps around)
10
+ */
11
+ import { matchesKey } from "../keys";
12
+ import type { Component } from "../tui";
13
+ import { truncateToWidth, visibleWidth } from "../utils";
14
+
15
+ /** Tab definition */
16
+ export interface Tab {
17
+ /** Unique identifier for the tab */
18
+ id: string;
19
+ /** Display label shown in the tab bar */
20
+ label: string;
21
+ }
22
+
23
+ /** Theme for styling the tab bar */
24
+ export interface TabBarTheme {
25
+ /** Style for the label prefix (e.g., "Settings:") */
26
+ label: (text: string) => string;
27
+ /** Style for the currently active tab */
28
+ activeTab: (text: string) => string;
29
+ /** Style for inactive tabs */
30
+ inactiveTab: (text: string) => string;
31
+ /** Style for the hint text (e.g., "(tab to cycle)") */
32
+ hint: (text: string) => string;
33
+ }
34
+
35
+ /**
36
+ * Horizontal tab bar component.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * const tabs = [
41
+ * { id: "config", label: "Config" },
42
+ * { id: "tools", label: "Tools" },
43
+ * ];
44
+ * const tabBar = new TabBar("Settings", tabs, theme);
45
+ * tabBar.onTabChange = (tab) => console.log(`Switched to ${tab.id}`);
46
+ * ```
47
+ */
48
+ export class TabBar implements Component {
49
+ #tabs: Tab[];
50
+ #activeIndex: number = 0;
51
+ #theme: TabBarTheme;
52
+ #label: string;
53
+
54
+ /** Callback fired when the active tab changes */
55
+ onTabChange?: (tab: Tab, index: number) => void;
56
+
57
+ constructor(label: string, tabs: Tab[], theme: TabBarTheme, initialIndex: number = 0) {
58
+ this.#label = label;
59
+ this.#tabs = tabs;
60
+ this.#theme = theme;
61
+ this.#activeIndex = initialIndex;
62
+ }
63
+
64
+ /** Get the currently active tab */
65
+ getActiveTab(): Tab {
66
+ return this.#tabs[this.#activeIndex];
67
+ }
68
+
69
+ /** Get the index of the currently active tab */
70
+ getActiveIndex(): number {
71
+ return this.#activeIndex;
72
+ }
73
+
74
+ /** Set the active tab by index (clamped to valid range) */
75
+ setActiveIndex(index: number): void {
76
+ const newIndex = Math.max(0, Math.min(index, this.#tabs.length - 1));
77
+ if (newIndex !== this.#activeIndex) {
78
+ this.#activeIndex = newIndex;
79
+ this.onTabChange?.(this.#tabs[this.#activeIndex], this.#activeIndex);
80
+ }
81
+ }
82
+
83
+ /** Move to the next tab (wraps to first tab after last) */
84
+ nextTab(): void {
85
+ this.setActiveIndex((this.#activeIndex + 1) % this.#tabs.length);
86
+ }
87
+
88
+ /** Move to the previous tab (wraps to last tab before first) */
89
+ prevTab(): void {
90
+ this.setActiveIndex((this.#activeIndex - 1 + this.#tabs.length) % this.#tabs.length);
91
+ }
92
+
93
+ invalidate(): void {
94
+ // No cached state to invalidate
95
+ }
96
+
97
+ /**
98
+ * Handle keyboard input for tab navigation.
99
+ * @returns true if the input was handled, false otherwise
100
+ */
101
+ handleInput(data: string): boolean {
102
+ if (matchesKey(data, "tab") || matchesKey(data, "right")) {
103
+ this.nextTab();
104
+ return true;
105
+ }
106
+ if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
107
+ this.prevTab();
108
+ return true;
109
+ }
110
+ return false;
111
+ }
112
+
113
+ /** Render the tab bar, wrapping to multiple lines if needed */
114
+ render(width: number): string[] {
115
+ const maxWidth = Math.max(1, width);
116
+ const chunks: string[] = [];
117
+
118
+ // Label prefix
119
+ chunks.push(this.#theme.label(`${this.#label}:`));
120
+ chunks.push(" ");
121
+
122
+ // Tab buttons
123
+ for (let i = 0; i < this.#tabs.length; i++) {
124
+ const tab = this.#tabs[i];
125
+ if (i === this.#activeIndex) {
126
+ chunks.push(this.#theme.activeTab(` ${tab.label} `));
127
+ } else {
128
+ chunks.push(this.#theme.inactiveTab(` ${tab.label} `));
129
+ }
130
+ if (i < this.#tabs.length - 1) {
131
+ chunks.push(" ");
132
+ }
133
+ }
134
+
135
+ // Navigation hint
136
+ chunks.push(" ");
137
+ chunks.push(this.#theme.hint("(tab to cycle)"));
138
+
139
+ const lines: string[] = [];
140
+ let currentLine = "";
141
+ let currentWidth = 0;
142
+
143
+ for (const chunk of chunks) {
144
+ const chunkWidth = visibleWidth(chunk);
145
+ if (chunkWidth <= 0) {
146
+ continue;
147
+ }
148
+
149
+ if (chunkWidth > maxWidth) {
150
+ if (currentLine) {
151
+ lines.push(currentLine);
152
+ currentLine = "";
153
+ currentWidth = 0;
154
+ }
155
+ lines.push(truncateToWidth(chunk, maxWidth));
156
+ continue;
157
+ }
158
+
159
+ if (currentWidth > 0 && currentWidth + chunkWidth > maxWidth) {
160
+ lines.push(currentLine);
161
+ currentLine = "";
162
+ currentWidth = 0;
163
+ }
164
+
165
+ currentLine += chunk;
166
+ currentWidth += chunkWidth;
167
+ }
168
+
169
+ if (currentLine) {
170
+ lines.push(currentLine);
171
+ }
172
+
173
+ return lines.length > 0 ? lines : [""];
174
+ }
175
+ }
@@ -0,0 +1,110 @@
1
+ import type { Component } from "../tui";
2
+ import { applyBackgroundToLine, padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils";
3
+
4
+ /**
5
+ * Text component - displays multi-line text with word wrapping
6
+ */
7
+ export class Text implements Component {
8
+ #text: string;
9
+ #paddingX: number; // Left/right padding
10
+ #paddingY: number; // Top/bottom padding
11
+ #customBgFn?: (text: string) => string;
12
+
13
+ // Cache for rendered output
14
+ #cachedText?: string;
15
+ #cachedWidth?: number;
16
+ #cachedLines?: string[];
17
+
18
+ constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) {
19
+ this.#text = text;
20
+ this.#paddingX = paddingX;
21
+ this.#paddingY = paddingY;
22
+ this.#customBgFn = customBgFn;
23
+ }
24
+
25
+ getText(): string {
26
+ return this.#text;
27
+ }
28
+
29
+ setText(text: string): void {
30
+ this.#text = text;
31
+ this.#cachedText = undefined;
32
+ this.#cachedWidth = undefined;
33
+ this.#cachedLines = undefined;
34
+ }
35
+
36
+ setCustomBgFn(customBgFn?: (text: string) => string): void {
37
+ this.#customBgFn = customBgFn;
38
+ this.#cachedText = undefined;
39
+ this.#cachedWidth = undefined;
40
+ this.#cachedLines = undefined;
41
+ }
42
+
43
+ invalidate(): void {
44
+ this.#cachedText = undefined;
45
+ this.#cachedWidth = undefined;
46
+ this.#cachedLines = undefined;
47
+ }
48
+
49
+ render(width: number): string[] {
50
+ // Check cache
51
+ if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
52
+ return this.#cachedLines;
53
+ }
54
+
55
+ // Don't render anything if there's no actual text
56
+ if (!this.#text || this.#text.trim() === "") {
57
+ const result: string[] = [];
58
+ this.#cachedText = this.#text;
59
+ this.#cachedWidth = width;
60
+ this.#cachedLines = result;
61
+ return result;
62
+ }
63
+
64
+ // Replace tabs with 3 spaces
65
+ const normalizedText = replaceTabs(this.#text);
66
+
67
+ // Calculate content width (subtract left/right margins)
68
+ const contentWidth = Math.max(1, width - this.#paddingX * 2);
69
+
70
+ // Wrap text (this preserves ANSI codes but does NOT pad)
71
+ const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth);
72
+
73
+ // Add margins and background to each line
74
+ const leftMargin = padding(this.#paddingX);
75
+ const rightMargin = padding(this.#paddingX);
76
+ const contentLines: string[] = [];
77
+
78
+ for (const line of wrappedLines) {
79
+ // Add margins
80
+ const lineWithMargins = leftMargin + line + rightMargin;
81
+
82
+ // Apply background if specified (this also pads to full width)
83
+ if (this.#customBgFn) {
84
+ contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.#customBgFn));
85
+ } else {
86
+ // No background - just pad to width with spaces
87
+ const visibleLen = visibleWidth(lineWithMargins);
88
+ const paddingNeeded = Math.max(0, width - visibleLen);
89
+ contentLines.push(lineWithMargins + padding(paddingNeeded));
90
+ }
91
+ }
92
+
93
+ // Add top/bottom padding (empty lines)
94
+ const emptyLine = padding(width);
95
+ const emptyLines: string[] = [];
96
+ for (let i = 0; i < this.#paddingY; i++) {
97
+ const line = this.#customBgFn ? applyBackgroundToLine(emptyLine, width, this.#customBgFn) : emptyLine;
98
+ emptyLines.push(line);
99
+ }
100
+
101
+ const result = [...emptyLines, ...contentLines, ...emptyLines];
102
+
103
+ // Update cache
104
+ this.#cachedText = this.#text;
105
+ this.#cachedWidth = width;
106
+ this.#cachedLines = result;
107
+
108
+ return result.length > 0 ? result : [""];
109
+ }
110
+ }
@@ -0,0 +1,61 @@
1
+ import type { Component } from "../tui";
2
+ import { padding, truncateToWidth } from "../utils";
3
+
4
+ /**
5
+ * Text component that truncates to fit viewport width
6
+ */
7
+ export class TruncatedText implements Component {
8
+ #text: string;
9
+ #paddingX: number;
10
+ #paddingY: number;
11
+
12
+ constructor(text: string, paddingX: number = 0, paddingY: number = 0) {
13
+ this.#text = text;
14
+ this.#paddingX = paddingX;
15
+ this.#paddingY = paddingY;
16
+ }
17
+
18
+ invalidate(): void {
19
+ // No cached state to invalidate currently
20
+ }
21
+
22
+ render(width: number): string[] {
23
+ const result: string[] = [];
24
+
25
+ // Empty line padded to width
26
+ const emptyLine = padding(width);
27
+
28
+ // Add vertical padding above
29
+ for (let i = 0; i < this.#paddingY; i++) {
30
+ result.push(emptyLine);
31
+ }
32
+
33
+ // Calculate available width after horizontal padding
34
+ const availableWidth = Math.max(1, width - this.#paddingX * 2);
35
+
36
+ // Take only the first line (stop at newline)
37
+ let singleLineText = this.#text;
38
+ const newlineIndex = this.#text.indexOf("\n");
39
+ if (newlineIndex !== -1) {
40
+ singleLineText = this.#text.substring(0, newlineIndex);
41
+ }
42
+
43
+ // Truncate text if needed (accounting for ANSI codes)
44
+ const displayText = truncateToWidth(singleLineText, availableWidth);
45
+
46
+ // Add horizontal padding
47
+ const leftPadding = padding(this.#paddingX);
48
+ const rightPadding = padding(this.#paddingX);
49
+ const lineWithPadding = leftPadding + displayText + rightPadding;
50
+
51
+ // Don't pad to full width - avoids trailing spaces when copying
52
+ result.push(lineWithPadding);
53
+
54
+ // Add vertical padding below
55
+ for (let i = 0; i < this.#paddingY; i++) {
56
+ result.push(emptyLine);
57
+ }
58
+
59
+ return result;
60
+ }
61
+ }
package/src/deccara.ts ADDED
@@ -0,0 +1,314 @@
1
+ /**
2
+ * DECCARA rectangular-SGR background-fill optimizer.
3
+ *
4
+ * Kitty extends VT510 DECCARA ("Change Attributes in Rectangular Area") to all
5
+ * SGR attributes, including background color, so a solid background panel can be
6
+ * painted as a single rectangle escape instead of a full-width run of
7
+ * background-styled spaces on every row (see kitty `docs/deccara.rst`):
8
+ *
9
+ * <ESC>[2*x DECSACE: select rectangle change extent
10
+ * <ESC>[Pt;Pl;Pb;Pr;<sgr>$r DECCARA: apply <sgr> to rows Pt..Pb, cols Pl..Pr
11
+ * <ESC>[*x DECSACE: restore default extent
12
+ *
13
+ * Coordinates are 1-based and inclusive. This module is a pure, renderer-level
14
+ * planner: it consumes the *final* ANSI strings the renderer would otherwise
15
+ * write, strips the trailing background-padded spaces it can prove are safe to
16
+ * drop, and returns the rectangles to emit in their place. It never mutates
17
+ * component output and never decides which rows are scrollback-bound — those
18
+ * concerns belong to the caller in `tui.ts`.
19
+ */
20
+ import { visibleWidth } from "./utils";
21
+
22
+ /** Reset every attribute (SGR 0). Mirrors `tui.ts`'s per-line terminator. */
23
+ const SEGMENT_RESET = "\x1b[0m";
24
+
25
+ /** DECSACE — select the rectangle change extent so DECCARA fills a rectangle. */
26
+ export const DECSACE_RECT = "\x1b[2*x";
27
+ /** DECSACE — restore the default (stream) change extent. */
28
+ export const DECSACE_DEFAULT = "\x1b[*x";
29
+
30
+ /**
31
+ * Byte cost of the per-frame DECSACE wrapper ({@link DECSACE_RECT} +
32
+ * {@link DECSACE_DEFAULT}) that brackets every rectangle batch. Charged once per
33
+ * frame: a plan is emitted only when the trailing-space bytes it removes exceed
34
+ * the rectangles' own bytes by more than this, so the optimizer never inflates.
35
+ */
36
+ const DECSACE_WRAPPER_BYTES = DECSACE_RECT.length + DECSACE_DEFAULT.length;
37
+
38
+ /**
39
+ * Encode a single DECCARA rectangle. `top`/`bottom` are 1-based inclusive screen
40
+ * rows, `left`/`right` 1-based inclusive columns, `sgr` the raw SGR parameter
41
+ * list to apply (e.g. `48;2;10;20;30`, `48;5;4`, `41`).
42
+ */
43
+ export function encodeDeccara(top: number, left: number, bottom: number, right: number, sgr: string): string {
44
+ return `\x1b[${top};${left};${bottom};${right};${sgr}$r`;
45
+ }
46
+
47
+ /** Sentinel for a background form this optimizer refuses to reason about. */
48
+ const BAIL = Symbol("deccara-bail");
49
+ type BgState = string | null;
50
+
51
+ /**
52
+ * Fold one SGR parameter list into the active background-color parameter string.
53
+ * Returns the new background (`null` = default/no background) or {@link BAIL}
54
+ * when the sequence contains a background form this optimizer will not reason
55
+ * about (colon-form extended color, malformed params). Foreground and style
56
+ * parameters are skipped; only background state is tracked.
57
+ */
58
+ function nextBackground(bg: BgState, params: string): BgState | typeof BAIL {
59
+ // CSI m with no parameters is SGR 0 (reset everything).
60
+ if (params.length === 0) return null;
61
+ const tokens = params.split(";");
62
+ let result: BgState = bg;
63
+ for (let i = 0; i < tokens.length; i++) {
64
+ const token = tokens[i];
65
+ // An empty parameter defaults to 0 (reset), matching terminal behavior.
66
+ const n = token.length === 0 ? 0 : Number(token);
67
+ if (!Number.isInteger(n)) return BAIL;
68
+ if (n === 0 || n === 49) {
69
+ result = null;
70
+ continue;
71
+ }
72
+ if ((n >= 40 && n <= 47) || (n >= 100 && n <= 107)) {
73
+ result = token;
74
+ continue;
75
+ }
76
+ if (n === 48) {
77
+ const mode = tokens[i + 1];
78
+ if (mode === "5") {
79
+ const idx = tokens[i + 2];
80
+ if (idx === undefined) return BAIL;
81
+ result = `48;5;${idx}`;
82
+ i += 2;
83
+ continue;
84
+ }
85
+ if (mode === "2") {
86
+ const r = tokens[i + 2];
87
+ const g = tokens[i + 3];
88
+ const b = tokens[i + 4];
89
+ if (r === undefined || g === undefined || b === undefined) return BAIL;
90
+ result = `48;2;${r};${g};${b}`;
91
+ i += 4;
92
+ continue;
93
+ }
94
+ // Colon-form (`48:2:...`) collapses to a single non-integer token and is
95
+ // rejected above; anything else following 48 is unexpected — bail.
96
+ return BAIL;
97
+ }
98
+ if (n === 38) {
99
+ // Foreground extended color: skip its sub-parameters, leave bg alone.
100
+ const mode = tokens[i + 1];
101
+ if (mode === "5") {
102
+ i += 2;
103
+ continue;
104
+ }
105
+ if (mode === "2") {
106
+ i += 4;
107
+ continue;
108
+ }
109
+ return BAIL;
110
+ }
111
+ // Every other parameter (foreground 30-39/90-97, styles) leaves bg alone.
112
+ }
113
+ return result;
114
+ }
115
+
116
+ /** Where to cut a fillable line and the background to paint over the remainder. */
117
+ export interface BgFillAnalysis {
118
+ /** Byte index where droppable trailing background padding begins (0 = whole line). */
119
+ cut: number;
120
+ /** 0-based column where the trailing padding begins (DECCARA left = leftCol + 1). */
121
+ leftCol: number;
122
+ /** SGR parameter list of the background covering the trailing region. */
123
+ bg: string;
124
+ }
125
+
126
+ /**
127
+ * Decide whether `line` (a final, width-fit, reset-terminated ANSI string) is a
128
+ * full-width background fill whose trailing padding can be replaced by a DECCARA
129
+ * rectangle. Returns `null` unless it can *prove* the dropped bytes are literal
130
+ * trailing spaces under a single, constant, non-default background span (or the
131
+ * entire row is background-styled spaces).
132
+ *
133
+ * Conservative by construction: any OSC sequence (hyperlinks/images), any
134
+ * non-SGR CSI, a partial row, an inconsistent or default trailing background, or
135
+ * a malformed escape all yield `null` so the caller keeps the exact original.
136
+ */
137
+ export function analyzeBgFillLine(line: string, width: number): BgFillAnalysis | null {
138
+ if (width <= 0 || line.length === 0) return null;
139
+ let i = 0;
140
+ let col = 0;
141
+ let bg: BgState = null;
142
+ // Byte index / column immediately after the last non-space printable glyph.
143
+ let nonSpaceEndByte = 0;
144
+ let nonSpaceEndCol = 0;
145
+ // Background covering the current trailing run of spaces, and whether that
146
+ // trailing run has started. `null` is a real "default background" value, so
147
+ // it cannot double as the uninitialized sentinel.
148
+ let trailBg: BgState = null;
149
+ let trailStarted = false;
150
+ let trailConsistent = true;
151
+
152
+ while (i < line.length) {
153
+ if (line.charCodeAt(i) === 0x1b) {
154
+ // Only CSI SGR (`\x1b[ ... m`) is tolerated. OSC, APC, and any other
155
+ // CSI mean styled hyperlinks/images/cursor markers — refuse to touch.
156
+ if (line.charCodeAt(i + 1) !== 0x5b) return null;
157
+ let j = i + 2;
158
+ while (j < line.length) {
159
+ const c = line.charCodeAt(j);
160
+ if (c >= 0x40 && c <= 0x7e) break;
161
+ j++;
162
+ }
163
+ if (j >= line.length) return null; // unterminated CSI
164
+ if (line.charCodeAt(j) !== 0x6d) return null; // non-SGR CSI (final byte != 'm')
165
+ const next = nextBackground(bg, line.slice(i + 2, j));
166
+ if (next === BAIL) return null;
167
+ bg = next;
168
+ i = j + 1;
169
+ continue;
170
+ }
171
+
172
+ // Printable run up to the next escape.
173
+ let j = i;
174
+ while (j < line.length && line.charCodeAt(j) !== 0x1b) j++;
175
+ const text = line.slice(i, j);
176
+ let nonSpaceLen = text.length;
177
+ while (nonSpaceLen > 0 && text.charCodeAt(nonSpaceLen - 1) === 0x20) nonSpaceLen--;
178
+
179
+ if (nonSpaceLen > 0) {
180
+ // Run carries a non-space glyph: the trailing region restarts after it.
181
+ const nonSpaceWidth = visibleWidth(text.slice(0, nonSpaceLen));
182
+ nonSpaceEndByte = i + nonSpaceLen;
183
+ nonSpaceEndCol = col + nonSpaceWidth;
184
+ // Spaces after the last non-space glyph in this same printable run sit
185
+ // under the current bg. If there are none, the trailing region has not
186
+ // started yet; a later SGR can still begin a uniform fill safely.
187
+ if (nonSpaceLen < text.length) {
188
+ trailBg = bg;
189
+ trailStarted = true;
190
+ } else {
191
+ trailBg = null;
192
+ trailStarted = false;
193
+ }
194
+ trailConsistent = true;
195
+ } else if (text.length > 0) {
196
+ // Whole run is spaces: it extends the trailing region. Track bg drift.
197
+ if (!trailStarted) {
198
+ trailBg = bg;
199
+ trailStarted = true;
200
+ } else if (bg !== trailBg) {
201
+ trailConsistent = false;
202
+ }
203
+ }
204
+ col += visibleWidth(text);
205
+ i = j;
206
+ }
207
+
208
+ if (col !== width) return null; // not a full-width fill
209
+ if (nonSpaceEndCol >= width) return null; // no trailing padding to drop
210
+ if (!trailStarted || trailBg === null || !trailConsistent) return null; // default/mixed bg — nothing safe to paint
211
+ return { cut: nonSpaceEndByte, leftCol: nonSpaceEndCol, bg: trailBg };
212
+ }
213
+
214
+ interface FillCandidate {
215
+ left: number;
216
+ right: number;
217
+ bg: string;
218
+ short: string;
219
+ origLen: number;
220
+ }
221
+
222
+ /** Per-frame plan: the (possibly shortened) row strings and the DECCARA batch. */
223
+ export interface DeccaraPlan {
224
+ /** Row strings to write, parallel to the input. Optimized rows are shortened. */
225
+ texts: string[];
226
+ /** DECSACE-wrapped rectangle batch to emit after the rows, or `""` if none. */
227
+ sequence: string;
228
+ }
229
+
230
+ /**
231
+ * Plan DECCARA rectangles for a contiguous block of visible rows.
232
+ *
233
+ * `lines[k]` is the final ANSI string for screen row `firstScreenRow + k`
234
+ * (0-based). For each fillable row the trailing background padding is removed
235
+ * (the row's cells are cleared/erased by the caller, then repainted by the
236
+ * rectangle), and vertically adjacent rows with an identical left/right/bg span
237
+ * coalesce into one rectangle. Rectangles are emitted only when they save more
238
+ * bytes than they cost, so the result never exceeds the original byte count.
239
+ */
240
+ export function planDeccaraFills(lines: string[], width: number, firstScreenRow = 0): DeccaraPlan {
241
+ const n = lines.length;
242
+ const texts: string[] = new Array(n);
243
+ const candidates: (FillCandidate | null)[] = new Array(n);
244
+
245
+ for (let k = 0; k < n; k++) {
246
+ const line = lines[k];
247
+ texts[k] = line;
248
+ const analysis = analyzeBgFillLine(line, width);
249
+ if (!analysis) {
250
+ candidates[k] = null;
251
+ continue;
252
+ }
253
+ // Cut at the last non-space glyph and re-close attributes. An all-space row
254
+ // (cut 0) needs no styled text at all — the caller's erase plus the
255
+ // rectangle paint it. A content row keeps its prefix and a fresh reset so
256
+ // the inline background never bleeds past the row.
257
+ const short = analysis.cut === 0 ? "" : line.slice(0, analysis.cut) + SEGMENT_RESET;
258
+ candidates[k] = { left: analysis.leftCol + 1, right: width, bg: analysis.bg, short, origLen: line.length };
259
+ }
260
+
261
+ // Collect coalesced groups whose rectangle at least pays for its own bytes.
262
+ // The DECSACE wrapper is a single per-frame cost, so it is charged once below
263
+ // rather than amortized into each group (which would over-reject lone rows).
264
+ interface Group {
265
+ start: number;
266
+ end: number;
267
+ rect: string;
268
+ }
269
+ const groups: Group[] = [];
270
+ let removedTotal = 0;
271
+ let rectBytesTotal = 0;
272
+ let k = 0;
273
+ while (k < n) {
274
+ const head = candidates[k];
275
+ if (!head) {
276
+ k++;
277
+ continue;
278
+ }
279
+ // Extend the group over adjacent rows sharing the same fill span.
280
+ let end = k;
281
+ while (end + 1 < n) {
282
+ const next = candidates[end + 1];
283
+ if (!next || next.left !== head.left || next.right !== head.right || next.bg !== head.bg) break;
284
+ end++;
285
+ }
286
+ const rect = encodeDeccara(firstScreenRow + k + 1, head.left, firstScreenRow + end + 1, head.right, head.bg);
287
+ let removed = 0;
288
+ for (let r = k; r <= end; r++) {
289
+ const c = candidates[r];
290
+ if (c) removed += c.origLen - c.short.length;
291
+ }
292
+ if (removed > rect.length) {
293
+ groups.push({ start: k, end, rect });
294
+ removedTotal += removed;
295
+ rectBytesTotal += rect.length;
296
+ }
297
+ k = end + 1;
298
+ }
299
+
300
+ // Emit nothing unless the batch beats the original by more than the wrapper.
301
+ if (groups.length === 0 || removedTotal - rectBytesTotal <= DECSACE_WRAPPER_BYTES) {
302
+ return { texts, sequence: "" };
303
+ }
304
+ let sequence = DECSACE_RECT;
305
+ for (const group of groups) {
306
+ for (let r = group.start; r <= group.end; r++) {
307
+ const c = candidates[r];
308
+ if (c) texts[r] = c.short;
309
+ }
310
+ sequence += group.rect;
311
+ }
312
+ sequence += DECSACE_DEFAULT;
313
+ return { texts, sequence };
314
+ }