pi-libtui 0.3.2

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 (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +205 -0
  3. package/package.json +55 -0
  4. package/src/activity-presentation.ts +75 -0
  5. package/src/appearance.ts +632 -0
  6. package/src/background-surface.ts +125 -0
  7. package/src/color/palette.ts +186 -0
  8. package/src/color/preview.ts +157 -0
  9. package/src/color/resolver.ts +75 -0
  10. package/src/color/theme.ts +531 -0
  11. package/src/component-stack.ts +247 -0
  12. package/src/content/markdown-code.ts +60 -0
  13. package/src/content/terminal-text.ts +135 -0
  14. package/src/content/text.ts +105 -0
  15. package/src/controls/action-panel.ts +420 -0
  16. package/src/controls/dialog-button-bar.ts +287 -0
  17. package/src/controls/multi-select.ts +467 -0
  18. package/src/controls/picker-panel.ts +447 -0
  19. package/src/controls/screen-icon-actions.ts +134 -0
  20. package/src/controls/searchable-select.ts +394 -0
  21. package/src/controls/select-box.ts +224 -0
  22. package/src/controls/selectable-list.ts +433 -0
  23. package/src/controls/selection-action-bar.ts +382 -0
  24. package/src/controls/semantic-input.ts +23 -0
  25. package/src/controls/tab.ts +189 -0
  26. package/src/cursor.ts +288 -0
  27. package/src/decoration/editor-pills.ts +156 -0
  28. package/src/decoration/glyphs.ts +326 -0
  29. package/src/decoration/pointer-interaction.ts +123 -0
  30. package/src/decoration/powerline-pill.ts +104 -0
  31. package/src/decoration/status.ts +185 -0
  32. package/src/decoration/transient-pill.ts +176 -0
  33. package/src/diff/index.ts +21 -0
  34. package/src/diff/model.ts +76 -0
  35. package/src/diff/parse.ts +310 -0
  36. package/src/diff/pierre-syntax.ts +86 -0
  37. package/src/diff/render.ts +504 -0
  38. package/src/diff/structured.ts +264 -0
  39. package/src/diff/view.ts +75 -0
  40. package/src/editor/chrome.ts +341 -0
  41. package/src/editor/composition.ts +53 -0
  42. package/src/editor/layout.ts +81 -0
  43. package/src/editor/presentation.ts +35 -0
  44. package/src/editor/protocol.ts +188 -0
  45. package/src/editor.ts +41 -0
  46. package/src/extension.ts +30 -0
  47. package/src/folding.ts +192 -0
  48. package/src/host/cursor-bridge.ts +210 -0
  49. package/src/host/editor-bridge.ts +148 -0
  50. package/src/host/extension-host.ts +231 -0
  51. package/src/host/mouse-bridge.ts +212 -0
  52. package/src/host/mouse-dispatch.ts +400 -0
  53. package/src/host/mouse-input.ts +85 -0
  54. package/src/host/mouse-targets.ts +238 -0
  55. package/src/host/pi-layout-adapter.ts +298 -0
  56. package/src/host/pi-native-selection.ts +227 -0
  57. package/src/host/split-pane-bridge.ts +720 -0
  58. package/src/host/transcript-bridge.ts +236 -0
  59. package/src/index.ts +334 -0
  60. package/src/line-layout.ts +6 -0
  61. package/src/motion.ts +1409 -0
  62. package/src/mouse/events.ts +55 -0
  63. package/src/mouse/fullscreen-layout.ts +260 -0
  64. package/src/mouse/registry.ts +348 -0
  65. package/src/mouse.ts +37 -0
  66. package/src/native-binary.ts +189 -0
  67. package/src/overlay/anchored.ts +91 -0
  68. package/src/overlay/detail-card.ts +158 -0
  69. package/src/overlay/dialog.ts +266 -0
  70. package/src/overlay/floating.ts +106 -0
  71. package/src/overlay/framed-editor.ts +236 -0
  72. package/src/overlay/fullscreen.ts +170 -0
  73. package/src/overlay/hover-tooltip.ts +109 -0
  74. package/src/overlay/modal-mount.ts +118 -0
  75. package/src/panels.ts +207 -0
  76. package/src/render-cache.ts +80 -0
  77. package/src/render-epoch.ts +48 -0
  78. package/src/request-animation.ts +160 -0
  79. package/src/scrollbar.ts +27 -0
  80. package/src/selection.ts +148 -0
  81. package/src/split-pane.ts +232 -0
  82. package/src/status-presentation.ts +411 -0
  83. package/src/stream.ts +543 -0
  84. package/src/syntax.ts +316 -0
  85. package/src/terminal/bridge-client.ts +243 -0
  86. package/src/terminal/embedding.ts +6 -0
  87. package/src/terminal/output.ts +151 -0
  88. package/src/terminal/projection.ts +362 -0
  89. package/src/terminal/pty-host.ts +263 -0
  90. package/src/terminal/pty-pane.ts +308 -0
  91. package/src/terminal-colors.ts +334 -0
  92. package/src/tool/action.ts +170 -0
  93. package/src/tool/activity.ts +500 -0
  94. package/src/tool/call-preview.ts +44 -0
  95. package/src/tool/disclosure-action.ts +89 -0
  96. package/src/tool/expanded-region-viewport.ts +103 -0
  97. package/src/tool/index.ts +26 -0
  98. package/src/tool/output.ts +271 -0
  99. package/src/tool/transcript.ts +150 -0
  100. package/src/tool/view-region.ts +581 -0
  101. package/test/appearance.test.ts +183 -0
  102. package/test/bounded-stream.test.ts +150 -0
  103. package/test/bridge-client.test.ts +42 -0
  104. package/test/color-preview.test.ts +23 -0
  105. package/test/component-stack.test.ts +206 -0
  106. package/test/components.test.ts +1165 -0
  107. package/test/content-text.test.ts +21 -0
  108. package/test/cursor-bridge.test.ts +151 -0
  109. package/test/cursor.test.ts +147 -0
  110. package/test/editor-chrome.test.ts +119 -0
  111. package/test/editor-layout.test.ts +30 -0
  112. package/test/editor-registry.test.ts +203 -0
  113. package/test/editor-theme.test.ts +33 -0
  114. package/test/editor-token-pill.test.ts +72 -0
  115. package/test/floating-overlay.test.ts +57 -0
  116. package/test/folding.test.ts +147 -0
  117. package/test/harmonious-theme.test.ts +23 -0
  118. package/test/interaction.test.ts +104 -0
  119. package/test/key-icons.test.ts +62 -0
  120. package/test/markdown-code.test.ts +24 -0
  121. package/test/modal-overlay-mount.test.ts +94 -0
  122. package/test/motion.test.ts +649 -0
  123. package/test/mouse/bridge.test.ts +1302 -0
  124. package/test/mouse/extension.test.ts +205 -0
  125. package/test/mouse/registry.test.ts +140 -0
  126. package/test/mouse/screen-decoration.test.ts +167 -0
  127. package/test/native-binary.test.ts +52 -0
  128. package/test/palette.test.ts +32 -0
  129. package/test/panels.test.ts +74 -0
  130. package/test/pointer-components.test.ts +151 -0
  131. package/test/progress.test.ts +54 -0
  132. package/test/pty-host.test.ts +126 -0
  133. package/test/pty-pane.test.ts +62 -0
  134. package/test/public-surface.test.ts +75 -0
  135. package/test/render-cache.test.ts +25 -0
  136. package/test/render-epoch.test.ts +119 -0
  137. package/test/request-animation.test.ts +193 -0
  138. package/test/screen-icon-actions.test.ts +69 -0
  139. package/test/select-box.test.ts +86 -0
  140. package/test/select-pointer.test.ts +369 -0
  141. package/test/selectable-list.test.ts +258 -0
  142. package/test/selection/registry.test.ts +44 -0
  143. package/test/selection-action-bar.test.ts +204 -0
  144. package/test/split-pane-bridge.test.ts +944 -0
  145. package/test/split-pane.test.ts +134 -0
  146. package/test/status-presentation.test.ts +39 -0
  147. package/test/syntax.test.ts +59 -0
  148. package/test/terminal-colors.test.ts +115 -0
  149. package/test/terminal-embedding.test.ts +12 -0
  150. package/test/terminal-projection.test.ts +312 -0
  151. package/test/theme-colors.test.ts +175 -0
  152. package/test/tool-output.test.ts +160 -0
  153. package/test/tool-transcript.test.ts +1190 -0
  154. package/test/unified-diff.test.ts +691 -0
  155. package/themes/harmonious.json +61 -0
  156. package/tsconfig.json +16 -0
@@ -0,0 +1,151 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { TerminalProjection } from "./projection.ts";
4
+
5
+ export type {
6
+ ProjectionScheduler,
7
+ ProjectionTimer,
8
+ RenderTerminalLinesOptions,
9
+ TerminalProjectionOptions,
10
+ } from "./projection.ts";
11
+ export { TerminalProjection };
12
+
13
+ const CUMULATIVE_TAIL_LIMIT = 64 * 1024;
14
+ const MIN_CUMULATIVE_OVERLAP = 16;
15
+
16
+ export interface TerminalOutputOptions {
17
+ requestRender(): void;
18
+ cols?: number;
19
+ rows?: number;
20
+ maxRows?: number;
21
+ }
22
+
23
+ /** Incremental ANSI/TTY output view with replay-safe replacement and bounded scrollback. */
24
+ export class TerminalOutput implements Component {
25
+ private projection: TerminalProjection;
26
+ private sourceLength = 0;
27
+ private sourceTail = "";
28
+ private maxRows: number;
29
+ private omissionRow: number | undefined;
30
+ private disposed = false;
31
+
32
+ constructor(private readonly options: TerminalOutputOptions) {
33
+ this.maxRows = Math.max(1, Math.floor(options.maxRows ?? 6));
34
+ this.omissionRow = undefined;
35
+ this.projection = this.createProjection();
36
+ }
37
+
38
+ getOmissionRow(): number | undefined {
39
+ return this.omissionRow;
40
+ }
41
+
42
+ setText(text: string): void {
43
+ if (this.disposed) return;
44
+ this.projection.dispose();
45
+ this.projection = this.createProjection();
46
+ this.sourceLength = text.length;
47
+ this.sourceTail = retainedSourceTail(text);
48
+ this.omissionRow = undefined;
49
+ if (text) this.write(text);
50
+ }
51
+
52
+ /** Consume a cumulative append-only snapshot without rescanning its retained prefix. */
53
+ appendCumulative(text: string): void {
54
+ if (this.disposed) return;
55
+ if (text.length < this.sourceLength) {
56
+ this.setText(text);
57
+ return;
58
+ }
59
+ const chunk = text.slice(this.sourceLength);
60
+ this.sourceLength = text.length;
61
+ this.sourceTail = retainedSourceTail(text);
62
+ if (chunk) this.write(chunk);
63
+ }
64
+
65
+ private write(chunk: string): void {
66
+ if (!chunk || this.disposed) return;
67
+ this.projection.write(chunk);
68
+ }
69
+
70
+ /**
71
+ * Consume a cumulative snapshot whose prefix may have been truncated by the
72
+ * caller. This is deliberately separate from setText(): only a caller that
73
+ * knows the snapshot is a retained cumulative tail may reuse terminal state
74
+ * by overlap.
75
+ */
76
+ appendCumulativeTail(text: string): void {
77
+ if (this.disposed) return;
78
+ if (text.length === 0 || this.sourceLength === 0 || this.sourceTail.length === 0) {
79
+ this.setText(text);
80
+ return;
81
+ }
82
+ const overlap = cumulativeTailOverlap(this.sourceTail, text);
83
+ if (overlap < MIN_CUMULATIVE_OVERLAP) {
84
+ this.setText(text);
85
+ return;
86
+ }
87
+ const chunk = text.slice(overlap);
88
+ if (chunk) this.projection.write(chunk);
89
+ this.sourceLength += chunk.length;
90
+ this.sourceTail = retainedSourceTail(text);
91
+ this.omissionRow = undefined;
92
+ }
93
+
94
+ /** Wait until all terminal input written so far has been projected. */
95
+ async drain(): Promise<void> {
96
+ if (this.disposed) return;
97
+ await this.projection.drain();
98
+ }
99
+
100
+ setMaxRows(maxRows: number): void {
101
+ this.maxRows = Math.max(1, Math.floor(maxRows));
102
+ this.omissionRow = undefined;
103
+ }
104
+
105
+ render(width: number): string[] {
106
+ this.omissionRow = undefined;
107
+ this.projection.resize(Math.max(1, width), this.options.rows ?? 24);
108
+ const lines = [...this.projection.renderLines({ includeScrollback: true, cursor: false })];
109
+ while (lines.at(-1) === "") lines.pop();
110
+ if (lines.length <= this.maxRows) return lines;
111
+ const visibleRows = this.maxRows - 1;
112
+ this.omissionRow = 0;
113
+ return [
114
+ truncateToWidth(`… ${lines.length - visibleRows} rows omitted …`, Math.max(0, width), "…"),
115
+ ...lines.slice(-visibleRows),
116
+ ];
117
+ }
118
+
119
+ invalidate(): void {}
120
+
121
+ dispose(): void {
122
+ if (this.disposed) return;
123
+ this.disposed = true;
124
+ this.projection.dispose();
125
+ }
126
+
127
+ private createProjection(): TerminalProjection {
128
+ return new TerminalProjection({
129
+ requestRender: this.options.requestRender,
130
+ cols: this.options.cols ?? 80,
131
+ rows: this.options.rows ?? 24,
132
+ });
133
+ }
134
+ }
135
+
136
+ function retainedSourceTail(text: string): string {
137
+ return text.length <= CUMULATIVE_TAIL_LIMIT ? text : text.slice(-CUMULATIVE_TAIL_LIMIT);
138
+ }
139
+
140
+ /** Return the longest suffix/prefix overlap needed to append a shifted tail. */
141
+ function cumulativeTailOverlap(previousTail: string, nextText: string): number {
142
+ if (previousTail.length === 0 || nextText.length === 0) return 0;
143
+ const needle = nextText.slice(0, Math.min(32, nextText.length));
144
+ for (let searchStart = Math.max(0, previousTail.length - nextText.length); ; ) {
145
+ const match = previousTail.indexOf(needle, searchStart);
146
+ if (match < 0) return 0;
147
+ const overlap = previousTail.length - match;
148
+ if (overlap <= nextText.length && nextText.startsWith(previousTail.slice(match))) return overlap;
149
+ searchStart = match + 1;
150
+ }
151
+ }
@@ -0,0 +1,362 @@
1
+ import { CURSOR_MARKER } from "@earendil-works/pi-tui";
2
+ import { type IBufferCell, type IBufferLine, Terminal } from "@xterm/headless";
3
+ import { markNativeCursorPosition, type NativeCursorStyle } from "../cursor.ts";
4
+
5
+ const DEFAULT_COLS = 80;
6
+ const DEFAULT_ROWS = 24;
7
+ const DEFAULT_SCROLLBACK = 5_000;
8
+ const DEFAULT_REPAINT_INTERVAL_MS = 40;
9
+ const MAX_COLS = 500;
10
+ const MAX_ROWS = 200;
11
+ const MAX_SCROLLBACK = 10_000;
12
+
13
+ /** Clamp a PTY and its projection to the same supported viewport. */
14
+ export function normalizeTerminalDimensions(cols: number, rows: number): readonly [number, number] {
15
+ return [clampDimension(cols, MAX_COLS), clampDimension(rows, MAX_ROWS)];
16
+ }
17
+
18
+ // type-boundary: @xterm/headless 6 keeps its low-latency user-input hint on the
19
+ // private write buffer. The hint retains public write ordering, async handlers,
20
+ // and slicing; remove this seam when Terminal exposes it.
21
+ type ProjectionTerminal = Terminal & {
22
+ _core?: {
23
+ _writeBuffer?: { handleUserInput?: () => void };
24
+ _inputHandler?: {
25
+ onRequestRefreshRows?: (listener: (range: { start: number; end: number } | undefined) => void) => {
26
+ dispose(): void;
27
+ };
28
+ };
29
+ };
30
+ };
31
+
32
+ export interface ProjectionTimer {
33
+ dispose(): void;
34
+ }
35
+
36
+ /** Injectable clock used to make repaint throttling deterministic. */
37
+ export interface ProjectionScheduler {
38
+ now(): number;
39
+ schedule(callback: () => void, delayMs: number): ProjectionTimer;
40
+ }
41
+
42
+ export interface TerminalProjectionOptions {
43
+ readonly requestRender: () => void;
44
+ readonly cols?: number;
45
+ readonly rows?: number;
46
+ readonly scrollback?: number;
47
+ readonly repaintIntervalMs?: number;
48
+ readonly scheduler?: ProjectionScheduler;
49
+ }
50
+
51
+ export interface RenderTerminalLinesOptions {
52
+ /** Include the bounded xterm scrollback instead of only the viewport. */
53
+ readonly includeScrollback?: boolean;
54
+ /** Include Pi's hardware cursor marker. Defaults to true. */
55
+ readonly cursor?: boolean;
56
+ /** Maximum returned rows. Defaults to the configured viewport or scrollback bound. */
57
+ readonly maxRows?: number;
58
+ }
59
+
60
+ const SYSTEM_SCHEDULER: ProjectionScheduler = {
61
+ now: () => Date.now(),
62
+ schedule(callback, delayMs) {
63
+ if (delayMs <= 0) {
64
+ let disposed = false;
65
+ queueMicrotask(() => {
66
+ if (!disposed) callback();
67
+ });
68
+ return { dispose: () => (disposed = true) };
69
+ }
70
+ const handle = setTimeout(callback, delayMs);
71
+ handle.unref?.();
72
+ return { dispose: () => clearTimeout(handle) };
73
+ },
74
+ };
75
+
76
+ /**
77
+ * Side-effect-free terminal emulation for streamed PTY bytes.
78
+ *
79
+ * Projection replays cell state as an allowlisted subset of SGR. OSC, DCS,
80
+ * title changes, clipboard controls, and other terminal side effects never
81
+ * escape into the host renderer.
82
+ */
83
+ export class TerminalProjection {
84
+ private readonly terminal: Terminal;
85
+ private readonly parsedSubscription: { dispose(): void };
86
+ private readonly dirtyRowsSubscription: { dispose(): void } | undefined;
87
+ private readonly cursorSubscriptions: readonly { dispose(): void }[];
88
+ private readonly requestRender: () => void;
89
+ private readonly scheduler: ProjectionScheduler;
90
+ private readonly repaintIntervalMs: number;
91
+ private repaintTimer: ProjectionTimer | undefined;
92
+ private lastRenderAt = Number.NEGATIVE_INFINITY;
93
+ private disposed = false;
94
+ private bufferRevision = 0;
95
+ private renderedRevision = -1;
96
+ private renderedKey = "";
97
+ private renderedLines: string[] | undefined;
98
+ private readonly projectedRows = new Map<
99
+ number,
100
+ { cursorColumn: number | undefined; cursorStyle: NativeCursorStyle | undefined; value: string }
101
+ >();
102
+ private projectedBufferType: "normal" | "alternate" | undefined;
103
+ private projectedBaseY = 0;
104
+ private contentEndRow = 0;
105
+ private cursorVisible = true;
106
+ private cursorStyle: NativeCursorStyle | undefined;
107
+
108
+ constructor(options: TerminalProjectionOptions) {
109
+ const [cols, rows] = normalizeTerminalDimensions(options.cols ?? DEFAULT_COLS, options.rows ?? DEFAULT_ROWS);
110
+ this.requestRender = options.requestRender;
111
+ this.scheduler = options.scheduler ?? SYSTEM_SCHEDULER;
112
+ this.repaintIntervalMs = clampNonNegative(options.repaintIntervalMs ?? DEFAULT_REPAINT_INTERVAL_MS);
113
+ this.terminal = new Terminal({
114
+ allowProposedApi: true,
115
+ cols,
116
+ rows,
117
+ scrollback: clampNonNegative(options.scrollback ?? DEFAULT_SCROLLBACK, MAX_SCROLLBACK),
118
+ convertEol: false,
119
+ });
120
+ this.parsedSubscription = this.terminal.onWriteParsed(() => {
121
+ this.markParsed();
122
+ });
123
+ this.cursorSubscriptions = [
124
+ this.terminal.parser.registerCsiHandler({ prefix: "?", final: "h" }, (params) => {
125
+ if (params.includes(25)) this.cursorVisible = true;
126
+ return false;
127
+ }),
128
+ this.terminal.parser.registerCsiHandler({ prefix: "?", final: "l" }, (params) => {
129
+ if (params.includes(25)) this.cursorVisible = false;
130
+ return false;
131
+ }),
132
+ this.terminal.parser.registerCsiHandler({ intermediates: " ", final: "q" }, (params) => {
133
+ this.cursorStyle = nativeCursorStyle(params[0]);
134
+ return false;
135
+ }),
136
+ ];
137
+ // type-boundary: @xterm/headless 6 exposes dirty viewport rows only on its
138
+ // private input handler. Cache correctness falls back to full invalidation
139
+ // when that installed structural seam is unavailable.
140
+ this.dirtyRowsSubscription = (this.terminal as ProjectionTerminal)._core?._inputHandler?.onRequestRefreshRows?.(
141
+ (range) => this.invalidateProjectedRows(range),
142
+ );
143
+ }
144
+
145
+ get cols(): number {
146
+ return this.terminal.cols;
147
+ }
148
+
149
+ get rows(): number {
150
+ return this.terminal.rows;
151
+ }
152
+
153
+ get acceptsFocusEvents(): boolean {
154
+ return this.terminal.modes.sendFocusMode;
155
+ }
156
+
157
+ /** Route interactive bytes through xterm's immediate, ordered input path. */
158
+ write(data: string | Uint8Array): void {
159
+ if (this.disposed || data.length === 0) return;
160
+ const writeBuffer = (this.terminal as ProjectionTerminal)._core?._writeBuffer;
161
+ writeBuffer?.handleUserInput?.call(writeBuffer);
162
+ this.terminal.write(data);
163
+ }
164
+
165
+ /** Resolve after every previously queued write has been parsed. */
166
+ drain(): Promise<void> {
167
+ if (this.disposed) return Promise.resolve();
168
+ return new Promise((resolve) => this.terminal.write("", resolve));
169
+ }
170
+
171
+ resize(cols: number, rows: number): boolean {
172
+ if (this.disposed) return false;
173
+ const [nextCols, nextRows] = normalizeTerminalDimensions(cols, rows);
174
+ if (nextCols === this.terminal.cols && nextRows === this.terminal.rows) return false;
175
+ this.terminal.resize(nextCols, nextRows);
176
+ this.projectedRows.clear();
177
+ this.bufferRevision += 1;
178
+ this.scheduleRender();
179
+ return true;
180
+ }
181
+
182
+ renderLines(options: RenderTerminalLinesOptions = {}): string[] {
183
+ if (this.disposed) return [];
184
+ const cacheKey = `${options.includeScrollback ?? false}\0${options.cursor ?? true}\0${options.maxRows ?? ""}`;
185
+ if (this.renderedRevision === this.bufferRevision && this.renderedKey === cacheKey && this.renderedLines) {
186
+ return this.renderedLines;
187
+ }
188
+ const buffer = this.terminal.buffer.active;
189
+ if (buffer.type !== this.projectedBufferType || buffer.baseY !== this.projectedBaseY) {
190
+ this.projectedRows.clear();
191
+ this.projectedBufferType = buffer.type;
192
+ this.projectedBaseY = buffer.baseY;
193
+ }
194
+ const includeScrollback = options.includeScrollback ?? false;
195
+ const firstRow = includeScrollback ? 0 : buffer.viewportY;
196
+ const endRow = includeScrollback
197
+ ? Math.max(1, Math.min(buffer.length, this.contentEndRow + 1))
198
+ : buffer.viewportY + this.terminal.rows;
199
+ const availableRows = endRow - firstRow;
200
+ const rowCount = Math.min(availableRows, clampNonNegative(options.maxRows ?? availableRows, MAX_SCROLLBACK));
201
+ const boundedFirstRow = endRow - rowCount;
202
+ const cursorLine = buffer.baseY + buffer.cursorY;
203
+ const lines: string[] = [];
204
+ const cell = buffer.getNullCell();
205
+ for (let offset = 0; offset < rowCount; offset += 1) {
206
+ const bufferRow = boundedFirstRow + offset;
207
+ const cursorColumn =
208
+ options.cursor !== false && this.cursorVisible && bufferRow === cursorLine ? buffer.cursorX : undefined;
209
+ const cached = this.projectedRows.get(bufferRow);
210
+ if (cached && cached.cursorColumn === cursorColumn && cached.cursorStyle === this.cursorStyle) {
211
+ lines.push(cached.value);
212
+ continue;
213
+ }
214
+ const projected = projectLine(buffer.getLine(bufferRow), this.terminal.cols, cursorColumn, cell);
215
+ const value =
216
+ cursorColumn !== undefined && this.cursorStyle
217
+ ? markNativeCursorPosition(projected, this.cursorStyle)
218
+ : projected;
219
+ this.projectedRows.set(bufferRow, { cursorColumn, cursorStyle: this.cursorStyle, value });
220
+ lines.push(value);
221
+ }
222
+ this.renderedRevision = this.bufferRevision;
223
+ this.renderedKey = cacheKey;
224
+ this.renderedLines = lines;
225
+ return lines;
226
+ }
227
+
228
+ dispose(): void {
229
+ if (this.disposed) return;
230
+ this.disposed = true;
231
+ this.repaintTimer?.dispose();
232
+ this.repaintTimer = undefined;
233
+ this.parsedSubscription.dispose();
234
+ this.dirtyRowsSubscription?.dispose();
235
+ for (const subscription of this.cursorSubscriptions) subscription.dispose();
236
+ this.terminal.dispose();
237
+ this.renderedLines = undefined;
238
+ this.projectedRows.clear();
239
+ }
240
+
241
+ private markParsed(): void {
242
+ if (!this.dirtyRowsSubscription) this.projectedRows.clear();
243
+ const buffer = this.terminal.buffer.active;
244
+ this.contentEndRow = Math.max(this.contentEndRow, buffer.baseY + buffer.cursorY);
245
+ this.bufferRevision += 1;
246
+ // Synchronized output is one atomic frame even when its bytes cross native
247
+ // reads. Publishing the body early causes tearing and burns an extra host frame.
248
+ if (!this.terminal.modes.synchronizedOutputMode) this.scheduleRender();
249
+ }
250
+
251
+ private invalidateProjectedRows(range: { start: number; end: number } | undefined): void {
252
+ if (!range) {
253
+ this.projectedRows.clear();
254
+ return;
255
+ }
256
+ const viewportY = this.terminal.buffer.active.viewportY;
257
+ for (let row = range.start; row <= range.end; row += 1) this.projectedRows.delete(viewportY + row);
258
+ }
259
+
260
+ private scheduleRender(): void {
261
+ if (this.disposed || this.repaintTimer) return;
262
+ const elapsed = this.scheduler.now() - this.lastRenderAt;
263
+ const delay = Math.max(0, this.repaintIntervalMs - elapsed);
264
+ this.repaintTimer = this.scheduler.schedule(() => {
265
+ this.repaintTimer = undefined;
266
+ if (this.disposed) return;
267
+ this.lastRenderAt = this.scheduler.now();
268
+ try {
269
+ this.requestRender();
270
+ } catch {
271
+ // Host repaint failures must not escape a terminal timer.
272
+ }
273
+ }, delay);
274
+ }
275
+ }
276
+
277
+ function nativeCursorStyle(parameter: number | number[] | undefined): NativeCursorStyle {
278
+ const value = Array.isArray(parameter) ? parameter[0] : parameter;
279
+ if (value === 1) return "blinking-block";
280
+ if (value === 2) return "steady-block";
281
+ if (value === 3) return "blinking-underline";
282
+ if (value === 4) return "steady-underline";
283
+ if (value === 5) return "blinking-bar";
284
+ if (value === 6) return "steady-bar";
285
+ return "terminal-default";
286
+ }
287
+
288
+ function projectLine(
289
+ line: IBufferLine | undefined,
290
+ cols: number,
291
+ cursorColumn: number | undefined,
292
+ cell: IBufferCell,
293
+ ): string {
294
+ let lastCell = cursorColumn ?? -1;
295
+ if (line) {
296
+ for (let column = 0; column < cols; column += 1) {
297
+ const current = line.getCell(column, cell);
298
+ if (current && current.getWidth() > 0 && (current.getChars().length > 0 || !current.isAttributeDefault())) {
299
+ lastCell = column;
300
+ }
301
+ }
302
+ }
303
+
304
+ let result = "";
305
+ let activeStyle = "";
306
+ for (let column = 0; column <= lastCell; column += 1) {
307
+ if (column === cursorColumn) result += CURSOR_MARKER;
308
+ const current = line?.getCell(column, cell);
309
+ if (!current || current.getWidth() === 0) continue;
310
+ const style = cellStyle(current);
311
+ if (style !== activeStyle) {
312
+ result += style ? `\x1b[0;${style}m` : "\x1b[0m";
313
+ activeStyle = style;
314
+ }
315
+ result += current.isInvisible() ? " " : current.getChars() || " ";
316
+ }
317
+ if (cursorColumn === lastCell + 1) result += CURSOR_MARKER;
318
+ if (activeStyle) result += "\x1b[0m";
319
+ return result;
320
+ }
321
+
322
+ /** Only these inert visual attributes can be replayed into Pi's renderer. */
323
+ function cellStyle(cell: IBufferCell): string {
324
+ if (cell.isAttributeDefault()) return "";
325
+ const codes: number[] = [];
326
+ if (cell.isBold()) codes.push(1);
327
+ if (cell.isDim()) codes.push(2);
328
+ if (cell.isItalic()) codes.push(3);
329
+ if (cell.isUnderline()) codes.push(4);
330
+ if (cell.isInverse()) codes.push(7);
331
+ if (cell.isInvisible()) codes.push(8);
332
+ if (cell.isStrikethrough()) codes.push(9);
333
+ if (cell.isOverline()) codes.push(53);
334
+ appendColor(codes, cell.getFgColor(), cell.isFgRGB(), cell.isFgPalette(), false);
335
+ appendColor(codes, cell.getBgColor(), cell.isBgRGB(), cell.isBgPalette(), true);
336
+ return codes.join(";");
337
+ }
338
+
339
+ function appendColor(codes: number[], color: number, rgb: boolean, palette: boolean, background: boolean): void {
340
+ if (rgb) {
341
+ codes.push(background ? 48 : 38, 2, (color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff);
342
+ return;
343
+ }
344
+ if (!palette) return;
345
+ if (color < 8) {
346
+ codes.push((background ? 40 : 30) + color);
347
+ return;
348
+ }
349
+ if (color < 16) {
350
+ codes.push((background ? 100 : 90) + color - 8);
351
+ return;
352
+ }
353
+ codes.push(background ? 48 : 38, 5, color);
354
+ }
355
+
356
+ function clampDimension(value: number, maximum: number): number {
357
+ return Math.min(maximum, Math.max(1, Math.floor(Number.isFinite(value) ? value : 1)));
358
+ }
359
+
360
+ function clampNonNegative(value: number, maximum = Number.MAX_SAFE_INTEGER): number {
361
+ return Math.min(maximum, Math.max(0, Math.floor(Number.isFinite(value) ? value : 0)));
362
+ }