@floegence/floeterm-terminal-web 0.14.1 → 0.15.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 (156) hide show
  1. package/README.md +78 -445
  2. package/dist/core/TerminalInputBridge.d.ts +7 -3
  3. package/dist/core/TerminalInputBridge.d.ts.map +1 -1
  4. package/dist/core/TerminalInputBridge.js +63 -4
  5. package/dist/core/TerminalInputBridge.js.map +1 -1
  6. package/dist/entries/live.d.ts +4 -4
  7. package/dist/entries/live.d.ts.map +1 -1
  8. package/dist/entries/live.js +2 -2
  9. package/dist/entries/live.js.map +1 -1
  10. package/dist/entries/semantic.d.ts +9 -0
  11. package/dist/entries/semantic.d.ts.map +1 -0
  12. package/dist/entries/semantic.js +5 -0
  13. package/dist/entries/semantic.js.map +1 -0
  14. package/dist/entries/sessions.d.ts +1 -1
  15. package/dist/entries/sessions.d.ts.map +1 -1
  16. package/dist/index.d.ts +4 -26
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +3 -16
  19. package/dist/index.js.map +1 -1
  20. package/dist/live/client.d.ts +4 -6
  21. package/dist/live/client.d.ts.map +1 -1
  22. package/dist/live/client.js +19 -72
  23. package/dist/live/client.js.map +1 -1
  24. package/dist/live/codec.d.ts +8 -21
  25. package/dist/live/codec.d.ts.map +1 -1
  26. package/dist/live/codec.js +79 -111
  27. package/dist/live/codec.js.map +1 -1
  28. package/dist/live/transport.d.ts +38 -18
  29. package/dist/live/transport.d.ts.map +1 -1
  30. package/dist/live/transport.js +59 -133
  31. package/dist/live/transport.js.map +1 -1
  32. package/dist/semantic/RendererSurface.d.ts +65 -0
  33. package/dist/semantic/RendererSurface.d.ts.map +1 -0
  34. package/dist/semantic/RendererSurface.js +566 -0
  35. package/dist/semantic/RendererSurface.js.map +1 -0
  36. package/dist/semantic/presentation.d.ts +97 -0
  37. package/dist/semantic/presentation.d.ts.map +1 -0
  38. package/dist/semantic/presentation.js +102 -0
  39. package/dist/semantic/presentation.js.map +1 -0
  40. package/dist/types.d.ts +13 -494
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/types.js +1 -37
  43. package/dist/types.js.map +1 -1
  44. package/package.json +11 -19
  45. package/dist/checkpoint/GhosttyCheckpoint.worker.d.ts +0 -2
  46. package/dist/checkpoint/GhosttyCheckpoint.worker.d.ts.map +0 -1
  47. package/dist/checkpoint/GhosttyCheckpoint.worker.js +0 -9
  48. package/dist/checkpoint/GhosttyCheckpoint.worker.js.map +0 -1
  49. package/dist/checkpoint/GhosttyCheckpointActor.d.ts +0 -44
  50. package/dist/checkpoint/GhosttyCheckpointActor.d.ts.map +0 -1
  51. package/dist/checkpoint/GhosttyCheckpointActor.js +0 -284
  52. package/dist/checkpoint/GhosttyCheckpointActor.js.map +0 -1
  53. package/dist/checkpoint/GhosttyCheckpointEngine.d.ts +0 -62
  54. package/dist/checkpoint/GhosttyCheckpointEngine.d.ts.map +0 -1
  55. package/dist/checkpoint/GhosttyCheckpointEngine.js +0 -175
  56. package/dist/checkpoint/GhosttyCheckpointEngine.js.map +0 -1
  57. package/dist/checkpoint/GhosttyCheckpointWorkerRuntime.d.ts +0 -31
  58. package/dist/checkpoint/GhosttyCheckpointWorkerRuntime.d.ts.map +0 -1
  59. package/dist/checkpoint/GhosttyCheckpointWorkerRuntime.js +0 -112
  60. package/dist/checkpoint/GhosttyCheckpointWorkerRuntime.js.map +0 -1
  61. package/dist/core/PagedTerminalOutputCoordinator.d.ts +0 -130
  62. package/dist/core/PagedTerminalOutputCoordinator.d.ts.map +0 -1
  63. package/dist/core/PagedTerminalOutputCoordinator.js +0 -1213
  64. package/dist/core/PagedTerminalOutputCoordinator.js.map +0 -1
  65. package/dist/core/TerminalCore.d.ts +0 -286
  66. package/dist/core/TerminalCore.d.ts.map +0 -1
  67. package/dist/core/TerminalCore.js +0 -3566
  68. package/dist/core/TerminalCore.js.map +0 -1
  69. package/dist/core/TerminalOutputPipeline.d.ts +0 -86
  70. package/dist/core/TerminalOutputPipeline.d.ts.map +0 -1
  71. package/dist/core/TerminalOutputPipeline.js +0 -511
  72. package/dist/core/TerminalOutputPipeline.js.map +0 -1
  73. package/dist/core/TerminalRenderScheduler.d.ts +0 -45
  74. package/dist/core/TerminalRenderScheduler.d.ts.map +0 -1
  75. package/dist/core/TerminalRenderScheduler.js +0 -145
  76. package/dist/core/TerminalRenderScheduler.js.map +0 -1
  77. package/dist/core/TerminalScrollbarOverlay.d.ts +0 -63
  78. package/dist/core/TerminalScrollbarOverlay.d.ts.map +0 -1
  79. package/dist/core/TerminalScrollbarOverlay.js +0 -493
  80. package/dist/core/TerminalScrollbarOverlay.js.map +0 -1
  81. package/dist/entries/history.d.ts +0 -4
  82. package/dist/entries/history.d.ts.map +0 -1
  83. package/dist/entries/history.js +0 -2
  84. package/dist/entries/history.js.map +0 -1
  85. package/dist/entries/preload.d.ts +0 -3
  86. package/dist/entries/preload.d.ts.map +0 -1
  87. package/dist/entries/preload.js +0 -2
  88. package/dist/entries/preload.js.map +0 -1
  89. package/dist/fabric/BeamtermFabricRenderer.d.ts +0 -67
  90. package/dist/fabric/BeamtermFabricRenderer.d.ts.map +0 -1
  91. package/dist/fabric/BeamtermFabricRenderer.js +0 -659
  92. package/dist/fabric/BeamtermFabricRenderer.js.map +0 -1
  93. package/dist/fabric/TerminalFabricCoordinator.d.ts +0 -40
  94. package/dist/fabric/TerminalFabricCoordinator.d.ts.map +0 -1
  95. package/dist/fabric/TerminalFabricCoordinator.js +0 -141
  96. package/dist/fabric/TerminalFabricCoordinator.js.map +0 -1
  97. package/dist/fabric/TerminalLiveFabric.d.ts +0 -44
  98. package/dist/fabric/TerminalLiveFabric.d.ts.map +0 -1
  99. package/dist/fabric/TerminalLiveFabric.js +0 -117
  100. package/dist/fabric/TerminalLiveFabric.js.map +0 -1
  101. package/dist/fabric/types.d.ts +0 -152
  102. package/dist/fabric/types.d.ts.map +0 -1
  103. package/dist/fabric/types.js +0 -2
  104. package/dist/fabric/types.js.map +0 -1
  105. package/dist/internal/BeamtermResourceLoader.d.ts +0 -3
  106. package/dist/internal/BeamtermResourceLoader.d.ts.map +0 -1
  107. package/dist/internal/BeamtermResourceLoader.js +0 -14
  108. package/dist/internal/BeamtermResourceLoader.js.map +0 -1
  109. package/dist/internal/GhosttyCompatibilityVersion.d.ts +0 -2
  110. package/dist/internal/GhosttyCompatibilityVersion.d.ts.map +0 -1
  111. package/dist/internal/GhosttyCompatibilityVersion.js +0 -2
  112. package/dist/internal/GhosttyCompatibilityVersion.js.map +0 -1
  113. package/dist/internal/GhosttyScrollbackCompat.d.ts +0 -16
  114. package/dist/internal/GhosttyScrollbackCompat.d.ts.map +0 -1
  115. package/dist/internal/GhosttyScrollbackCompat.js +0 -43
  116. package/dist/internal/GhosttyScrollbackCompat.js.map +0 -1
  117. package/dist/internal/GhosttyScrollbarCompat.d.ts +0 -12
  118. package/dist/internal/GhosttyScrollbarCompat.d.ts.map +0 -1
  119. package/dist/internal/GhosttyScrollbarCompat.js +0 -160
  120. package/dist/internal/GhosttyScrollbarCompat.js.map +0 -1
  121. package/dist/internal/SequenceBuffer.d.ts +0 -19
  122. package/dist/internal/SequenceBuffer.d.ts.map +0 -1
  123. package/dist/internal/SequenceBuffer.js +0 -83
  124. package/dist/internal/SequenceBuffer.js.map +0 -1
  125. package/dist/internal/TerminalInitializationScheduler.d.ts +0 -38
  126. package/dist/internal/TerminalInitializationScheduler.d.ts.map +0 -1
  127. package/dist/internal/TerminalInitializationScheduler.js +0 -112
  128. package/dist/internal/TerminalInitializationScheduler.js.map +0 -1
  129. package/dist/internal/TerminalResourceLoader.d.ts +0 -40
  130. package/dist/internal/TerminalResourceLoader.d.ts.map +0 -1
  131. package/dist/internal/TerminalResourceLoader.js +0 -165
  132. package/dist/internal/TerminalResourceLoader.js.map +0 -1
  133. package/dist/internal/UnicodeSafeUrlProvider.d.ts +0 -17
  134. package/dist/internal/UnicodeSafeUrlProvider.d.ts.map +0 -1
  135. package/dist/internal/UnicodeSafeUrlProvider.js +0 -32
  136. package/dist/internal/UnicodeSafeUrlProvider.js.map +0 -1
  137. package/dist/internal/scheduleUiTurn.d.ts +0 -5
  138. package/dist/internal/scheduleUiTurn.d.ts.map +0 -1
  139. package/dist/internal/scheduleUiTurn.js +0 -37
  140. package/dist/internal/scheduleUiTurn.js.map +0 -1
  141. package/dist/manager/TerminalInstanceController.d.ts +0 -88
  142. package/dist/manager/TerminalInstanceController.d.ts.map +0 -1
  143. package/dist/manager/TerminalInstanceController.js +0 -1076
  144. package/dist/manager/TerminalInstanceController.js.map +0 -1
  145. package/dist/utils/errors.d.ts +0 -3
  146. package/dist/utils/errors.d.ts.map +0 -1
  147. package/dist/utils/errors.js +0 -11
  148. package/dist/utils/errors.js.map +0 -1
  149. package/dist/utils/history.d.ts +0 -2
  150. package/dist/utils/history.d.ts.map +0 -1
  151. package/dist/utils/history.js +0 -12
  152. package/dist/utils/history.js.map +0 -1
  153. package/dist/utils/xtermAutoResponseFilter.d.ts +0 -2
  154. package/dist/utils/xtermAutoResponseFilter.d.ts.map +0 -1
  155. package/dist/utils/xtermAutoResponseFilter.js +0 -109
  156. package/dist/utils/xtermAutoResponseFilter.js.map +0 -1
@@ -1,3566 +0,0 @@
1
- import { filterXtermAutoResponses } from '../utils/xtermAutoResponseFilter.js';
2
- import { createConsoleLogger, noopLogger } from '../utils/logger.js';
3
- import { cursorToFabricCursor, renderReasonFromForce, terminalLiveFabric, themeToFabricTheme, } from '../fabric/TerminalLiveFabric.js';
4
- import { terminalFabricCoordinator } from '../fabric/TerminalFabricCoordinator.js';
5
- import { TerminalState, } from '../types.js';
6
- import { terminalInitializationScheduler, } from '../internal/TerminalInitializationScheduler.js';
7
- import { acquireGhosttyRuntime, getGhosttyFitAddonConstructor, getGhosttyLinkConstructors, getGhosttyTerminalConstructor, inspectGhosttyRuntimeMemory, loadGhosttyModules, waitWithAbort, } from '../internal/TerminalResourceLoader.js';
8
- import { capAutoFitTerminalColumns, mapGhosttyScrollbackRowsForPinnedVersion, validateTerminalColumns, validateTerminalScrollbackRows, } from '../internal/GhosttyScrollbackCompat.js';
9
- import { installPinnedGhosttyAlternateScreenProjection, installPinnedGhosttyOutputViewportPolicy, isPinnedGhosttyOutputWriteActive, suppressPinnedGhosttyScrollbarBeforeOpen, suppressPinnedGhosttyScrollbarRenderer, } from '../internal/GhosttyScrollbarCompat.js';
10
- import { createUnicodeSafeUrlProviderTerminal, } from '../internal/UnicodeSafeUrlProvider.js';
11
- import { resolveTerminalInputElement, TerminalInputBridge } from './TerminalInputBridge.js';
12
- import { terminalRenderScheduler } from './TerminalRenderScheduler.js';
13
- import { scheduleUiTurn } from '../internal/scheduleUiTurn.js';
14
- import { parseThemeColor } from '../utils/themeColor.js';
15
- import { normalizeTerminalScrollbarOptions, TerminalScrollbarOverlay, } from './TerminalScrollbarOverlay.js';
16
- const TERMINAL_SNAPSHOT_VERSION = 1;
17
- const DEFAULT_TERMINAL_SNAPSHOT_MAX_BYTES = 2 * 1024 * 1024;
18
- const ESTIMATED_RENDERER_BYTES = 4 * 1024 * 1024;
19
- const SNAPSHOT_RESET_PREFIX = '\x1bc\x1b[2J\x1b[H';
20
- const encodedByteLength = (value) => new TextEncoder().encode(value).byteLength;
21
- const sha256Hex = async (bytes) => {
22
- if (typeof crypto === 'undefined' || !crypto.subtle) {
23
- throw new Error('Web Crypto SHA-256 is unavailable for checkpoint validation');
24
- }
25
- const digest = await crypto.subtle.digest('SHA-256', bytes.slice());
26
- return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
27
- };
28
- const TERMINAL_SEARCH_MAX_RESULTS = 5000;
29
- const PRESENTATION_SCALE_EPSILON = 0.0001;
30
- const GHOSTTY_DEFAULT_SCROLLBAR_RESERVE_PX = 15;
31
- const MIN_TERMINAL_COLS = 2;
32
- const MIN_TERMINAL_ROWS = 1;
33
- // Search highlighting: all matches use yellow background; active match uses yellow + red text.
34
- const TERMINAL_SEARCH_MATCH_BACKGROUND = 'rgba(255, 234, 0, 0.38)';
35
- const TERMINAL_SEARCH_ACTIVE_BACKGROUND = 'rgba(255, 234, 0, 0.72)';
36
- const TERMINAL_SEARCH_ACTIVE_FOREGROUND = '#dc2626';
37
- const TERMINAL_SELECTION_BACKGROUND = '#f5e6b3';
38
- const TERMINAL_SELECTION_FOREGROUND = '#1f2328';
39
- const TERMINAL_THEME_PALETTE_KEYS = [
40
- 'black',
41
- 'red',
42
- 'green',
43
- 'yellow',
44
- 'blue',
45
- 'magenta',
46
- 'cyan',
47
- 'white',
48
- 'brightBlack',
49
- 'brightRed',
50
- 'brightGreen',
51
- 'brightYellow',
52
- 'brightBlue',
53
- 'brightMagenta',
54
- 'brightCyan',
55
- 'brightWhite',
56
- ];
57
- function parsePositiveCSSPixelValue(value) {
58
- if (!value) {
59
- return null;
60
- }
61
- const parsed = Number.parseFloat(value);
62
- return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
63
- }
64
- function resolveCanvasLocalDisplaySize(canvas, coordinateRoot) {
65
- const canvasRect = canvas.getBoundingClientRect();
66
- const rootRect = coordinateRoot.getBoundingClientRect();
67
- const rootWidth = coordinateRoot.clientWidth;
68
- const rootHeight = coordinateRoot.clientHeight;
69
- const ratioWidth = rootRect.width > 0 && rootWidth > 0 && canvasRect.width > 0
70
- ? rootWidth * (canvasRect.width / rootRect.width)
71
- : null;
72
- const ratioHeight = rootRect.height > 0 && rootHeight > 0 && canvasRect.height > 0
73
- ? rootHeight * (canvasRect.height / rootRect.height)
74
- : null;
75
- const computed = (() => {
76
- try {
77
- return getComputedStyle(canvas);
78
- }
79
- catch {
80
- return null;
81
- }
82
- })();
83
- const cssWidth = ratioWidth
84
- ?? parsePositiveCSSPixelValue(canvas.style.width)
85
- ?? parsePositiveCSSPixelValue(computed?.width)
86
- ?? (canvas.offsetWidth > 0 ? canvas.offsetWidth : null)
87
- ?? (canvas.clientWidth > 0 ? canvas.clientWidth : null)
88
- ?? canvas.width;
89
- const cssHeight = ratioHeight
90
- ?? parsePositiveCSSPixelValue(canvas.style.height)
91
- ?? parsePositiveCSSPixelValue(computed?.height)
92
- ?? (canvas.offsetHeight > 0 ? canvas.offsetHeight : null)
93
- ?? (canvas.clientHeight > 0 ? canvas.clientHeight : null)
94
- ?? canvas.height;
95
- const dpr = cssWidth > 0 ? canvas.width / cssWidth : window.devicePixelRatio ?? 1;
96
- return {
97
- cssWidth,
98
- cssHeight,
99
- dpr: Number.isFinite(dpr) && dpr > 0 ? dpr : 1,
100
- };
101
- }
102
- function syncCanvasOverlaySize(overlay, termCanvas, coordinateRoot) {
103
- const { cssWidth, cssHeight, dpr } = resolveCanvasLocalDisplaySize(termCanvas, coordinateRoot);
104
- if (overlay.canvas.width === termCanvas.width
105
- && overlay.canvas.height === termCanvas.height
106
- && overlay.cssWidth === cssWidth
107
- && overlay.cssHeight === cssHeight
108
- && overlay.dpr === dpr) {
109
- return;
110
- }
111
- overlay.dpr = dpr;
112
- overlay.cssWidth = cssWidth;
113
- overlay.cssHeight = cssHeight;
114
- overlay.canvas.style.width = `${cssWidth}px`;
115
- overlay.canvas.style.height = `${cssHeight}px`;
116
- overlay.canvas.width = termCanvas.width;
117
- overlay.canvas.height = termCanvas.height;
118
- overlay.ctx.setTransform(1, 0, 0, 1, 0, 0);
119
- overlay.ctx.scale(dpr, dpr);
120
- }
121
- const getPerfProbe = () => {
122
- if (typeof window === 'undefined') {
123
- return undefined;
124
- }
125
- return window.__floetermPerfProbe;
126
- };
127
- export { preloadTerminalResources } from '../internal/TerminalResourceLoader.js';
128
- const mapThemeToGhostty = (theme) => {
129
- if (!theme) {
130
- return {};
131
- }
132
- const mapped = {};
133
- for (const [key, value] of Object.entries(theme)) {
134
- if (typeof value === 'string') {
135
- mapped[key] = value;
136
- }
137
- }
138
- if (mapped.selection && !mapped.selectionBackground) {
139
- mapped.selectionBackground = mapped.selection;
140
- delete mapped.selection;
141
- }
142
- return mapped;
143
- };
144
- function colorKey(color) {
145
- return `${color.r},${color.g},${color.b}`;
146
- }
147
- function cellColorKey(cell, prefix) {
148
- const r = Number(cell[`${prefix}_r`]);
149
- const g = Number(cell[`${prefix}_g`]);
150
- const b = Number(cell[`${prefix}_b`]);
151
- if (![r, g, b].every(channel => Number.isInteger(channel) && channel >= 0 && channel <= 255)) {
152
- return null;
153
- }
154
- return `${r},${g},${b}`;
155
- }
156
- function colorsEqual(left, right) {
157
- return left.r === right.r && left.g === right.g && left.b === right.b;
158
- }
159
- function setColorTranslation(map, source, target, key) {
160
- const sourceColor = parseThemeColor(source[key]);
161
- const targetColor = parseThemeColor(target[key]);
162
- if (!sourceColor || !targetColor || colorsEqual(sourceColor, targetColor)) {
163
- return;
164
- }
165
- map.set(colorKey(sourceColor), targetColor);
166
- }
167
- function buildThemeColorTranslator(source, target) {
168
- const fg = new Map();
169
- const bg = new Map();
170
- for (const key of TERMINAL_THEME_PALETTE_KEYS) {
171
- setColorTranslation(fg, source, target, key);
172
- setColorTranslation(bg, source, target, key);
173
- }
174
- setColorTranslation(fg, source, target, 'foreground');
175
- setColorTranslation(bg, source, target, 'background');
176
- if (fg.size === 0 && bg.size === 0) {
177
- return null;
178
- }
179
- return { fg, bg };
180
- }
181
- function buildCellSignature(renderer, cell, row, col) {
182
- const grapheme = (cell.grapheme_len ?? 0) > 0
183
- ? String(renderer.currentBuffer?.getGraphemeString?.(row, col) ?? '')
184
- : '';
185
- return [
186
- cell.codepoint ?? 0,
187
- cell.fg_r ?? 0,
188
- cell.fg_g ?? 0,
189
- cell.fg_b ?? 0,
190
- cell.bg_r ?? 0,
191
- cell.bg_g ?? 0,
192
- cell.bg_b ?? 0,
193
- cell.flags ?? 0,
194
- cell.width ?? 0,
195
- cell.hyperlink_id ?? 0,
196
- cell.grapheme_len ?? 0,
197
- grapheme,
198
- ].join(',');
199
- }
200
- function buildRowSignature(renderer, cells, row, cols) {
201
- const parts = [`cols:${cols}`, `len:${cells.length}`];
202
- for (let col = 0; col < cells.length; col += 1) {
203
- parts.push(buildCellSignature(renderer, cells[col] ?? {}, row, col));
204
- }
205
- return parts.join(';');
206
- }
207
- function canSkipCachedRowRender(renderer, row) {
208
- if (renderer.__floetermRowRenderCacheForceAll) {
209
- return false;
210
- }
211
- if (hasTransientRendererState(renderer)) {
212
- renderer.__floetermRowRenderCache?.clear();
213
- return false;
214
- }
215
- const cursorY = renderer.currentBuffer?.getCursor?.()?.y;
216
- if (typeof cursorY === 'number' && cursorY === row) {
217
- return false;
218
- }
219
- const previousCursorY = renderer.lastCursorPosition?.y;
220
- if (typeof previousCursorY === 'number' && previousCursorY === row) {
221
- return false;
222
- }
223
- return true;
224
- }
225
- function canSkipNativeCanvasRowRender(renderer, row, fabricActive) {
226
- void renderer;
227
- void row;
228
- return fabricActive;
229
- }
230
- function samePresentationScale(left, right) {
231
- return Math.abs(left - right) <= PRESENTATION_SCALE_EPSILON;
232
- }
233
- function normalizeTerminalDimensions(value, columnsLabel = 'fixedDimensions.cols') {
234
- const raw = (typeof value === 'object' && value) ? value : null;
235
- if (!raw) {
236
- return null;
237
- }
238
- const cols = validateTerminalColumns(raw.cols, columnsLabel);
239
- const rows = Math.floor(Number(raw.rows));
240
- if (!Number.isFinite(rows) || rows <= 0) {
241
- return null;
242
- }
243
- return { cols, rows };
244
- }
245
- function sameTerminalDimensions(left, right) {
246
- return Boolean(left && right && left.cols === right.cols && left.rows === right.rows);
247
- }
248
- function normalizeNonNegativePixels(value, fallback) {
249
- const parsed = Number(value);
250
- if (!Number.isFinite(parsed) || parsed < 0) {
251
- return fallback;
252
- }
253
- return parsed;
254
- }
255
- function hasUsableClientSize(element) {
256
- return Boolean(element && element.clientWidth > 0 && element.clientHeight > 0);
257
- }
258
- function formatCssPixelValue(value) {
259
- const normalized = Number.isFinite(value) ? Math.max(1, value) : 1;
260
- return `${Math.round(normalized * 1000) / 1000}px`;
261
- }
262
- function hasTransientRendererState(renderer) {
263
- return Boolean(renderer.currentSelectionCoords
264
- || (renderer.hoveredHyperlinkId ?? 0) > 0
265
- || renderer.hoveredLinkRange);
266
- }
267
- function forceTransientRowsOnExit(renderer) {
268
- const active = hasTransientRendererState(renderer);
269
- const previous = Boolean(renderer.__floetermRowRenderCacheHadTransientState);
270
- renderer.__floetermRowRenderCacheHadTransientState = active;
271
- return previous && !active;
272
- }
273
- function normalizeNumberField(record, key) {
274
- if (!record || typeof record !== 'object') {
275
- return null;
276
- }
277
- const value = Number(record[key]);
278
- return Number.isFinite(value) ? value : null;
279
- }
280
- function normalizeSelectionRange(value) {
281
- const startCol = normalizeNumberField(value, 'startCol');
282
- const startRow = normalizeNumberField(value, 'startRow');
283
- const endCol = normalizeNumberField(value, 'endCol');
284
- const endRow = normalizeNumberField(value, 'endRow');
285
- if (startCol === null || startRow === null || endCol === null || endRow === null) {
286
- return null;
287
- }
288
- return {
289
- startCol: Math.max(0, Math.floor(startCol)),
290
- startRow: Math.max(0, Math.floor(startRow)),
291
- endCol: Math.max(0, Math.floor(endCol)),
292
- endRow: Math.max(0, Math.floor(endRow)),
293
- };
294
- }
295
- function normalizeHoverRange(value) {
296
- const startX = normalizeNumberField(value, 'startX');
297
- const startY = normalizeNumberField(value, 'startY');
298
- const endX = normalizeNumberField(value, 'endX');
299
- const endY = normalizeNumberField(value, 'endY');
300
- if (startX === null || startY === null || endX === null || endY === null) {
301
- return null;
302
- }
303
- return {
304
- startX: Math.max(0, Math.floor(startX)),
305
- startY: Math.max(0, Math.floor(startY)),
306
- endX: Math.max(0, Math.floor(endX)),
307
- endY: Math.max(0, Math.floor(endY)),
308
- };
309
- }
310
- function normalizeFabricIdentifier(value) {
311
- return value.trim().replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'terminal';
312
- }
313
- // TerminalCore provides a focused wrapper around @floegence/ghostty-web (xterm.js API-compatible) and its fit addon.
314
- export class TerminalCore {
315
- constructor(container, config = {}, eventHandlers = {}, logger = createConsoleLogger()) {
316
- this.container = container;
317
- this.config = config;
318
- this.terminal = null;
319
- this.ownedRuntime = null;
320
- this.ownedRuntimeMemory = null;
321
- this.fitAddon = null;
322
- this.needsFullRenderOnNextWrite = false;
323
- this.demandRenderForceAll = false;
324
- this.fabricView = null;
325
- this.fabricAttachSeq = 0;
326
- this.fabricAttachPromise = null;
327
- this.fabricAttachWaiters = [];
328
- this.committedFabricFrameGeneration = 0;
329
- this.submittedFabricFrameGeneration = 0;
330
- this.lastCommittedFabricFrame = null;
331
- this.pendingPresentationRequest = null;
332
- this.presentationRequests = new Set();
333
- this.committedFrameRequests = new Set();
334
- this.cancelFabricAttachSchedule = null;
335
- this.cancelInputRefocusSchedule = null;
336
- this.inputElement = null;
337
- this.viewportHost = null;
338
- this.renderHost = null;
339
- this.scrollbarOverlay = null;
340
- this.resizeObserver = null;
341
- this.resizeDebounceTimer = null;
342
- this.resizeRaf = null;
343
- this.focusResizeRaf = null;
344
- this.presentationScaleRaf = null;
345
- this.state = TerminalState.IDLE;
346
- this.isDisposed = false;
347
- this.initializationOperation = null;
348
- this.isReplayingHistory = false;
349
- this.historyWriteDepth = 0;
350
- this.replayingHistoryTimer = null;
351
- this.logicalFontSize = 12;
352
- this.presentationScale = 1;
353
- this.fixedDimensions = null;
354
- this.terminalThemeSource = {};
355
- this.themeColorTranslator = null;
356
- this.fontMetricSeq = 0;
357
- this.fontMetricRefreshPromise = null;
358
- this.pendingPresentationScale = null;
359
- this.suppressResizeNotifications = false;
360
- this.clearResizeSuppressionRaf = null;
361
- this.hasFocus = false;
362
- this.resizeNotifySeq = 0;
363
- this.lastNotifiedSize = null;
364
- this.unbindResponsiveListeners = null;
365
- this.searchQuery = '';
366
- this.searchOptionsKey = '';
367
- this.searchMatches = [];
368
- this.searchMatchIndex = -1;
369
- this.searchRowIndex = new Map();
370
- this.searchResultsCallback = null;
371
- this.searchOverlay = null;
372
- this.searchOverlayRaf = null;
373
- this.searchOverlayUnsubs = [];
374
- // Temporary theme override while search is active.
375
- this.searchThemeRestore = null;
376
- this.inputBridge = null;
377
- this.terminalEventDisposables = [];
378
- this.registeredLinkProviders = new Set();
379
- this.appliedLinkProviders = new Set();
380
- this.visualRenderState = {
381
- suspendDepth: 0,
382
- nextSuspendId: 1,
383
- activeReasons: new Map(),
384
- pendingDemandRender: false,
385
- pendingForceFullRender: false,
386
- pendingResizeReason: null,
387
- pendingSearchOverlayRender: false,
388
- };
389
- const explicitCols = config?.cols;
390
- if (explicitCols !== undefined) {
391
- validateTerminalColumns(explicitCols);
392
- }
393
- this.scrollbackRows = validateTerminalScrollbackRows(config?.scrollback === undefined ? 1000 : config.scrollback);
394
- this.eventHandlers = eventHandlers;
395
- this.clipboard = TerminalCore.normalizeClipboardConfig(config?.clipboard);
396
- this.fit = TerminalCore.normalizeFitConfig(config?.fit);
397
- this.scrollbarOptions = normalizeTerminalScrollbarOptions(config?.scrollbar);
398
- this.logger = logger ?? noopLogger;
399
- this.responsive = TerminalCore.normalizeResponsiveConfig(config?.responsive);
400
- this.logicalFontSize = TerminalCore.normalizeFontSize(config?.fontSize);
401
- this.presentationScale = TerminalCore.normalizePresentationScale(config?.presentationScale);
402
- this.fixedDimensions = normalizeTerminalDimensions(config?.fixedDimensions);
403
- this.config = {
404
- ...config,
405
- ...(explicitCols === undefined ? {} : { cols: explicitCols }),
406
- fixedDimensions: this.fixedDimensions,
407
- scrollback: this.scrollbackRows,
408
- scrollbar: this.scrollbarOptions,
409
- };
410
- this.renderTask = {
411
- id: TerminalCore.nextRenderTaskId,
412
- run: forceAll => {
413
- this.demandRenderForceAll = false;
414
- this.renderDemandFrame(forceAll);
415
- },
416
- };
417
- this.fabricViewId = `floeterm-view-${this.renderTask.id}`;
418
- TerminalCore.nextRenderTaskId += 1;
419
- }
420
- // initialize creates the @floegence/ghostty-web terminal instance and binds addons.
421
- async initialize(options = {}) {
422
- if (this.isDisposed) {
423
- throw new Error('Cannot initialize a disposed TerminalCore');
424
- }
425
- if (this.terminal && this.isReady()) {
426
- return Promise.resolve();
427
- }
428
- const priority = options.priority ?? 'interactive';
429
- let operation = this.initializationOperation;
430
- if (operation) {
431
- if (priority === 'interactive')
432
- operation.request.promote();
433
- return this.waitForInitialization(operation, options.signal);
434
- }
435
- try {
436
- this.setState(TerminalState.INITIALIZING);
437
- }
438
- catch (error) {
439
- this.state = TerminalState.IDLE;
440
- throw error;
441
- }
442
- const request = terminalInitializationScheduler.request(priority);
443
- const lifecycleAbortController = new AbortController();
444
- operation = {
445
- request,
446
- lifecycleAbortController,
447
- started: false,
448
- cancelled: false,
449
- waiters: 0,
450
- promise: Promise.resolve(),
451
- };
452
- operation.promise = (async () => {
453
- const assertInitializationActive = () => {
454
- if (this.isDisposed)
455
- throw new Error('Cannot initialize a disposed TerminalCore');
456
- lifecycleAbortController.signal.throwIfAborted();
457
- };
458
- const permit = await request.permit;
459
- if (!permit) {
460
- if (this.isDisposed)
461
- throw new Error('Cannot initialize a disposed TerminalCore');
462
- if (this.initializationOperation === operation)
463
- this.setState(TerminalState.IDLE);
464
- return;
465
- }
466
- operation.started = true;
467
- try {
468
- assertInitializationActive();
469
- await loadGhosttyModules(this.logger, lifecycleAbortController.signal);
470
- assertInitializationActive();
471
- const ownedRuntime = await acquireGhosttyRuntime(this.logger);
472
- assertInitializationActive();
473
- const ownedRuntimeMemory = inspectGhosttyRuntimeMemory(ownedRuntime);
474
- assertInitializationActive();
475
- this.ownedRuntime = ownedRuntime;
476
- this.ownedRuntimeMemory = ownedRuntimeMemory;
477
- await this.createTerminalInstance(lifecycleAbortController.signal);
478
- assertInitializationActive();
479
- await this.loadAddons();
480
- assertInitializationActive();
481
- await this.openTerminal(lifecycleAbortController.signal);
482
- assertInitializationActive();
483
- this.setupEventListeners();
484
- this.setupResponsiveListeners();
485
- this.startSizeWatching();
486
- this.setState(TerminalState.READY);
487
- this.performResize('post_init');
488
- this.forceFullRender();
489
- setTimeout(() => {
490
- if (!this.isReady())
491
- return;
492
- this.performResize('post_init');
493
- }, 100);
494
- }
495
- catch (error) {
496
- this.cleanupFailedInitialization();
497
- if (!this.isDisposed)
498
- this.setState(TerminalState.IDLE);
499
- throw error;
500
- }
501
- finally {
502
- permit.release();
503
- }
504
- })().finally(() => {
505
- if (this.initializationOperation === operation)
506
- this.initializationOperation = null;
507
- });
508
- this.initializationOperation = operation;
509
- return this.waitForInitialization(operation, options.signal);
510
- }
511
- waitForInitialization(operation, signal) {
512
- operation.waiters += 1;
513
- let settled = false;
514
- const releaseWaiter = () => {
515
- if (settled)
516
- return;
517
- settled = true;
518
- operation.waiters = Math.max(0, operation.waiters - 1);
519
- if (operation.waiters !== 0 || operation.cancelled)
520
- return;
521
- if (operation.started) {
522
- if (this.initializationOperation === operation
523
- && !this.isDisposed
524
- && this.state === TerminalState.INITIALIZING) {
525
- operation.cancelled = true;
526
- // Ghostty.load() cannot be cancelled. Abort the Core lifecycle so the
527
- // owned runtime is discarded when that load settles, while the
528
- // initialization permit remains held by the operation's finally path.
529
- operation.lifecycleAbortController.abort();
530
- }
531
- return;
532
- }
533
- operation.cancelled = true;
534
- operation.request.cancel();
535
- if (this.initializationOperation === operation) {
536
- this.initializationOperation = null;
537
- if (!this.isDisposed)
538
- this.setState(TerminalState.IDLE);
539
- }
540
- };
541
- return waitWithAbort(operation.promise, signal).finally(releaseWaiter);
542
- }
543
- cleanupFailedInitialization() {
544
- this.unbindResponsiveListeners?.();
545
- this.unbindResponsiveListeners = null;
546
- this.resizeObserver?.disconnect();
547
- this.resizeObserver = null;
548
- this.disposeInputBridge();
549
- this.disposeTerminalEventListeners();
550
- this.scrollbarOverlay?.dispose();
551
- this.scrollbarOverlay = null;
552
- this.fabricView?.dispose();
553
- this.fabricView = null;
554
- this.terminal?.dispose();
555
- this.terminal = null;
556
- this.ownedRuntimeMemory = null;
557
- this.ownedRuntime = null;
558
- this.inputElement?.remove();
559
- this.inputElement = null;
560
- this.fitAddon = null;
561
- this.viewportHost?.remove();
562
- this.viewportHost = null;
563
- this.renderHost = null;
564
- }
565
- async createTerminalInstance(signal) {
566
- const TerminalCtor = getGhosttyTerminalConstructor();
567
- if (!TerminalCtor) {
568
- throw new Error('@floegence/ghostty-web module not loaded');
569
- }
570
- if (!this.ownedRuntime) {
571
- throw new Error('@floegence/ghostty-web runtime not acquired');
572
- }
573
- const defaultConfig = {
574
- cols: 80,
575
- rows: 24,
576
- theme: {
577
- background: '#1a1a1a',
578
- foreground: '#ffffff',
579
- cursor: '#ffffff',
580
- selectionBackground: TERMINAL_SELECTION_BACKGROUND,
581
- selectionForeground: TERMINAL_SELECTION_FOREGROUND
582
- },
583
- fontSize: 12,
584
- fontFamily: '\"SF Mono\", Monaco, \"Cascadia Code\", \"Roboto Mono\", Consolas, \"Courier New\", monospace',
585
- cursorBlink: false,
586
- scrollback: 1000,
587
- allowTransparency: false,
588
- convertEol: true,
589
- cursorStyle: 'block',
590
- disableStdin: false,
591
- smoothScrollDuration: 80
592
- };
593
- const finalConfig = { ...defaultConfig, ...this.config };
594
- const initialTheme = mapThemeToGhostty(finalConfig.theme);
595
- this.terminalThemeSource = initialTheme;
596
- this.themeColorTranslator = null;
597
- this.logicalFontSize = TerminalCore.normalizeFontSize(finalConfig.fontSize);
598
- this.presentationScale = TerminalCore.normalizePresentationScale(finalConfig.presentationScale);
599
- await waitWithAbort(this.waitForTerminalFontReady(typeof finalConfig.fontFamily === 'string' ? finalConfig.fontFamily : undefined, this.resolveEffectiveFontSize(), 'initial'), signal);
600
- const initialDimensions = this.fixedDimensions;
601
- this.terminal = new TerminalCtor({
602
- cols: initialDimensions?.cols ?? (typeof finalConfig.cols === 'number' ? finalConfig.cols : undefined),
603
- rows: initialDimensions?.rows ?? (typeof finalConfig.rows === 'number' ? finalConfig.rows : undefined),
604
- cursorBlink: typeof finalConfig.cursorBlink === 'boolean' ? finalConfig.cursorBlink : undefined,
605
- cursorStyle: typeof finalConfig.cursorStyle === 'string' ? finalConfig.cursorStyle : undefined,
606
- theme: initialTheme,
607
- scrollback: mapGhosttyScrollbackRowsForPinnedVersion(this.scrollbackRows),
608
- fontSize: this.resolveEffectiveFontSize(),
609
- fontFamily: typeof finalConfig.fontFamily === 'string' ? finalConfig.fontFamily : undefined,
610
- allowTransparency: typeof finalConfig.allowTransparency === 'boolean' ? finalConfig.allowTransparency : undefined,
611
- convertEol: typeof finalConfig.convertEol === 'boolean' ? finalConfig.convertEol : undefined,
612
- disableStdin: typeof finalConfig.disableStdin === 'boolean' ? finalConfig.disableStdin : undefined,
613
- smoothScrollDuration: typeof finalConfig.smoothScrollDuration === 'number' ? finalConfig.smoothScrollDuration : undefined,
614
- ghostty: this.ownedRuntime,
615
- });
616
- }
617
- async loadAddons() {
618
- if (!this.terminal) {
619
- throw new Error('Terminal instance not created');
620
- }
621
- const FitAddonCtor = getGhosttyFitAddonConstructor();
622
- if (!FitAddonCtor) {
623
- throw new Error('Required @floegence/ghostty-web addons not loaded');
624
- }
625
- this.fitAddon = new FitAddonCtor();
626
- this.terminal.loadAddon(this.fitAddon);
627
- this.installFitAddonGeometryPatch();
628
- }
629
- async openTerminal(signal) {
630
- if (!this.terminal) {
631
- throw new Error('Terminal instance not created');
632
- }
633
- await waitWithAbort(this.waitForDOMAndFonts(), signal);
634
- await waitWithAbort(this.ensureContainerReady(), signal);
635
- const renderHost = this.ensurePresentationHosts();
636
- this.applyPresentationScaleStyles();
637
- this.installDemandRenderPatchBeforeOpen();
638
- suppressPinnedGhosttyScrollbarBeforeOpen(this.terminal);
639
- this.terminal.open(renderHost);
640
- installPinnedGhosttyAlternateScreenProjection(this.terminal);
641
- installPinnedGhosttyOutputViewportPolicy(this.terminal);
642
- const renderer = this.terminal.renderer;
643
- suppressPinnedGhosttyScrollbarRenderer(renderer ?? {});
644
- this.installLinkDetector();
645
- if (this.config.rendererType === 'webgl') {
646
- const canvas = this.terminal
647
- .renderer?.getCanvas?.();
648
- if (canvas)
649
- canvas.style.opacity = '0';
650
- }
651
- this.mountInputElement(renderHost);
652
- this.stopGhosttyRenderLoop();
653
- this.patchDemandRenderTriggersAfterOpen();
654
- this.patchSelectionManagerClipboardBehavior();
655
- this.patchSelectionManagerRenderingBehavior();
656
- this.setupInputBridge(renderHost);
657
- this.mountScrollbarOverlay();
658
- this.applyRegisteredLinkProviders();
659
- void this.refreshFontMetricsAfterLoad('open');
660
- }
661
- mountInputElement(renderHost) {
662
- const input = resolveTerminalInputElement(renderHost);
663
- if (!input) {
664
- throw new Error('@floegence/ghostty-web terminal input element was not created');
665
- }
666
- const body = this.container.ownerDocument.body;
667
- if (!body) {
668
- throw new Error('Terminal input requires a document body');
669
- }
670
- body.appendChild(input);
671
- this.inputElement = input;
672
- }
673
- mountScrollbarOverlay() {
674
- if (!this.terminal || !this.viewportHost || !this.renderHost) {
675
- throw new Error('Terminal scrollbar requires an initialized presentation host');
676
- }
677
- this.scrollbarOverlay?.dispose();
678
- const terminal = this.terminal;
679
- const surfaceId = `${this.fabricViewId}-surface`;
680
- this.renderHost.id = surfaceId;
681
- this.scrollbarOverlay = new TerminalScrollbarOverlay(this.viewportHost, {
682
- get rows() {
683
- return terminal.rows;
684
- },
685
- getScrollbackLength: () => terminal.getScrollbackLength(),
686
- getViewportY: () => terminal.getViewportY(),
687
- isAlternateScreen: () => terminal.isAlternateScreen(),
688
- scrollToLine: line => terminal.scrollToLine(line),
689
- scrollToTop: () => terminal.scrollToTop(),
690
- scrollToBottom: () => terminal.scrollToBottom(),
691
- scrollLines: amount => terminal.scrollLines(amount),
692
- scrollPages: amount => terminal.scrollPages(amount),
693
- focusTerminal: () => this.scheduleInputSurfaceRefocus({ preventScroll: true }),
694
- }, this.scrollbarOptions, surfaceId);
695
- this.scrollbarOverlay.setBackgroundColor(this.terminalThemeSource.background);
696
- }
697
- installLinkDetector() {
698
- if (!this.terminal) {
699
- throw new Error('Terminal instance not created');
700
- }
701
- const { LinkDetector, OSC8LinkProvider, UrlRegexProvider, } = getGhosttyLinkConstructors();
702
- const detector = new LinkDetector(this.terminal);
703
- detector.registerProvider(new OSC8LinkProvider(this.terminal));
704
- detector.registerProvider(new UrlRegexProvider(createUnicodeSafeUrlProviderTerminal(this.terminal)));
705
- this.terminal.linkDetector = detector;
706
- }
707
- installFitAddonGeometryPatch() {
708
- const fitAddon = this.fitAddon;
709
- if (!fitAddon || fitAddon.__floetermGeometryPatchApplied) {
710
- return;
711
- }
712
- fitAddon.__floetermGeometryPatchApplied = true;
713
- fitAddon.proposeDimensions = () => this.proposeFitDimensions();
714
- }
715
- proposeFitDimensions() {
716
- const fabricDimensions = this.proposeFabricFitDimensions();
717
- if (fabricDimensions) {
718
- return fabricDimensions;
719
- }
720
- const terminalAny = this.terminal;
721
- const renderer = terminalAny?.renderer;
722
- const metrics = typeof renderer?.getMetrics === 'function' ? renderer.getMetrics() : null;
723
- const cellWidth = Number(metrics?.width);
724
- const cellHeight = Number(metrics?.height);
725
- if (!Number.isFinite(cellWidth) || !Number.isFinite(cellHeight) || cellWidth <= 0 || cellHeight <= 0) {
726
- return undefined;
727
- }
728
- const element = this.resolveFitElement(terminalAny?.element);
729
- if (!element || typeof element.clientWidth === 'undefined') {
730
- return undefined;
731
- }
732
- const width = element.clientWidth;
733
- const height = element.clientHeight;
734
- if (width === 0 || height === 0) {
735
- return undefined;
736
- }
737
- const style = getComputedStyle(element);
738
- const paddingTop = Number.parseInt(style.getPropertyValue('padding-top')) || 0;
739
- const paddingBottom = Number.parseInt(style.getPropertyValue('padding-bottom')) || 0;
740
- const paddingLeft = Number.parseInt(style.getPropertyValue('padding-left')) || 0;
741
- const paddingRight = Number.parseInt(style.getPropertyValue('padding-right')) || 0;
742
- const availableWidth = Math.max(0, width - paddingLeft - paddingRight - this.fit.scrollbarReservePx);
743
- const availableHeight = Math.max(0, height - paddingTop - paddingBottom);
744
- return {
745
- cols: capAutoFitTerminalColumns(Math.max(MIN_TERMINAL_COLS, Math.floor(availableWidth / cellWidth))),
746
- rows: Math.max(MIN_TERMINAL_ROWS, Math.floor(availableHeight / cellHeight)),
747
- };
748
- }
749
- proposeFabricFitDimensions() {
750
- if (this.config.rendererType !== 'webgl') {
751
- return null;
752
- }
753
- const geometry = this.fabricView?.renderer.getGeometry();
754
- if (!geometry) {
755
- return null;
756
- }
757
- const cellWidth = Number(geometry.cellWidth);
758
- const hostWidth = Number(this.viewportHost?.clientWidth ?? this.container.clientWidth);
759
- const visibleCellWidth = cellWidth / this.presentationScale;
760
- const cols = Number.isFinite(hostWidth) && hostWidth > 0
761
- && Number.isFinite(visibleCellWidth) && visibleCellWidth > 0
762
- ? Math.floor(Math.max(0, hostWidth - this.fit.scrollbarReservePx) / visibleCellWidth)
763
- : Math.floor(Number(geometry.cols));
764
- const rows = Math.floor(Number(geometry.rows));
765
- if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols <= 0 || rows <= 0) {
766
- return null;
767
- }
768
- return {
769
- cols: capAutoFitTerminalColumns(Math.max(MIN_TERMINAL_COLS, cols)),
770
- rows: Math.max(MIN_TERMINAL_ROWS, rows),
771
- };
772
- }
773
- syncGhosttyInputGeometryToFabric() {
774
- if (!this.terminal || !this.isFabricRendererActive()) {
775
- return;
776
- }
777
- const geometry = this.fabricView?.renderer.getGeometry();
778
- if (!geometry) {
779
- return;
780
- }
781
- const terminalAny = this.terminal;
782
- const renderer = terminalAny.renderer;
783
- const canvas = renderer?.getCanvas?.();
784
- const cols = Math.floor(Number(terminalAny.cols ?? geometry.cols));
785
- const rows = Math.floor(Number(terminalAny.rows ?? geometry.rows));
786
- const cellWidth = Number(geometry.cellWidth);
787
- const cellHeight = Number(geometry.cellHeight);
788
- if (!renderer
789
- || !canvas
790
- || !Number.isFinite(cols)
791
- || !Number.isFinite(rows)
792
- || !Number.isFinite(cellWidth)
793
- || !Number.isFinite(cellHeight)
794
- || cols <= 0
795
- || rows <= 0
796
- || cellWidth <= 0
797
- || cellHeight <= 0) {
798
- return;
799
- }
800
- const currentMetrics = renderer.getMetrics?.() ?? renderer.metrics ?? {};
801
- const baselineRatio = Number(currentMetrics.height) > 0
802
- ? Number(currentMetrics.baseline ?? cellHeight * 0.8) / Number(currentMetrics.height)
803
- : 0.8;
804
- const nextMetrics = {
805
- ...currentMetrics,
806
- width: cellWidth,
807
- height: cellHeight,
808
- baseline: Math.max(1, Math.round(cellHeight * baselineRatio)),
809
- };
810
- renderer.metrics = nextMetrics;
811
- const gridWidth = cols * cellWidth;
812
- const gridHeight = rows * cellHeight;
813
- canvas.style.width = formatCssPixelValue(gridWidth);
814
- canvas.style.height = formatCssPixelValue(gridHeight);
815
- canvas.style.maxWidth = 'none';
816
- canvas.style.maxHeight = 'none';
817
- const canvasDpr = canvas.width > 0 && gridWidth > 0
818
- ? canvas.width / gridWidth
819
- : window.devicePixelRatio ?? 1;
820
- const dpr = Number.isFinite(canvasDpr) && canvasDpr > 0 ? canvasDpr : 1;
821
- const expectedWidth = Math.max(1, Math.round(gridWidth * dpr));
822
- const expectedHeight = Math.max(1, Math.round(gridHeight * dpr));
823
- if (canvas.width !== expectedWidth || canvas.height !== expectedHeight) {
824
- canvas.width = expectedWidth;
825
- canvas.height = expectedHeight;
826
- }
827
- canvas.style.position = 'absolute';
828
- canvas.style.left = '0';
829
- canvas.style.top = '0';
830
- canvas.style.inset = '0 auto auto 0';
831
- canvas.style.zIndex = '1';
832
- this.syncImeInputAnchor();
833
- }
834
- syncImeInputAnchor() {
835
- const input = this.inputElement;
836
- if (!input || this.shouldPreserveTransientInputPlacement(input)) {
837
- return;
838
- }
839
- const geometry = this.resolveImeInputAnchorGeometry();
840
- const cursor = this.resolveImeCursorPosition();
841
- if (!geometry || !cursor) {
842
- return;
843
- }
844
- const canvasRect = geometry.canvas.getBoundingClientRect();
845
- const canvasClientWidth = Number(geometry.canvas.clientWidth);
846
- const canvasClientHeight = Number(geometry.canvas.clientHeight);
847
- if (!Number.isFinite(canvasClientWidth)
848
- || !Number.isFinite(canvasClientHeight)
849
- || canvasClientWidth <= 0
850
- || canvasClientHeight <= 0) {
851
- return;
852
- }
853
- const visibleScaleX = Number(canvasRect.width) / canvasClientWidth;
854
- const visibleScaleY = Number(canvasRect.height) / canvasClientHeight;
855
- const cellWidth = geometry.cellWidth * visibleScaleX;
856
- const cellHeight = geometry.cellHeight * visibleScaleY;
857
- const left = Number(canvasRect.left) + cursor.x * cellWidth;
858
- const top = Number(canvasRect.top) + cursor.y * cellHeight;
859
- if (!Number.isFinite(left)
860
- || !Number.isFinite(top)
861
- || !Number.isFinite(cellWidth)
862
- || !Number.isFinite(cellHeight)
863
- || cellWidth <= 0
864
- || cellHeight <= 0) {
865
- return;
866
- }
867
- input.style.position = 'fixed';
868
- input.style.left = formatCssPixelValue(left);
869
- input.style.top = formatCssPixelValue(top);
870
- input.style.width = formatCssPixelValue(cellWidth);
871
- input.style.height = formatCssPixelValue(cellHeight);
872
- input.style.lineHeight = formatCssPixelValue(cellHeight);
873
- input.style.padding = '0';
874
- input.style.border = 'none';
875
- input.style.margin = '0';
876
- input.style.opacity = '0';
877
- input.style.overflow = 'hidden';
878
- input.style.whiteSpace = 'nowrap';
879
- input.style.resize = 'none';
880
- input.style.clipPath = 'none';
881
- input.style.pointerEvents = 'none';
882
- input.style.zIndex = '0';
883
- }
884
- shouldPreserveTransientInputPlacement(input) {
885
- return input.style.pointerEvents === 'auto' || input.style.zIndex === '1000';
886
- }
887
- resolveImeInputAnchorGeometry() {
888
- const terminalAny = this.terminal;
889
- const renderer = terminalAny?.renderer ?? null;
890
- const canvas = renderer?.getCanvas?.() ?? this.container.querySelector('canvas');
891
- if (!(canvas instanceof HTMLCanvasElement)) {
892
- return null;
893
- }
894
- const fabricGeometry = this.isFabricRendererActive()
895
- ? this.fabricView?.renderer.getGeometry()
896
- : null;
897
- if (fabricGeometry) {
898
- const cellWidth = Number(fabricGeometry.cellWidth);
899
- const cellHeight = Number(fabricGeometry.cellHeight);
900
- const cols = Math.floor(Number(terminalAny?.cols ?? fabricGeometry.cols));
901
- const rows = Math.floor(Number(terminalAny?.rows ?? fabricGeometry.rows));
902
- if (this.isUsableAnchorGeometry(cellWidth, cellHeight, cols, rows)) {
903
- return { canvas, cellWidth, cellHeight, cols, rows };
904
- }
905
- }
906
- const metrics = renderer?.getMetrics?.() ?? renderer?.metrics ?? {};
907
- const cols = Math.floor(Number(terminalAny?.cols));
908
- const rows = Math.floor(Number(terminalAny?.rows));
909
- const fallbackCellWidth = cols > 0 ? Number(canvas.clientWidth) / cols : Number.NaN;
910
- const fallbackCellHeight = rows > 0 ? Number(canvas.clientHeight) / rows : Number.NaN;
911
- const cellWidth = Number(metrics.width ?? renderer?.charWidth ?? fallbackCellWidth);
912
- const cellHeight = Number(metrics.height ?? renderer?.charHeight ?? fallbackCellHeight);
913
- if (!this.isUsableAnchorGeometry(cellWidth, cellHeight, cols, rows)) {
914
- return null;
915
- }
916
- return { canvas, cellWidth, cellHeight, cols, rows };
917
- }
918
- isUsableAnchorGeometry(cellWidth, cellHeight, cols, rows) {
919
- return Number.isFinite(cellWidth)
920
- && Number.isFinite(cellHeight)
921
- && Number.isFinite(cols)
922
- && Number.isFinite(rows)
923
- && cellWidth > 0
924
- && cellHeight > 0
925
- && cols > 0
926
- && rows > 0;
927
- }
928
- resolveImeCursorPosition() {
929
- const terminalAny = this.terminal;
930
- const cursor = terminalAny?.wasmTerm?.getCursor?.();
931
- if (!cursor) {
932
- return null;
933
- }
934
- const cols = Math.max(1, Math.floor(Number(terminalAny?.cols ?? 1)));
935
- const rows = Math.max(1, Math.floor(Number(terminalAny?.rows ?? 1)));
936
- const rawX = Number(cursor.viewportX ?? cursor.x);
937
- const rawY = Number(cursor.viewportY ?? cursor.y);
938
- if (!Number.isFinite(rawX) || !Number.isFinite(rawY)) {
939
- return null;
940
- }
941
- return {
942
- x: Math.max(0, Math.min(cols - 1, Math.floor(rawX))),
943
- y: Math.max(0, Math.min(rows - 1, Math.floor(rawY))),
944
- };
945
- }
946
- focusInputSurface(options) {
947
- this.inputBridge?.focus(options);
948
- }
949
- scheduleInputSurfaceRefocus(options) {
950
- this.cancelInputRefocusSchedule?.();
951
- this.cancelInputRefocusSchedule = scheduleUiTurn(() => {
952
- this.cancelInputRefocusSchedule = null;
953
- if (this.isDisposed) {
954
- return;
955
- }
956
- this.focusInputSurface(options);
957
- });
958
- }
959
- resolveFitElement(terminalElement) {
960
- const candidates = [
961
- this.renderHost,
962
- this.viewportHost,
963
- this.container,
964
- terminalElement,
965
- ];
966
- return candidates.find(hasUsableClientSize) ?? terminalElement ?? this.renderHost ?? this.container;
967
- }
968
- installDemandRenderPatchBeforeOpen() {
969
- const terminalAny = this.terminal;
970
- if (!terminalAny || terminalAny.__floetermDemandRenderLoopPatched) {
971
- return;
972
- }
973
- terminalAny.__floetermDemandRenderLoopPatched = true;
974
- // @floegence/ghostty-web currently starts a perpetual RAF loop from open(). TerminalCore
975
- // already knows every meaningful invalidation point, so keep rendering
976
- // demand-driven while preserving the upstream terminal surface.
977
- terminalAny.startRenderLoop = () => {
978
- this.requestDemandRender(false);
979
- };
980
- }
981
- patchDemandRenderTriggersAfterOpen() {
982
- const terminalAny = this.terminal;
983
- if (!terminalAny || terminalAny.__floetermDemandRenderTriggersPatched) {
984
- return;
985
- }
986
- terminalAny.__floetermDemandRenderTriggersPatched = true;
987
- if (typeof terminalAny.animateScroll === 'function') {
988
- const originalAnimateScroll = terminalAny.animateScroll.bind(terminalAny);
989
- terminalAny.animateScroll = (...args) => {
990
- const result = originalAnimateScroll(...args);
991
- this.requestDemandRender(false);
992
- return result;
993
- };
994
- }
995
- const renderer = terminalAny.renderer;
996
- this.installRendererRowCachePatch(renderer);
997
- if (!renderer || renderer.__floetermDemandRenderPatched) {
998
- return;
999
- }
1000
- renderer.__floetermDemandRenderPatched = true;
1001
- const wrapRendererInvalidation = (method) => {
1002
- const original = renderer[method];
1003
- if (typeof original !== 'function') {
1004
- return;
1005
- }
1006
- renderer[method] = ((...args) => {
1007
- const result = original.apply(renderer, args);
1008
- this.requestDemandRender(false);
1009
- return result;
1010
- });
1011
- };
1012
- wrapRendererInvalidation('setHoveredHyperlinkId');
1013
- wrapRendererInvalidation('setHoveredLinkRange');
1014
- wrapRendererInvalidation('setCursorStyle');
1015
- wrapRendererInvalidation('setCursorBlink');
1016
- wrapRendererInvalidation('setTheme');
1017
- wrapRendererInvalidation('setFontSize');
1018
- wrapRendererInvalidation('setFontFamily');
1019
- wrapRendererInvalidation('clear');
1020
- this.patchRendererCursorBlinkForDemandRendering(renderer);
1021
- }
1022
- patchRendererCursorBlinkForDemandRendering(renderer) {
1023
- if (!renderer || renderer.__floetermDemandCursorPatched) {
1024
- return;
1025
- }
1026
- renderer.__floetermDemandCursorPatched = true;
1027
- renderer.cursorVisible = true;
1028
- try {
1029
- renderer.setCursorBlink?.(false);
1030
- }
1031
- catch (error) {
1032
- this.logger.debug('[TerminalCore] Failed to disable cursor blink for demand rendering', { error });
1033
- }
1034
- if (this.terminal) {
1035
- this.terminal.options.cursorBlink = false;
1036
- }
1037
- }
1038
- keepDemandCursorVisible(renderer) {
1039
- if (!renderer) {
1040
- return;
1041
- }
1042
- renderer.cursorVisible = true;
1043
- }
1044
- installRendererRowCachePatch(renderer) {
1045
- if (!renderer || renderer.__floetermRowRenderCachePatched) {
1046
- return;
1047
- }
1048
- if (typeof renderer.render !== 'function' || typeof renderer.renderLine !== 'function') {
1049
- return;
1050
- }
1051
- const originalRender = renderer.render.bind(renderer);
1052
- const originalRenderLine = renderer.renderLine.bind(renderer);
1053
- const cache = new Map();
1054
- renderer.__floetermRowRenderCachePatched = true;
1055
- renderer.__floetermRowRenderCache = cache;
1056
- renderer.render = (...args) => {
1057
- const incomingViewportY = Number(args[2] ?? 0);
1058
- const currentViewportY = Number(renderer.lastViewportY ?? 0);
1059
- const viewportChanged = Number.isFinite(incomingViewportY)
1060
- && Number.isFinite(currentViewportY)
1061
- && incomingViewportY !== currentViewportY;
1062
- const forceTransientExit = forceTransientRowsOnExit(renderer);
1063
- const forceAll = Boolean(args[1]) || viewportChanged || forceTransientExit;
1064
- renderer.__floetermRowRenderCacheForceAll = forceAll;
1065
- const fabricFrame = this.beginFabricFrame(forceAll, args[0]);
1066
- try {
1067
- const renderArgs = [...args];
1068
- renderArgs[1] = forceAll;
1069
- return originalRender(...renderArgs);
1070
- }
1071
- finally {
1072
- this.finishFabricFrame(fabricFrame);
1073
- renderer.__floetermRowRenderCacheForceAll = false;
1074
- }
1075
- };
1076
- renderer.renderLine = (cells, row, cols) => {
1077
- const themedCells = this.translateCellsForTheme(cells);
1078
- const fabricActive = this.writeFabricRow(renderer, row, themedCells, cols);
1079
- if (canSkipNativeCanvasRowRender(renderer, row, fabricActive)) {
1080
- return undefined;
1081
- }
1082
- const signature = buildRowSignature(renderer, themedCells, row, cols);
1083
- if (canSkipCachedRowRender(renderer, row) && cache.get(row) === signature) {
1084
- return undefined;
1085
- }
1086
- const result = originalRenderLine(themedCells, row, cols);
1087
- if (hasTransientRendererState(renderer)) {
1088
- cache.delete(row);
1089
- return result;
1090
- }
1091
- cache.set(row, signature);
1092
- return result;
1093
- };
1094
- }
1095
- beginFabricFrame(forceAll, buffer) {
1096
- const renderer = this.fabricView?.renderer;
1097
- if (!renderer?.isActive()) {
1098
- return null;
1099
- }
1100
- const dimensions = this.resolveFabricDimensions(buffer);
1101
- if (!dimensions) {
1102
- return null;
1103
- }
1104
- const frame = terminalFabricCoordinator.beginFrame(renderReasonFromForce(forceAll), forceAll);
1105
- const theme = this.resolveFabricTheme();
1106
- renderer.startFrame(frame, {
1107
- cols: dimensions.cols,
1108
- rows: dimensions.rows,
1109
- theme: themeToFabricTheme(theme),
1110
- });
1111
- return frame;
1112
- }
1113
- resolveFabricDimensions(buffer) {
1114
- const raw = buffer;
1115
- const dimensions = raw?.getDimensions?.();
1116
- const cols = Math.floor(Number(dimensions?.cols ?? this.terminal?.cols ?? 0));
1117
- const rows = Math.floor(Number(dimensions?.rows ?? this.terminal?.rows ?? 0));
1118
- if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols <= 0 || rows <= 0) {
1119
- return null;
1120
- }
1121
- return { cols, rows };
1122
- }
1123
- writeFabricRow(renderer, row, cells, cols) {
1124
- const fabricRenderer = this.fabricView?.renderer;
1125
- if (!fabricRenderer?.isActive()) {
1126
- return false;
1127
- }
1128
- fabricRenderer.writeRow(renderer, row, cells, cols, this.resolveFabricRowRenderHints(renderer));
1129
- return true;
1130
- }
1131
- resolveFabricRowRenderHints(renderer) {
1132
- return {
1133
- selection: this.resolveFabricSelectionHint(renderer),
1134
- hover: {
1135
- hyperlinkId: Number(renderer.hoveredHyperlinkId ?? 0),
1136
- range: normalizeHoverRange(renderer.hoveredLinkRange),
1137
- },
1138
- };
1139
- }
1140
- resolveFabricSelectionHint(renderer) {
1141
- const range = normalizeSelectionRange(renderer.currentSelectionCoords);
1142
- if (!range) {
1143
- return null;
1144
- }
1145
- const theme = this.resolveFabricTheme();
1146
- return {
1147
- ...range,
1148
- background: parseThemeColor(theme.selectionBackground ?? TERMINAL_SELECTION_BACKGROUND)
1149
- ?? parseThemeColor(TERMINAL_SELECTION_BACKGROUND),
1150
- foreground: parseThemeColor(theme.selectionForeground ?? TERMINAL_SELECTION_FOREGROUND)
1151
- ?? parseThemeColor(TERMINAL_SELECTION_FOREGROUND),
1152
- };
1153
- }
1154
- syncFabricSelectionState() {
1155
- const renderer = this.terminal?.renderer;
1156
- const selectionManager = this.terminal?.selectionManager;
1157
- if (!renderer || !selectionManager) {
1158
- return;
1159
- }
1160
- renderer.currentSelectionCoords = selectionManager.hasSelection?.()
1161
- ? selectionManager.getSelectionCoords?.() ?? null
1162
- : null;
1163
- renderer.__floetermRowRenderCache?.clear();
1164
- this.demandRenderForceAll = true;
1165
- }
1166
- finishFabricFrame(frame) {
1167
- const fabricRenderer = this.fabricView?.renderer;
1168
- if (!frame || !fabricRenderer?.isActive()) {
1169
- return;
1170
- }
1171
- const terminalAny = this.terminal;
1172
- const result = fabricRenderer.finishFrame(cursorToFabricCursor(terminalAny?.wasmTerm?.getCursor?.()));
1173
- if (result.rendered) {
1174
- terminalFabricCoordinator.completeFrame(frame, result.renderedRows, result.dirtyCells);
1175
- this.committedFabricFrameGeneration += 1;
1176
- this.lastCommittedFabricFrame = {
1177
- generation: this.committedFabricFrameGeneration,
1178
- result,
1179
- };
1180
- }
1181
- }
1182
- resolveFabricTheme() {
1183
- if (this.searchQuery.trim().length > 0 && this.searchThemeRestore) {
1184
- return this.applySearchThemeOverride(this.searchThemeRestore);
1185
- }
1186
- const configured = mapThemeToGhostty(this.config.theme);
1187
- return Object.keys(configured).length > 0 ? configured : this.terminalThemeSource;
1188
- }
1189
- translateCellsForTheme(cells) {
1190
- const translator = this.themeColorTranslator;
1191
- if (!translator) {
1192
- return cells;
1193
- }
1194
- let translatedCells = null;
1195
- for (let index = 0; index < cells.length; index += 1) {
1196
- const cell = translatedCells?.[index] ?? cells[index] ?? {};
1197
- const translated = this.translateCellForTheme(cell, translator);
1198
- if (translated !== cell && !translatedCells) {
1199
- translatedCells = cells.slice(0, index);
1200
- }
1201
- translatedCells?.push(translated);
1202
- }
1203
- return translatedCells ?? cells;
1204
- }
1205
- translateCellForTheme(cell, translator) {
1206
- const fgKey = cellColorKey(cell, 'fg');
1207
- const bgKey = cellColorKey(cell, 'bg');
1208
- const fg = fgKey ? translator.fg.get(fgKey) : undefined;
1209
- const bg = bgKey ? translator.bg.get(bgKey) : undefined;
1210
- if (!fg && !bg) {
1211
- return cell;
1212
- }
1213
- return {
1214
- ...cell,
1215
- ...(fg ? { fg_r: fg.r, fg_g: fg.g, fg_b: fg.b } : {}),
1216
- ...(bg ? { bg_r: bg.r, bg_g: bg.g, bg_b: bg.b } : {}),
1217
- };
1218
- }
1219
- ensurePresentationHosts() {
1220
- if (this.viewportHost && this.renderHost) {
1221
- return this.renderHost;
1222
- }
1223
- if (this.viewportHost || this.renderHost) {
1224
- throw new Error('Terminal presentation host state is inconsistent');
1225
- }
1226
- const viewportHost = document.createElement('div');
1227
- viewportHost.style.position = 'relative';
1228
- viewportHost.style.width = '100%';
1229
- viewportHost.style.height = '100%';
1230
- viewportHost.style.overflow = 'hidden';
1231
- const renderHost = document.createElement('div');
1232
- renderHost.style.position = 'absolute';
1233
- renderHost.style.inset = '0';
1234
- renderHost.style.overflow = 'hidden';
1235
- renderHost.style.transformOrigin = 'top left';
1236
- renderHost.style.willChange = 'transform';
1237
- viewportHost.appendChild(renderHost);
1238
- this.container.replaceChildren(viewportHost);
1239
- this.viewportHost = viewportHost;
1240
- this.renderHost = renderHost;
1241
- return renderHost;
1242
- }
1243
- applyPresentationScaleStyles() {
1244
- const renderHost = this.renderHost;
1245
- if (!renderHost) {
1246
- return;
1247
- }
1248
- const scale = this.pendingPresentationScale ?? this.presentationScale;
1249
- if (scale <= 1.001) {
1250
- renderHost.style.width = '100%';
1251
- renderHost.style.height = '100%';
1252
- renderHost.style.transform = 'none';
1253
- return;
1254
- }
1255
- renderHost.style.width = `${scale * 100}%`;
1256
- renderHost.style.height = `${scale * 100}%`;
1257
- renderHost.style.transform = `scale(${1 / scale})`;
1258
- }
1259
- patchSelectionManagerClipboardBehavior() {
1260
- if (this.clipboard.copyOnSelect) {
1261
- return;
1262
- }
1263
- const selectionManager = (this.terminal
1264
- ?.selectionManager) ?? null;
1265
- if (!selectionManager || typeof selectionManager.copyToClipboard !== 'function') {
1266
- return;
1267
- }
1268
- selectionManager.copyToClipboard = async () => {
1269
- // Disable the upstream copy-on-select side effect while keeping
1270
- // the selection lifecycle intact for explicit copy commands.
1271
- };
1272
- }
1273
- patchSelectionManagerRenderingBehavior() {
1274
- const selectionManager = (this.terminal
1275
- ?.selectionManager) ?? null;
1276
- if (!selectionManager || selectionManager.__floetermDemandRenderPatched) {
1277
- return;
1278
- }
1279
- selectionManager.__floetermDemandRenderPatched = true;
1280
- const originalRequestRender = typeof selectionManager.requestRender === 'function'
1281
- ? selectionManager.requestRender.bind(selectionManager)
1282
- : null;
1283
- selectionManager.requestRender = () => {
1284
- originalRequestRender?.();
1285
- this.syncFabricSelectionState();
1286
- this.requestDemandRender(false);
1287
- };
1288
- }
1289
- async attachFabricRenderer(seq) {
1290
- if (this.fabricView || this.config.rendererType !== 'webgl' || !this.terminal) {
1291
- return;
1292
- }
1293
- const terminalAny = this.terminal;
1294
- const theme = Object.keys(this.terminalThemeSource).length > 0
1295
- ? this.terminalThemeSource
1296
- : mapThemeToGhostty(this.config.theme);
1297
- const fontFamily = typeof terminalAny.options?.fontFamily === 'string'
1298
- ? terminalAny.options.fontFamily
1299
- : typeof this.config.fontFamily === 'string'
1300
- ? this.config.fontFamily
1301
- : '"JetBrains Mono", monospace';
1302
- const fontSize = Number(terminalAny.options?.fontSize ?? this.resolveEffectiveFontSize());
1303
- const sessionId = normalizeFabricIdentifier(typeof this.config.sessionId === 'string' ? this.config.sessionId : this.fabricViewId);
1304
- const fabricView = await terminalLiveFabric.attachView({
1305
- sessionId,
1306
- viewId: this.fabricViewId,
1307
- container: this.renderHost ?? this.container,
1308
- logger: this.logger,
1309
- fontFamily,
1310
- fontSize: Number.isFinite(fontSize) && fontSize > 0 ? fontSize : this.resolveEffectiveFontSize(),
1311
- theme,
1312
- getGhosttyCanvas: () => terminalAny.renderer?.getCanvas?.() ?? null,
1313
- focusInputSurface: () => this.inputBridge?.focus(),
1314
- forwardWheel: event => {
1315
- this.container.dispatchEvent(new WheelEvent(event.type, event));
1316
- },
1317
- onRendererError: error => this.handleRendererFailure(error),
1318
- });
1319
- if (this.isDisposed || seq !== this.fabricAttachSeq || this.fabricView) {
1320
- fabricView.dispose();
1321
- if (this.isDisposed) {
1322
- throw new Error('TerminalCore was disposed before the Beamterm renderer attached');
1323
- }
1324
- return;
1325
- }
1326
- this.fabricView = fabricView;
1327
- this.fabricView.renderer.setAppearance({ theme, fontFamily, fontSize });
1328
- this.performResize('force');
1329
- this.syncGhosttyInputGeometryToFabric();
1330
- this.forceFullRender();
1331
- this.resolveFabricAttachWaiters();
1332
- }
1333
- waitForFabricRendererAttach() {
1334
- if (this.isFabricRendererActive())
1335
- return Promise.resolve();
1336
- if (this.isDisposed)
1337
- return Promise.reject(new Error('Cannot attach a disposed TerminalCore'));
1338
- const waiter = new Promise((resolve, reject) => {
1339
- this.fabricAttachWaiters.push({ resolve, reject });
1340
- });
1341
- if (!this.fabricAttachPromise) {
1342
- this.cancelFabricAttachSchedule?.();
1343
- this.cancelFabricAttachSchedule = null;
1344
- const seq = ++this.fabricAttachSeq;
1345
- void this.startFabricRendererAttach(seq).catch(() => undefined);
1346
- }
1347
- return waiter;
1348
- }
1349
- resolveFabricAttachWaiters() {
1350
- const waiters = this.fabricAttachWaiters;
1351
- this.fabricAttachWaiters = [];
1352
- for (const waiter of waiters)
1353
- waiter.resolve();
1354
- }
1355
- rejectFabricAttachWaiters(error) {
1356
- const waiters = this.fabricAttachWaiters;
1357
- this.fabricAttachWaiters = [];
1358
- for (const waiter of waiters)
1359
- waiter.reject(error);
1360
- }
1361
- startFabricRendererAttach(seq) {
1362
- if (this.fabricView)
1363
- return Promise.resolve();
1364
- if (this.fabricAttachPromise)
1365
- return this.fabricAttachPromise;
1366
- const operation = this.attachFabricRenderer(seq)
1367
- .catch(error => {
1368
- if (!this.isDisposed && seq === this.fabricAttachSeq) {
1369
- this.handleRendererFailure(error);
1370
- }
1371
- throw error;
1372
- })
1373
- .finally(() => {
1374
- if (this.fabricAttachPromise === operation) {
1375
- this.fabricAttachPromise = null;
1376
- }
1377
- });
1378
- this.fabricAttachPromise = operation;
1379
- return operation;
1380
- }
1381
- scheduleFabricRendererAttach() {
1382
- if (this.config.rendererType !== 'webgl' || this.fabricView || this.fabricAttachPromise || !this.terminal) {
1383
- return;
1384
- }
1385
- if (this.cancelFabricAttachSchedule !== null) {
1386
- return;
1387
- }
1388
- const seq = ++this.fabricAttachSeq;
1389
- const attach = () => {
1390
- this.cancelFabricAttachSchedule = null;
1391
- if (this.isDisposed || seq !== this.fabricAttachSeq || this.fabricView) {
1392
- return;
1393
- }
1394
- void this.startFabricRendererAttach(seq).catch(() => undefined);
1395
- };
1396
- this.cancelFabricAttachSchedule = scheduleUiTurn(() => {
1397
- if (this.isDisposed || seq !== this.fabricAttachSeq || this.fabricView) {
1398
- this.cancelFabricAttachSchedule = null;
1399
- return;
1400
- }
1401
- this.cancelFabricAttachSchedule = scheduleUiTurn(attach);
1402
- });
1403
- }
1404
- handleRendererFailure(value) {
1405
- if (this.isDisposed)
1406
- return;
1407
- const error = value instanceof Error ? value : new Error(String(value));
1408
- this.cancelDemandRender();
1409
- this.rejectFabricAttachWaiters(error);
1410
- this.rejectAllPresentationRequests(error);
1411
- this.logger.error('[TerminalCore] Beamterm WebGL2 renderer failed', { error });
1412
- this.setState(TerminalState.ERROR);
1413
- this.eventHandlers.onError?.(error);
1414
- }
1415
- setupInputBridge(inputHost) {
1416
- this.disposeInputBridge();
1417
- const input = this.inputElement;
1418
- if (!input) {
1419
- throw new Error('Terminal input element was not mounted');
1420
- }
1421
- this.inputBridge = new TerminalInputBridge({
1422
- inputHost,
1423
- inputElement: input,
1424
- onData: (data) => {
1425
- this.restoreTerminalViewportForUserInput();
1426
- getPerfProbe()?.onTerminalInput?.(data.length);
1427
- this.eventHandlers.onData?.(data);
1428
- },
1429
- logger: this.logger,
1430
- hasSelection: () => this.hasSelection(),
1431
- copySelection: (source, clipboardData) => this.performSelectionCopy(source, clipboardData),
1432
- syncInputGeometry: () => this.syncImeInputAnchor(),
1433
- });
1434
- this.syncImeInputAnchor();
1435
- if (this.isContainerFocused()) {
1436
- this.scheduleInputSurfaceRefocus();
1437
- }
1438
- }
1439
- async waitForDOMAndFonts() {
1440
- if (document.readyState !== 'complete') {
1441
- await new Promise((resolve) => {
1442
- const handler = () => {
1443
- if (document.readyState === 'complete') {
1444
- document.removeEventListener('readystatechange', handler);
1445
- resolve();
1446
- }
1447
- };
1448
- document.addEventListener('readystatechange', handler);
1449
- });
1450
- }
1451
- if (typeof document.fonts !== 'undefined' && document.fonts.ready) {
1452
- try {
1453
- await document.fonts.ready;
1454
- }
1455
- catch (error) {
1456
- this.logger.warn('[TerminalCore] Font loading failed, continuing', { error });
1457
- await new Promise(resolve => setTimeout(resolve, 200));
1458
- }
1459
- }
1460
- else {
1461
- await new Promise(resolve => setTimeout(resolve, 200));
1462
- }
1463
- }
1464
- async waitForTerminalFontReady(fontFamily, fontSize, reason) {
1465
- if (typeof document === 'undefined') {
1466
- return;
1467
- }
1468
- await this.waitForDOMAndFonts();
1469
- const fonts = document.fonts;
1470
- if (!fontFamily || typeof fonts === 'undefined' || typeof fonts.load !== 'function') {
1471
- return;
1472
- }
1473
- const cssFont = `${Math.max(1, fontSize)}px ${fontFamily}`;
1474
- try {
1475
- await fonts.load(cssFont, 'MMMMMMMMMM');
1476
- if (fonts.ready) {
1477
- await fonts.ready;
1478
- }
1479
- }
1480
- catch (error) {
1481
- this.logger.warn('[TerminalCore] Terminal font loading failed, continuing', { error, reason, fontFamily });
1482
- }
1483
- }
1484
- refreshFontMetricsAfterLoad(reason) {
1485
- if (!this.terminal) {
1486
- return Promise.resolve();
1487
- }
1488
- const terminalAny = this.terminal;
1489
- const fontFamily = terminalAny.options?.fontFamily ?? this.config.fontFamily;
1490
- const fontSize = Number(terminalAny.options?.fontSize ?? this.resolveEffectiveFontSize());
1491
- const seq = ++this.fontMetricSeq;
1492
- const refresh = this.applyFontMetricsAfterLoad(terminalAny, typeof fontFamily === 'string' ? fontFamily : undefined, fontSize, reason, seq);
1493
- this.fontMetricRefreshPromise = refresh;
1494
- return refresh;
1495
- }
1496
- async applyFontMetricsAfterLoad(terminal, fontFamily, fontSize, reason, seq) {
1497
- await this.waitForTerminalFontReady(fontFamily, fontSize, reason);
1498
- if (this.isDisposed || seq !== this.fontMetricSeq || !this.terminal) {
1499
- return;
1500
- }
1501
- try {
1502
- terminal.renderer?.remeasureFont?.();
1503
- }
1504
- catch (error) {
1505
- this.logger.debug('[TerminalCore] Terminal font remeasure failed', { error, reason });
1506
- }
1507
- this.performResize('font');
1508
- this.forceFullRender();
1509
- this.scheduleRenderSearchOverlay();
1510
- }
1511
- async waitForStableFontMetrics() {
1512
- while (true) {
1513
- if (this.isDisposed) {
1514
- throw new Error('TerminalCore was disposed before font metrics stabilized');
1515
- }
1516
- const seq = this.fontMetricSeq;
1517
- const refresh = this.fontMetricRefreshPromise;
1518
- if (refresh) {
1519
- await refresh;
1520
- }
1521
- if (seq === this.fontMetricSeq && refresh === this.fontMetricRefreshPromise) {
1522
- return seq;
1523
- }
1524
- }
1525
- }
1526
- async ensureContainerReady() {
1527
- const maxRetries = 15;
1528
- const retryDelay = 50;
1529
- if (!document.contains(this.container)) {
1530
- throw new Error('Container element is no longer in the DOM');
1531
- }
1532
- for (let i = 0; i < maxRetries; i += 1) {
1533
- if (!document.contains(this.container)) {
1534
- throw new Error('Container element removed from DOM during initialization');
1535
- }
1536
- const width = this.container.clientWidth;
1537
- const height = this.container.clientHeight;
1538
- const style = getComputedStyle(this.container);
1539
- if (width > 0 && height > 0 && style.display !== 'none' && style.visibility !== 'hidden') {
1540
- return;
1541
- }
1542
- await new Promise(resolve => requestAnimationFrame(resolve));
1543
- if (i < maxRetries - 1) {
1544
- await new Promise(resolve => setTimeout(resolve, retryDelay));
1545
- }
1546
- }
1547
- }
1548
- setupEventListeners() {
1549
- if (!this.terminal) {
1550
- return;
1551
- }
1552
- this.disposeTerminalEventListeners();
1553
- let pendingTerminalKeyInput = false;
1554
- let pendingTerminalKeyInputEpoch = 0;
1555
- if (this.eventHandlers.onData && typeof this.terminal.onKey === 'function') {
1556
- const disposable = this.terminal.onKey(() => {
1557
- pendingTerminalKeyInput = true;
1558
- const epoch = ++pendingTerminalKeyInputEpoch;
1559
- queueMicrotask(() => {
1560
- if (pendingTerminalKeyInputEpoch !== epoch)
1561
- return;
1562
- pendingTerminalKeyInput = false;
1563
- });
1564
- });
1565
- this.trackTerminalEventDisposable(disposable);
1566
- }
1567
- if (this.eventHandlers.onData) {
1568
- const disposable = this.terminal.onData((data) => {
1569
- let filtered = data;
1570
- if (this.isReplayingHistory || this.historyWriteDepth > 0) {
1571
- filtered = filterXtermAutoResponses(data);
1572
- if (filtered.length === 0) {
1573
- return;
1574
- }
1575
- }
1576
- if (pendingTerminalKeyInput
1577
- && !isPinnedGhosttyOutputWriteActive(this.terminal)) {
1578
- pendingTerminalKeyInput = false;
1579
- pendingTerminalKeyInputEpoch += 1;
1580
- this.restoreTerminalViewportForUserInput();
1581
- }
1582
- getPerfProbe()?.onTerminalInput?.(filtered.length);
1583
- this.eventHandlers.onData?.(filtered);
1584
- });
1585
- this.trackTerminalEventDisposable(disposable);
1586
- }
1587
- if (this.eventHandlers.onResize) {
1588
- const disposable = this.terminal.onResize((size) => {
1589
- this.emitResize(size, { source: 'terminal' });
1590
- });
1591
- this.trackTerminalEventDisposable(disposable);
1592
- }
1593
- if (this.eventHandlers.onBell && typeof this.terminal.onBell === 'function') {
1594
- const disposable = this.terminal.onBell(() => {
1595
- this.eventHandlers.onBell?.();
1596
- });
1597
- this.trackTerminalEventDisposable(disposable);
1598
- }
1599
- if (this.eventHandlers.onTitleChange && typeof this.terminal.onTitleChange === 'function') {
1600
- const disposable = this.terminal.onTitleChange((title) => {
1601
- this.eventHandlers.onTitleChange?.(title);
1602
- });
1603
- this.trackTerminalEventDisposable(disposable);
1604
- }
1605
- if (typeof this.terminal.onScroll === 'function') {
1606
- const disposable = this.terminal.onScroll(() => {
1607
- this.syncImeInputAnchor();
1608
- this.scrollbarOverlay?.reveal();
1609
- this.scrollbarOverlay?.sync();
1610
- this.requestDemandRender(false);
1611
- });
1612
- this.trackTerminalEventDisposable(disposable);
1613
- }
1614
- if (typeof this.terminal.onSelectionChange === 'function') {
1615
- const disposable = this.terminal.onSelectionChange(() => {
1616
- this.syncFabricSelectionState();
1617
- this.requestDemandRender(false);
1618
- });
1619
- this.trackTerminalEventDisposable(disposable);
1620
- }
1621
- }
1622
- restoreTerminalViewportForUserInput() {
1623
- const terminal = this.terminal;
1624
- if (!terminal)
1625
- return;
1626
- if (isPinnedGhosttyOutputWriteActive(terminal))
1627
- return;
1628
- terminal.scrollToBottom?.();
1629
- }
1630
- setupResponsiveListeners() {
1631
- const enabled = this.responsive.fitOnFocus || this.responsive.emitResizeOnFocus || this.responsive.notifyResizeOnlyWhenFocused;
1632
- if (!enabled) {
1633
- return;
1634
- }
1635
- const onFocusIn = () => {
1636
- this.hasFocus = true;
1637
- this.scheduleFocusResize();
1638
- };
1639
- const onDocumentFocusIn = () => {
1640
- this.hasFocus = this.isContainerFocused();
1641
- if (this.hasFocus) {
1642
- this.scheduleFocusResize();
1643
- }
1644
- };
1645
- const onFocusOut = () => {
1646
- // focusout fires even when moving focus within the subtree; re-check on next frame.
1647
- requestAnimationFrame(() => {
1648
- if (this.isDisposed) {
1649
- return;
1650
- }
1651
- this.hasFocus = this.isContainerFocused();
1652
- });
1653
- };
1654
- const onPointerDown = () => {
1655
- // pointerdown can happen before focus moves; treat it as "intent to interact".
1656
- this.hasFocus = true;
1657
- this.scheduleFocusResize();
1658
- };
1659
- this.container.addEventListener('focusin', onFocusIn);
1660
- this.container.addEventListener('focusout', onFocusOut);
1661
- this.container.addEventListener('pointerdown', onPointerDown);
1662
- this.container.ownerDocument.addEventListener('focusin', onDocumentFocusIn);
1663
- this.container.ownerDocument.addEventListener('focusout', onFocusOut);
1664
- this.hasFocus = this.isContainerFocused();
1665
- this.unbindResponsiveListeners = () => {
1666
- this.container.removeEventListener('focusin', onFocusIn);
1667
- this.container.removeEventListener('focusout', onFocusOut);
1668
- this.container.removeEventListener('pointerdown', onPointerDown);
1669
- this.container.ownerDocument.removeEventListener('focusin', onDocumentFocusIn);
1670
- this.container.ownerDocument.removeEventListener('focusout', onFocusOut);
1671
- };
1672
- }
1673
- startSizeWatching() {
1674
- if (!this.container || !this.fitAddon) {
1675
- return;
1676
- }
1677
- if (typeof ResizeObserver === 'undefined') {
1678
- return;
1679
- }
1680
- this.resizeObserver = new ResizeObserver(() => {
1681
- this.scheduleObservedResize();
1682
- });
1683
- const observed = new Set();
1684
- const observe = (target) => {
1685
- if (!target || observed.has(target)) {
1686
- return;
1687
- }
1688
- observed.add(target);
1689
- this.resizeObserver?.observe(target);
1690
- };
1691
- observe(this.container);
1692
- observe(this.container.parentElement);
1693
- observe(this.viewportHost);
1694
- observe(this.renderHost);
1695
- }
1696
- scheduleObservedResize() {
1697
- if (this.resizeRaf === null) {
1698
- this.resizeRaf = requestAnimationFrame(() => {
1699
- this.resizeRaf = null;
1700
- this.performResize('observer');
1701
- });
1702
- }
1703
- if (this.resizeDebounceTimer) {
1704
- clearTimeout(this.resizeDebounceTimer);
1705
- }
1706
- this.resizeDebounceTimer = setTimeout(() => {
1707
- this.resizeDebounceTimer = null;
1708
- this.performResize('observer');
1709
- }, 80);
1710
- }
1711
- performResize(reason) {
1712
- if (this.isVisualRenderSuspended()) {
1713
- this.markPendingResize(reason);
1714
- return;
1715
- }
1716
- this.performResizeNow(reason);
1717
- }
1718
- performResizeNow(reason) {
1719
- if (!this.isReady() || !this.fitAddon || !this.terminal) {
1720
- return;
1721
- }
1722
- if (this.fixedDimensions) {
1723
- this.applyFixedDimensions(reason);
1724
- return;
1725
- }
1726
- if (this.container.clientWidth === 0 || this.container.clientHeight === 0) {
1727
- return;
1728
- }
1729
- const startSeq = this.resizeNotifySeq;
1730
- const before = this.lastNotifiedSize;
1731
- try {
1732
- if (this.isFabricRendererActive()) {
1733
- this.resizeFabricToHost();
1734
- }
1735
- this.fitAddon.fit();
1736
- if (this.isFabricRendererActive()) {
1737
- this.syncGhosttyInputGeometryToFabric();
1738
- }
1739
- if (!this.isFabricRendererActive()) {
1740
- this.resizeFabricToHost();
1741
- }
1742
- this.syncImeInputAnchor();
1743
- }
1744
- catch (error) {
1745
- this.logger.debug('[TerminalCore] Resize failed', { error });
1746
- }
1747
- // Some @floegence/ghostty-web builds may not always emit onResize for programmatic fit().
1748
- // Emit a deduped notification based on the actual terminal dimensions.
1749
- if (this.resizeNotifySeq !== startSeq) {
1750
- return;
1751
- }
1752
- const dims = this.getDimensions();
1753
- if (reason === 'focus' && this.responsive.emitResizeOnFocus) {
1754
- const force = Boolean(before) && before.cols === dims.cols && before.rows === dims.rows;
1755
- this.emitResize(dims, { source: 'core', force });
1756
- return;
1757
- }
1758
- this.emitResize(dims, { source: 'core' });
1759
- }
1760
- applyFixedDimensions(reason) {
1761
- if (!this.terminal || !this.fixedDimensions) {
1762
- return;
1763
- }
1764
- const dims = this.fixedDimensions;
1765
- const startSeq = this.resizeNotifySeq;
1766
- const before = this.lastNotifiedSize;
1767
- const changed = this.terminal.cols !== dims.cols || this.terminal.rows !== dims.rows;
1768
- const reportHostDimensions = this.responsive.reportHostDimensionsWithFixedGrid;
1769
- const previousSuppression = this.suppressResizeNotifications;
1770
- if (reportHostDimensions && changed) {
1771
- this.suppressResizeNotifications = true;
1772
- }
1773
- try {
1774
- if (changed) {
1775
- this.terminal.resize(dims.cols, dims.rows);
1776
- }
1777
- this.resizeFabricToHost();
1778
- this.syncImeInputAnchor();
1779
- }
1780
- catch (error) {
1781
- this.logger.debug('[TerminalCore] Fixed-dimension resize failed', { error });
1782
- }
1783
- finally {
1784
- this.suppressResizeNotifications = previousSuppression;
1785
- }
1786
- if (reportHostDimensions) {
1787
- const hostDimensions = this.proposeFitDimensions();
1788
- if (!hostDimensions) {
1789
- return;
1790
- }
1791
- const force = reason === 'focus' && this.responsive.emitResizeOnFocus
1792
- && Boolean(before) && before.cols === hostDimensions.cols && before.rows === hostDimensions.rows;
1793
- this.emitResize(hostDimensions, { source: 'core', force });
1794
- return;
1795
- }
1796
- // Some @floegence/ghostty-web builds may not emit onResize for programmatic resize().
1797
- if (this.resizeNotifySeq !== startSeq) {
1798
- return;
1799
- }
1800
- if (reason === 'focus' && this.responsive.emitResizeOnFocus) {
1801
- const force = Boolean(before) && before.cols === dims.cols && before.rows === dims.rows;
1802
- this.emitResize(dims, { source: 'core', force });
1803
- return;
1804
- }
1805
- if (changed) {
1806
- this.emitResize(dims, { source: 'core' });
1807
- }
1808
- }
1809
- scheduleFocusResize() {
1810
- if (!this.responsive.fitOnFocus && !this.responsive.emitResizeOnFocus) {
1811
- return;
1812
- }
1813
- if (this.focusResizeRaf !== null) {
1814
- return;
1815
- }
1816
- this.focusResizeRaf = requestAnimationFrame(() => {
1817
- this.focusResizeRaf = null;
1818
- this.performResize('focus');
1819
- });
1820
- }
1821
- emitResize(size, opts) {
1822
- if (!this.eventHandlers.onResize) {
1823
- return;
1824
- }
1825
- if (this.suppressResizeNotifications) {
1826
- return;
1827
- }
1828
- if (this.responsive.notifyResizeOnlyWhenFocused && !this.hasFocus) {
1829
- return;
1830
- }
1831
- const cols = typeof size?.cols === 'number' ? size.cols : 0;
1832
- const rows = typeof size?.rows === 'number' ? size.rows : 0;
1833
- if (!opts.force && this.lastNotifiedSize && this.lastNotifiedSize.cols === cols && this.lastNotifiedSize.rows === rows) {
1834
- return;
1835
- }
1836
- this.lastNotifiedSize = { cols, rows };
1837
- this.resizeNotifySeq += 1;
1838
- try {
1839
- this.eventHandlers.onResize({ cols, rows });
1840
- }
1841
- catch (error) {
1842
- this.logger.warn('[TerminalCore] onResize handler threw', { error });
1843
- }
1844
- }
1845
- isContainerFocused() {
1846
- if (typeof document === 'undefined') {
1847
- return false;
1848
- }
1849
- const el = document.activeElement;
1850
- if (!el) {
1851
- return false;
1852
- }
1853
- return this.container.contains(el) || this.inputBridge?.containsTarget(el) === true;
1854
- }
1855
- setState(newState) {
1856
- if (this.state !== newState) {
1857
- this.state = newState;
1858
- this.eventHandlers.onStateChange?.(newState);
1859
- }
1860
- }
1861
- isReady() {
1862
- return this.state === TerminalState.READY || this.state === TerminalState.CONNECTED;
1863
- }
1864
- // startHistoryReplay enables auto-response filtering for a limited duration.
1865
- startHistoryReplay(duration = 2000) {
1866
- if (this.replayingHistoryTimer) {
1867
- clearTimeout(this.replayingHistoryTimer);
1868
- }
1869
- this.isReplayingHistory = true;
1870
- this.replayingHistoryTimer = setTimeout(() => this.endHistoryReplay(), duration);
1871
- }
1872
- // endHistoryReplay disables auto-response filtering.
1873
- endHistoryReplay() {
1874
- if (this.replayingHistoryTimer) {
1875
- clearTimeout(this.replayingHistoryTimer);
1876
- this.replayingHistoryTimer = null;
1877
- }
1878
- this.isReplayingHistory = false;
1879
- }
1880
- write(data, callback) {
1881
- this.writeWithRenderMode(data, callback, false);
1882
- }
1883
- writeFrame(data, callback) {
1884
- this.writeWithRenderMode(data, callback, true);
1885
- }
1886
- writeWithRenderMode(data, callback, renderInCurrentFrame) {
1887
- if (!this.terminal || !this.isReady()) {
1888
- callback?.();
1889
- return;
1890
- }
1891
- try {
1892
- const probe = getPerfProbe();
1893
- probe?.onTerminalWrite?.(typeof data === 'string' ? data.length : data.byteLength);
1894
- const profileStartedAt = performance.now();
1895
- let snapshotMs = 0;
1896
- let parseMs = 0;
1897
- const beforeSnapshotStartedAt = performance.now();
1898
- const beforeWrite = this.captureRenderSnapshot();
1899
- snapshotMs += performance.now() - beforeSnapshotStartedAt;
1900
- const shouldForce = this.needsFullRenderOnNextWrite;
1901
- const render = () => {
1902
- const afterSnapshotStartedAt = performance.now();
1903
- const shouldForceAfterWrite = shouldForce || this.shouldForceFullRenderAfterWrite(beforeWrite);
1904
- snapshotMs += performance.now() - afterSnapshotStartedAt;
1905
- if (shouldForceAfterWrite) {
1906
- this.needsFullRenderOnNextWrite = false;
1907
- }
1908
- if (renderInCurrentFrame) {
1909
- const forceCurrentFrame = shouldForceAfterWrite || this.demandRenderForceAll;
1910
- this.cancelDemandRender();
1911
- this.renderDemandFrame(forceCurrentFrame);
1912
- }
1913
- else {
1914
- this.requestDemandRender(shouldForceAfterWrite);
1915
- }
1916
- this.scrollbarOverlay?.sync();
1917
- callback?.();
1918
- probe?.onTerminalWriteProfile?.({
1919
- totalMs: performance.now() - profileStartedAt,
1920
- parseMs,
1921
- snapshotMs,
1922
- });
1923
- };
1924
- const parseStartedAt = performance.now();
1925
- if (callback || shouldForce) {
1926
- this.terminal.write(data, render);
1927
- parseMs += performance.now() - parseStartedAt;
1928
- return;
1929
- }
1930
- this.terminal.write(data);
1931
- parseMs += performance.now() - parseStartedAt;
1932
- render();
1933
- }
1934
- catch (error) {
1935
- this.logger.error('[TerminalCore] Write failed', { error });
1936
- callback?.();
1937
- }
1938
- }
1939
- writeHistory(data, callback) {
1940
- this.historyWriteDepth += 1;
1941
- this.write(data, () => {
1942
- this.historyWriteDepth = Math.max(0, this.historyWriteDepth - 1);
1943
- callback?.();
1944
- });
1945
- }
1946
- clear() {
1947
- if (!this.terminal || !this.isReady()) {
1948
- return;
1949
- }
1950
- this.terminal.clear();
1951
- this.needsFullRenderOnNextWrite = true;
1952
- this.forceFullRender();
1953
- }
1954
- serialize() {
1955
- return this.captureRestorableSnapshot()?.data ?? '';
1956
- }
1957
- captureRestorableSnapshot(options = {}) {
1958
- if (!this.terminal || !this.isReady()) {
1959
- return null;
1960
- }
1961
- const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0
1962
- ? Math.floor(Number(options.maxBytes))
1963
- : DEFAULT_TERMINAL_SNAPSHOT_MAX_BYTES;
1964
- const info = this.getTerminalInfo();
1965
- if (!info) {
1966
- return null;
1967
- }
1968
- const lines = [];
1969
- for (let row = 0; row < info.bufferLength; row += 1) {
1970
- lines.push(this.readBufferLine(row, { trimRight: true }));
1971
- }
1972
- const suffix = '\x1b[0m';
1973
- let selected = lines;
1974
- let data = SNAPSHOT_RESET_PREFIX + selected.join('\r\n') + suffix;
1975
- let partial = false;
1976
- while (selected.length > Math.max(1, info.rows) && encodedByteLength(data) > maxBytes) {
1977
- partial = true;
1978
- selected = selected.slice(Math.max(1, Math.floor(selected.length / 4)));
1979
- data = SNAPSHOT_RESET_PREFIX + selected.join('\r\n') + suffix;
1980
- }
1981
- if (encodedByteLength(data) > maxBytes) {
1982
- partial = true;
1983
- const availableBytes = Math.max(0, maxBytes - encodedByteLength(SNAPSHOT_RESET_PREFIX + suffix));
1984
- const encoded = new TextEncoder().encode(selected.join('\r\n'));
1985
- const tail = encoded.slice(Math.max(0, encoded.byteLength - availableBytes));
1986
- data = SNAPSHOT_RESET_PREFIX + new TextDecoder().decode(tail) + suffix;
1987
- }
1988
- return {
1989
- version: TERMINAL_SNAPSHOT_VERSION,
1990
- data,
1991
- byteLength: encodedByteLength(data),
1992
- partial,
1993
- coveredThroughSequence: Math.max(0, Math.floor(options.coveredThroughSequence ?? 0)),
1994
- cols: info.cols,
1995
- rows: info.rows,
1996
- createdAtMs: options.now?.() ?? Date.now(),
1997
- };
1998
- }
1999
- async restoreSnapshot(snapshot) {
2000
- if (!this.terminal || !this.isReady() || snapshot.version !== TERMINAL_SNAPSHOT_VERSION) {
2001
- return false;
2002
- }
2003
- if (!Number.isFinite(snapshot.coveredThroughSequence) || snapshot.coveredThroughSequence < 0) {
2004
- return false;
2005
- }
2006
- this.startHistoryReplay();
2007
- this.needsFullRenderOnNextWrite = true;
2008
- return new Promise(resolve => {
2009
- this.write(snapshot.data, () => {
2010
- this.endHistoryReplay();
2011
- resolve(true);
2012
- });
2013
- });
2014
- }
2015
- /**
2016
- * Restores a validated checkpoint produced by the published Ghostty engine.
2017
- * The terminal must already be at the checkpoint geometry; geometry changes
2018
- * remain owned by the history boundary before restore is attempted.
2019
- */
2020
- async restoreAuthoritativeCheckpoint(checkpoint) {
2021
- if (!this.terminal || !this.isReady()) {
2022
- throw new Error('Cannot restore a checkpoint before initialization completes');
2023
- }
2024
- if (checkpoint.formatVersion !== 1
2025
- || checkpoint.engineId !== 'floegence-ghostty-web'
2026
- || !Number.isSafeInteger(checkpoint.coveredThroughSequence)
2027
- || checkpoint.coveredThroughSequence <= 0
2028
- || !Number.isSafeInteger(checkpoint.geometryGeneration)
2029
- || checkpoint.geometryGeneration <= 0
2030
- || !Number.isSafeInteger(checkpoint.parserEpoch)
2031
- || checkpoint.parserEpoch <= 0
2032
- || !Number.isSafeInteger(checkpoint.cols)
2033
- || checkpoint.cols <= 0
2034
- || !Number.isSafeInteger(checkpoint.rows)
2035
- || checkpoint.rows <= 0
2036
- || !(checkpoint.bytes instanceof Uint8Array)
2037
- || checkpoint.bytes.byteLength === 0
2038
- || !/^[0-9a-f]{64}$/.test(checkpoint.stateDigestSha256)) {
2039
- throw new Error('Invalid authoritative terminal checkpoint metadata');
2040
- }
2041
- const terminalAny = this.terminal;
2042
- const wasmTerm = terminalAny.wasmTerm;
2043
- if (!wasmTerm
2044
- || typeof wasmTerm.validateCheckpoint !== 'function'
2045
- || typeof wasmTerm.restoreCheckpoint !== 'function'
2046
- || typeof wasmTerm.getStateDigest !== 'function') {
2047
- throw new Error('Published Ghostty terminal checkpoint API is unavailable');
2048
- }
2049
- if (terminalAny.cols !== checkpoint.cols || terminalAny.rows !== checkpoint.rows) {
2050
- throw new Error('Terminal geometry does not match authoritative checkpoint');
2051
- }
2052
- const bytes = checkpoint.bytes.slice();
2053
- if (await sha256Hex(bytes) !== checkpoint.checksumSha256) {
2054
- throw new Error('Authoritative terminal checkpoint checksum mismatch');
2055
- }
2056
- const coordinates = {
2057
- historySequence: BigInt(checkpoint.coveredThroughSequence),
2058
- geometryGeneration: BigInt(checkpoint.geometryGeneration),
2059
- parserEpoch: BigInt(checkpoint.parserEpoch),
2060
- };
2061
- const metadata = wasmTerm.validateCheckpoint(bytes);
2062
- if (metadata.formatVersion !== checkpoint.formatVersion
2063
- || metadata.cols !== checkpoint.cols
2064
- || metadata.rows !== checkpoint.rows
2065
- || metadata.historySequence !== coordinates.historySequence
2066
- || metadata.geometryGeneration !== coordinates.geometryGeneration
2067
- || metadata.parserEpoch !== coordinates.parserEpoch) {
2068
- throw new Error('Authoritative terminal checkpoint coordinates do not match');
2069
- }
2070
- wasmTerm.restoreCheckpoint(bytes, coordinates);
2071
- if (wasmTerm.getStateDigest() !== checkpoint.stateDigestSha256) {
2072
- throw new Error('Authoritative terminal checkpoint state digest mismatch');
2073
- }
2074
- this.needsFullRenderOnNextWrite = true;
2075
- this.forceFullRender();
2076
- await this.forceResizeAndWaitForCommittedFrame();
2077
- }
2078
- getResourceEstimate() {
2079
- const wasmMemoryBytes = this.ownedRuntimeMemory?.buffer.byteLength ?? 0;
2080
- if (wasmMemoryBytes <= 0) {
2081
- return {
2082
- bufferBytes: 0,
2083
- cellCount: 0,
2084
- wasmMemoryBytes: 0,
2085
- estimatedBytes: 0,
2086
- rendererType: this.config.rendererType ?? 'canvas',
2087
- };
2088
- }
2089
- const info = this.getTerminalInfo();
2090
- const cellCount = info ? Math.max(0, info.cols * info.bufferLength) : 0;
2091
- let bufferBytes = 0;
2092
- if (info) {
2093
- for (let row = 0; row < info.bufferLength; row += 1) {
2094
- bufferBytes += encodedByteLength(this.readBufferLine(row));
2095
- }
2096
- }
2097
- return {
2098
- bufferBytes,
2099
- cellCount,
2100
- wasmMemoryBytes,
2101
- estimatedBytes: ESTIMATED_RENDERER_BYTES + wasmMemoryBytes,
2102
- rendererType: this.config.rendererType ?? 'canvas',
2103
- };
2104
- }
2105
- getSelectionText() {
2106
- if (!this.terminal) {
2107
- return '';
2108
- }
2109
- return this.terminal.getSelection();
2110
- }
2111
- hasSelection() {
2112
- return this.getSelectionSnapshot().hasSelection;
2113
- }
2114
- async copySelection(source = 'command') {
2115
- return this.performSelectionCopy(source);
2116
- }
2117
- getState() {
2118
- return this.state;
2119
- }
2120
- getSelectionSnapshot() {
2121
- const text = this.getSelectionText();
2122
- return {
2123
- text,
2124
- hasSelection: text.length > 0,
2125
- };
2126
- }
2127
- async performSelectionCopy(source, clipboardData = null) {
2128
- const selection = this.getSelectionSnapshot();
2129
- if (!selection.hasSelection) {
2130
- return {
2131
- copied: false,
2132
- reason: 'empty_selection',
2133
- source,
2134
- };
2135
- }
2136
- if (clipboardData && typeof clipboardData.setData === 'function') {
2137
- clipboardData.setData('text/plain', selection.text);
2138
- return {
2139
- copied: true,
2140
- textLength: selection.text.length,
2141
- source,
2142
- };
2143
- }
2144
- if (await this.copyTextWithNavigatorClipboard(selection.text, source)) {
2145
- return {
2146
- copied: true,
2147
- textLength: selection.text.length,
2148
- source,
2149
- };
2150
- }
2151
- if (this.copyTextWithExecCommand(selection.text)) {
2152
- return {
2153
- copied: true,
2154
- textLength: selection.text.length,
2155
- source,
2156
- };
2157
- }
2158
- this.logger.warn('[TerminalCore] Clipboard copy unavailable', {
2159
- source,
2160
- textLength: selection.text.length,
2161
- });
2162
- return {
2163
- copied: false,
2164
- reason: 'clipboard_unavailable',
2165
- source,
2166
- };
2167
- }
2168
- async copyTextWithNavigatorClipboard(text, source) {
2169
- const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
2170
- if (!clipboard || typeof clipboard.writeText !== 'function') {
2171
- return false;
2172
- }
2173
- try {
2174
- await clipboard.writeText(text);
2175
- return true;
2176
- }
2177
- catch (error) {
2178
- this.logger.debug('[TerminalCore] navigator.clipboard.writeText failed', {
2179
- error,
2180
- source,
2181
- });
2182
- return false;
2183
- }
2184
- }
2185
- copyTextWithExecCommand(text) {
2186
- if (typeof document === 'undefined' || typeof document.execCommand !== 'function') {
2187
- return false;
2188
- }
2189
- const previousActiveElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
2190
- const restorableSelection = (previousActiveElement instanceof HTMLInputElement ||
2191
- previousActiveElement instanceof HTMLTextAreaElement)
2192
- ? {
2193
- element: previousActiveElement,
2194
- start: previousActiveElement.selectionStart ?? 0,
2195
- end: previousActiveElement.selectionEnd ?? 0,
2196
- direction: previousActiveElement.selectionDirection ?? 'none',
2197
- }
2198
- : null;
2199
- const textarea = document.createElement('textarea');
2200
- textarea.value = text;
2201
- textarea.setAttribute('readonly', 'true');
2202
- textarea.setAttribute('aria-hidden', 'true');
2203
- textarea.style.position = 'fixed';
2204
- textarea.style.top = '0';
2205
- textarea.style.left = '-9999px';
2206
- textarea.style.opacity = '0';
2207
- textarea.style.pointerEvents = 'none';
2208
- document.body.appendChild(textarea);
2209
- textarea.focus({ preventScroll: true });
2210
- textarea.select();
2211
- try {
2212
- textarea.setSelectionRange(0, text.length);
2213
- }
2214
- catch {
2215
- this.logger.debug('[TerminalCore] Failed to prepare textarea selection for clipboard fallback');
2216
- }
2217
- let copied = false;
2218
- try {
2219
- copied = document.execCommand('copy');
2220
- }
2221
- catch (error) {
2222
- this.logger.debug('[TerminalCore] document.execCommand("copy") failed', { error });
2223
- }
2224
- textarea.remove();
2225
- previousActiveElement?.focus({ preventScroll: true });
2226
- if (restorableSelection) {
2227
- try {
2228
- restorableSelection.element.setSelectionRange(restorableSelection.start, restorableSelection.end, restorableSelection.direction);
2229
- }
2230
- catch {
2231
- this.logger.debug('[TerminalCore] Failed to restore previous selection after clipboard fallback');
2232
- }
2233
- }
2234
- return copied;
2235
- }
2236
- getDimensions() {
2237
- if (!this.terminal) {
2238
- return { cols: 0, rows: 0 };
2239
- }
2240
- return { cols: this.terminal.cols, rows: this.terminal.rows };
2241
- }
2242
- getTerminalInfo() {
2243
- if (!this.terminal) {
2244
- return null;
2245
- }
2246
- return {
2247
- rows: this.terminal.rows,
2248
- cols: this.terminal.cols,
2249
- bufferLength: this.terminal.buffer.active.length
2250
- };
2251
- }
2252
- readBufferLine(row, options = {}) {
2253
- if (!this.terminal) {
2254
- return '';
2255
- }
2256
- const normalizedRow = Math.floor(Number(row));
2257
- if (!Number.isFinite(normalizedRow) || normalizedRow < 0) {
2258
- return '';
2259
- }
2260
- try {
2261
- const line = this.terminal.buffer?.active?.getLine?.(normalizedRow);
2262
- const text = line?.translateToString?.(options.trimRight ?? false);
2263
- return typeof text === 'string' ? text : '';
2264
- }
2265
- catch {
2266
- return '';
2267
- }
2268
- }
2269
- readBufferLines(startRow, endRowInclusive, options = {}) {
2270
- const start = Math.floor(Number(startRow));
2271
- const end = Math.floor(Number(endRowInclusive));
2272
- if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) {
2273
- return [];
2274
- }
2275
- const lines = [];
2276
- for (let row = Math.max(0, start); row <= end; row += 1) {
2277
- const text = this.readBufferLine(row, options);
2278
- if (text.length > 0) {
2279
- lines.push({ row, text });
2280
- }
2281
- }
2282
- return lines;
2283
- }
2284
- getTouchScrollRuntime() {
2285
- const terminal = this.terminal;
2286
- if (!terminal) {
2287
- return null;
2288
- }
2289
- return {
2290
- scrollLines: amount => {
2291
- const normalizedAmount = Math.trunc(Number(amount));
2292
- if (!Number.isFinite(normalizedAmount) || normalizedAmount === 0) {
2293
- return false;
2294
- }
2295
- if (typeof terminal.scrollLines !== 'function') {
2296
- return false;
2297
- }
2298
- terminal.scrollLines(normalizedAmount);
2299
- this.requestDemandRender(false);
2300
- return true;
2301
- },
2302
- getScrollbackLength: () => {
2303
- if (typeof terminal.getScrollbackLength !== 'function') {
2304
- return 0;
2305
- }
2306
- const length = Number(terminal.getScrollbackLength());
2307
- return Number.isFinite(length) && length > 0 ? length : 0;
2308
- },
2309
- isAlternateScreen: () => {
2310
- if (typeof terminal.isAlternateScreen !== 'function') {
2311
- return false;
2312
- }
2313
- return Boolean(terminal.isAlternateScreen());
2314
- },
2315
- sendAlternateScreenInput: data => {
2316
- const text = String(data ?? '');
2317
- if (!text) {
2318
- return;
2319
- }
2320
- if (typeof terminal.input === 'function') {
2321
- terminal.input(text, true);
2322
- return;
2323
- }
2324
- getPerfProbe()?.onTerminalInput?.(text.length);
2325
- this.eventHandlers.onData?.(text);
2326
- },
2327
- };
2328
- }
2329
- findNext(term, options) {
2330
- return this.findInternal(term, options, 1);
2331
- }
2332
- findPrevious(term, options) {
2333
- return this.findInternal(term, options, -1);
2334
- }
2335
- clearSearch() {
2336
- this.searchQuery = '';
2337
- this.searchOptionsKey = '';
2338
- this.searchMatches = [];
2339
- this.searchRowIndex = new Map();
2340
- this.searchMatchIndex = -1;
2341
- this.clearActiveSearchSelection();
2342
- this.disposeSearchOverlay();
2343
- this.restoreSearchTheme();
2344
- this.notifySearchResults();
2345
- }
2346
- setSearchResultsCallback(callback) {
2347
- this.searchResultsCallback = callback;
2348
- this.notifySearchResults();
2349
- }
2350
- notifySearchResults() {
2351
- if (!this.searchResultsCallback)
2352
- return;
2353
- const count = this.searchMatches.length;
2354
- const index = this.searchMatchIndex;
2355
- try {
2356
- this.searchResultsCallback({ resultIndex: index, resultCount: count });
2357
- }
2358
- catch (error) {
2359
- this.logger.debug('[TerminalCore] Search results callback failed', { error });
2360
- }
2361
- }
2362
- findInternal(rawTerm, options, dir) {
2363
- if (!this.terminal || !this.isReady())
2364
- return false;
2365
- const term = (rawTerm ?? '').trim();
2366
- const key = JSON.stringify({
2367
- term,
2368
- caseSensitive: Boolean(options?.caseSensitive),
2369
- wholeWord: Boolean(options?.wholeWord),
2370
- regex: Boolean(options?.regex)
2371
- });
2372
- if (!term) {
2373
- this.clearSearch();
2374
- return false;
2375
- }
2376
- const shouldRescan = term !== this.searchQuery || key !== this.searchOptionsKey;
2377
- this.searchQuery = term;
2378
- this.searchOptionsKey = key;
2379
- if (shouldRescan) {
2380
- this.searchMatches = this.scanTerminalMatches(term, options);
2381
- this.searchRowIndex = this.buildSearchRowIndex(this.searchMatches);
2382
- this.searchMatchIndex = this.searchMatches.length > 0 ? (dir === 1 ? 0 : this.searchMatches.length - 1) : -1;
2383
- this.ensureSearchTheme();
2384
- this.ensureSearchOverlaySubscriptions();
2385
- this.scheduleRenderSearchOverlay();
2386
- }
2387
- else if (this.searchMatches.length > 0) {
2388
- this.searchMatchIndex = dir === 1
2389
- ? (this.searchMatchIndex + 1) % this.searchMatches.length
2390
- : (this.searchMatchIndex - 1 + this.searchMatches.length) % this.searchMatches.length;
2391
- }
2392
- const match = this.searchMatchIndex >= 0 ? this.searchMatches[this.searchMatchIndex] : null;
2393
- if (!match) {
2394
- this.clearActiveSearchSelection();
2395
- this.scheduleRenderSearchOverlay();
2396
- this.notifySearchResults();
2397
- return false;
2398
- }
2399
- this.applySearchMatch(match);
2400
- this.scheduleRenderSearchOverlay();
2401
- this.notifySearchResults();
2402
- return true;
2403
- }
2404
- scanTerminalMatches(term, options) {
2405
- const t = this.terminal;
2406
- if (!t)
2407
- return [];
2408
- const buffer = t.buffer?.active;
2409
- if (!buffer || typeof buffer.length !== 'number')
2410
- return [];
2411
- const caseSensitive = Boolean(options?.caseSensitive);
2412
- const query = caseSensitive ? term : term.toLowerCase();
2413
- // 模糊搜索:按空白切分为多个 token(例如 "git status" -> ["git", "status"])
2414
- const tokens = query.split(/\s+/).filter(Boolean);
2415
- let regex = null;
2416
- if (options?.regex) {
2417
- try {
2418
- regex = new RegExp(term, caseSensitive ? 'g' : 'gi');
2419
- }
2420
- catch {
2421
- regex = null;
2422
- }
2423
- }
2424
- const results = [];
2425
- for (let row = 0; row < buffer.length; row += 1) {
2426
- const line = buffer.getLine(row);
2427
- if (!line)
2428
- continue;
2429
- const raw = line.translateToString(false);
2430
- if (!raw)
2431
- continue;
2432
- const text = caseSensitive ? raw : raw.toLowerCase();
2433
- if (regex) {
2434
- regex.lastIndex = 0;
2435
- for (;;) {
2436
- const m = regex.exec(raw);
2437
- if (!m || typeof m.index !== 'number')
2438
- break;
2439
- const len = Math.max(1, String(m[0] ?? '').length);
2440
- results.push({ row, col: m.index, len });
2441
- if (results.length >= TERMINAL_SEARCH_MAX_RESULTS)
2442
- break;
2443
- if (len <= 0)
2444
- break;
2445
- if (regex.lastIndex === m.index)
2446
- regex.lastIndex += 1;
2447
- }
2448
- }
2449
- else if (tokens.length <= 1) {
2450
- const needle = query;
2451
- let idx = 0;
2452
- while (idx <= text.length - needle.length) {
2453
- const at = text.indexOf(needle, idx);
2454
- if (at < 0)
2455
- break;
2456
- if (options?.wholeWord && !this.isWholeWordMatch(text, at, needle.length)) {
2457
- idx = at + Math.max(1, needle.length);
2458
- continue;
2459
- }
2460
- results.push({ row, col: at, len: Math.max(1, needle.length) });
2461
- if (results.length >= TERMINAL_SEARCH_MAX_RESULTS)
2462
- break;
2463
- idx = at + Math.max(1, needle.length);
2464
- }
2465
- }
2466
- else {
2467
- // Multi-token fuzzy: tokens must appear in order; highlight the full span.
2468
- let from = 0;
2469
- let start = -1;
2470
- let end = -1;
2471
- let ok = true;
2472
- for (const token of tokens) {
2473
- const at = text.indexOf(token, from);
2474
- if (at < 0) {
2475
- ok = false;
2476
- break;
2477
- }
2478
- if (start < 0)
2479
- start = at;
2480
- end = at + token.length;
2481
- from = end;
2482
- }
2483
- if (ok && start >= 0 && end > start) {
2484
- const len = end - start;
2485
- if (!options?.wholeWord || this.isWholeWordMatch(text, start, len)) {
2486
- results.push({ row, col: start, len });
2487
- }
2488
- }
2489
- }
2490
- if (results.length >= TERMINAL_SEARCH_MAX_RESULTS)
2491
- break;
2492
- }
2493
- // Prefer recent output: sort from bottom to top.
2494
- results.sort((a, b) => {
2495
- const dr = b.row - a.row;
2496
- if (dr !== 0)
2497
- return dr;
2498
- return a.col - b.col;
2499
- });
2500
- return results;
2501
- }
2502
- buildSearchRowIndex(matches) {
2503
- const byRow = new Map();
2504
- for (const m of matches) {
2505
- const list = byRow.get(m.row);
2506
- if (list)
2507
- list.push(m);
2508
- else
2509
- byRow.set(m.row, [m]);
2510
- }
2511
- return byRow;
2512
- }
2513
- isWholeWordMatch(haystack, start, len) {
2514
- const before = start > 0 ? haystack[start - 1] : '';
2515
- const after = start + len < haystack.length ? haystack[start + len] : '';
2516
- const isWordChar = (ch) => /[0-9A-Za-z_]/.test(ch);
2517
- if (before && isWordChar(before))
2518
- return false;
2519
- if (after && isWordChar(after))
2520
- return false;
2521
- return true;
2522
- }
2523
- applySearchMatch(match) {
2524
- const t = this.terminal;
2525
- if (!t)
2526
- return;
2527
- const scrollbackLen = typeof t.getScrollbackLength === 'function' ? t.getScrollbackLength() : 0;
2528
- const rows = typeof t.rows === 'number' ? t.rows : 24;
2529
- // Center the match line in viewport when possible.
2530
- const desiredTop = match.row >= scrollbackLen ? scrollbackLen : Math.max(0, Math.min(scrollbackLen, match.row - Math.floor(rows / 2)));
2531
- const viewportY = Math.max(0, Math.min(scrollbackLen, scrollbackLen - desiredTop));
2532
- if (typeof t.scrollToLine === 'function')
2533
- t.scrollToLine(viewportY);
2534
- const sm = t?.selectionManager;
2535
- const startCol = Math.max(0, match.col);
2536
- const endCol = Math.max(startCol, startCol + Math.max(1, match.len) - 1);
2537
- if (sm) {
2538
- this.patchSearchSelectionManager(sm);
2539
- sm.__floeterm_searchAllowSingleCellSelection = true;
2540
- sm.markCurrentSelectionDirty?.();
2541
- sm.selectionStart = { col: startCol, absoluteRow: match.row };
2542
- sm.selectionEnd = { col: endCol, absoluteRow: match.row };
2543
- sm.markCurrentSelectionDirty?.();
2544
- sm.selectionChangedEmitter?.fire?.();
2545
- // @floegence/ghostty-web 不会因为 selection change 自动触发 render;主动刷新以保证 UI(按钮/快捷键)操作立即可见。
2546
- this.forceFullRender();
2547
- return;
2548
- }
2549
- // Fallback: select within viewport row (may fail for scrollback).
2550
- const viewportRow = Math.max(0, Math.min(rows - 1, Math.floor(rows / 2)));
2551
- if (typeof t.select === 'function') {
2552
- t.select(startCol, viewportRow, Math.max(1, match.len));
2553
- // 同上:确保当前匹配的 selection 颜色立即显示。
2554
- this.forceFullRender();
2555
- }
2556
- }
2557
- clearActiveSearchSelection() {
2558
- const t = this.terminal;
2559
- const sm = t?.selectionManager;
2560
- if (sm) {
2561
- sm.__floeterm_searchAllowSingleCellSelection = false;
2562
- }
2563
- if (t?.clearSelection) {
2564
- try {
2565
- t.clearSelection();
2566
- }
2567
- catch {
2568
- }
2569
- }
2570
- // @floegence/ghostty-web 不会因为 clearSelection 自动触发 render;主动刷新以清除残留高亮。
2571
- this.forceFullRender();
2572
- }
2573
- patchSearchSelectionManager(sm) {
2574
- if (!sm || sm.__floeterm_searchPatched)
2575
- return;
2576
- const original = typeof sm.hasSelection === 'function' ? sm.hasSelection.bind(sm) : null;
2577
- if (!original)
2578
- return;
2579
- sm.__floeterm_searchPatched = true;
2580
- sm.__floeterm_searchAllowSingleCellSelection = false;
2581
- sm.hasSelection = () => {
2582
- // Keep original mouse selection semantics, but allow a single-cell selection for search.
2583
- if (sm.__floeterm_searchAllowSingleCellSelection && sm.selectionStart && sm.selectionEnd)
2584
- return true;
2585
- return original();
2586
- };
2587
- }
2588
- ensureSearchTheme() {
2589
- if (!this.terminal)
2590
- return;
2591
- if (this.searchThemeRestore)
2592
- return;
2593
- const terminalAny = this.terminal;
2594
- const rendererTheme = terminalAny?.renderer?.theme;
2595
- const base = rendererTheme && typeof rendererTheme === 'object' ? { ...rendererTheme } : mapThemeToGhostty(this.config.theme);
2596
- this.searchThemeRestore = { ...base };
2597
- const themed = this.applySearchThemeOverride(base);
2598
- this.applyRendererTheme(terminalAny, themed);
2599
- }
2600
- restoreSearchTheme() {
2601
- if (!this.terminal)
2602
- return;
2603
- if (!this.searchThemeRestore)
2604
- return;
2605
- const terminalAny = this.terminal;
2606
- const restore = this.searchThemeRestore;
2607
- this.searchThemeRestore = null;
2608
- this.applyRendererTheme(terminalAny, restore);
2609
- this.forceFullRender();
2610
- }
2611
- applySearchThemeOverride(theme) {
2612
- return {
2613
- ...theme,
2614
- selectionBackground: TERMINAL_SEARCH_ACTIVE_BACKGROUND,
2615
- selectionForeground: TERMINAL_SEARCH_ACTIVE_FOREGROUND,
2616
- selection: TERMINAL_SEARCH_ACTIVE_BACKGROUND
2617
- };
2618
- }
2619
- applyRendererTheme(terminalAny, theme) {
2620
- // @floegence/ghostty-web emits a warning when mutating options.theme after open().
2621
- // Apply theme directly to the renderer to avoid noisy console output.
2622
- if (!terminalAny.isOpen && this.terminal) {
2623
- this.terminal.options.theme = theme;
2624
- }
2625
- terminalAny.renderer?.setTheme?.(theme);
2626
- this.fabricView?.renderer.setAppearance({ theme });
2627
- if (this.isVisualRenderSuspended()) {
2628
- this.markPendingDemandRender(true);
2629
- return;
2630
- }
2631
- if (terminalAny.renderer?.render && terminalAny.wasmTerm) {
2632
- try {
2633
- terminalAny.renderer.render(terminalAny.wasmTerm, true, terminalAny.viewportY ?? 0, terminalAny, terminalAny.scrollbarOpacity);
2634
- }
2635
- catch (error) {
2636
- this.logger.debug('[TerminalCore] Theme render failed', { error });
2637
- }
2638
- }
2639
- }
2640
- captureRenderSnapshot() {
2641
- const terminalAny = this.terminal;
2642
- if (!terminalAny) {
2643
- return null;
2644
- }
2645
- const cursor = terminalAny.wasmTerm?.getCursor?.();
2646
- const cursorY = Number(cursor?.y);
2647
- const rows = Number(terminalAny.rows);
2648
- const scrollbackLength = (() => {
2649
- if (typeof terminalAny.getScrollbackLength === 'function') {
2650
- return Number(terminalAny.getScrollbackLength.call(terminalAny));
2651
- }
2652
- if (typeof terminalAny.wasmTerm?.getScrollbackLength === 'function') {
2653
- return Number(terminalAny.wasmTerm.getScrollbackLength.call(terminalAny.wasmTerm));
2654
- }
2655
- return Number.NaN;
2656
- })();
2657
- const viewportY = Number(terminalAny.viewportY ?? 0);
2658
- return {
2659
- cursorY: Number.isFinite(cursorY) ? cursorY : null,
2660
- rows: Number.isFinite(rows) ? rows : null,
2661
- scrollbackLength: Number.isFinite(scrollbackLength) ? scrollbackLength : null,
2662
- viewportY: Number.isFinite(viewportY) ? viewportY : null,
2663
- };
2664
- }
2665
- shouldForceFullRenderAfterWrite(before) {
2666
- if (!before) {
2667
- return false;
2668
- }
2669
- const after = this.captureRenderSnapshot();
2670
- if (!after) {
2671
- return false;
2672
- }
2673
- if (before.viewportY !== null
2674
- && after.viewportY !== null
2675
- && before.viewportY !== after.viewportY) {
2676
- return true;
2677
- }
2678
- if (before.scrollbackLength !== null
2679
- && after.scrollbackLength !== null
2680
- && before.scrollbackLength !== after.scrollbackLength) {
2681
- return true;
2682
- }
2683
- return Boolean(before.cursorY !== null
2684
- && after.cursorY !== null
2685
- && before.rows !== null
2686
- && before.cursorY >= before.rows - 1
2687
- && after.cursorY < before.cursorY);
2688
- }
2689
- ensureSearchOverlaySubscriptions() {
2690
- const t = this.terminal;
2691
- if (!t)
2692
- return;
2693
- if (this.searchOverlayUnsubs.length > 0)
2694
- return;
2695
- // Keep overlay synced with scroll/resize.
2696
- const onScroll = typeof t.onScroll === 'function' ? t.onScroll(() => this.scheduleRenderSearchOverlay()) : null;
2697
- if (onScroll?.dispose)
2698
- this.searchOverlayUnsubs.push(() => onScroll.dispose());
2699
- const onResize = typeof t.onResize === 'function' ? t.onResize(() => this.scheduleRenderSearchOverlay()) : null;
2700
- if (onResize?.dispose)
2701
- this.searchOverlayUnsubs.push(() => onResize.dispose());
2702
- }
2703
- disposeSearchOverlaySubscriptions() {
2704
- for (const unsub of this.searchOverlayUnsubs)
2705
- unsub();
2706
- this.searchOverlayUnsubs = [];
2707
- }
2708
- disposeSearchOverlay() {
2709
- if (this.searchOverlayRaf !== null) {
2710
- cancelAnimationFrame(this.searchOverlayRaf);
2711
- this.searchOverlayRaf = null;
2712
- }
2713
- this.disposeSearchOverlaySubscriptions();
2714
- if (this.searchOverlay?.canvas?.parentElement) {
2715
- this.searchOverlay.canvas.parentElement.removeChild(this.searchOverlay.canvas);
2716
- }
2717
- this.searchOverlay = null;
2718
- }
2719
- createCanvasOverlay(zIndex) {
2720
- const t = this.terminal;
2721
- if (!t)
2722
- return null;
2723
- const renderer = t?.renderer;
2724
- const termCanvas = renderer?.getCanvas?.();
2725
- if (!termCanvas)
2726
- return null;
2727
- try {
2728
- const style = getComputedStyle(this.container);
2729
- if (style.position === 'static')
2730
- this.container.style.position = 'relative';
2731
- }
2732
- catch {
2733
- }
2734
- const overlay = document.createElement('canvas');
2735
- overlay.style.position = 'absolute';
2736
- overlay.style.top = '0';
2737
- overlay.style.left = '0';
2738
- overlay.style.pointerEvents = 'none';
2739
- overlay.style.zIndex = zIndex;
2740
- overlay.style.mixBlendMode = 'normal';
2741
- const ctx = overlay.getContext('2d');
2742
- if (!ctx)
2743
- return null;
2744
- this.container.appendChild(overlay);
2745
- const state = {
2746
- canvas: overlay,
2747
- ctx,
2748
- dpr: 1,
2749
- cssWidth: 0,
2750
- cssHeight: 0,
2751
- termCanvas,
2752
- };
2753
- syncCanvasOverlaySize(state, termCanvas, this.container);
2754
- return state;
2755
- }
2756
- ensureSearchOverlay() {
2757
- const t = this.terminal;
2758
- if (!t)
2759
- return null;
2760
- const renderer = t?.renderer;
2761
- const termCanvas = renderer?.getCanvas?.();
2762
- if (!termCanvas)
2763
- return null;
2764
- // Ensure we have a positioning context.
2765
- try {
2766
- const style = getComputedStyle(this.container);
2767
- if (style.position === 'static')
2768
- this.container.style.position = 'relative';
2769
- }
2770
- catch {
2771
- }
2772
- if (this.searchOverlay && this.searchOverlay.termCanvas !== termCanvas) {
2773
- this.disposeSearchOverlay();
2774
- }
2775
- if (!this.searchOverlay) {
2776
- this.searchOverlay = this.createCanvasOverlay('5');
2777
- }
2778
- if (this.searchOverlay) {
2779
- syncCanvasOverlaySize(this.searchOverlay, termCanvas, this.container);
2780
- }
2781
- return this.searchOverlay;
2782
- }
2783
- scheduleRenderSearchOverlay() {
2784
- if (this.isVisualRenderSuspended()) {
2785
- this.visualRenderState.pendingSearchOverlayRender = true;
2786
- return;
2787
- }
2788
- if (this.searchOverlayRaf !== null)
2789
- return;
2790
- this.searchOverlayRaf = requestAnimationFrame(() => {
2791
- this.searchOverlayRaf = null;
2792
- this.renderSearchOverlay();
2793
- });
2794
- }
2795
- renderSearchOverlay() {
2796
- const t = this.terminal;
2797
- const queryActive = this.searchQuery.trim().length > 0;
2798
- const rowIndex = this.searchRowIndex;
2799
- if (!queryActive || rowIndex.size === 0 || !t) {
2800
- this.searchOverlay?.ctx.clearRect(0, 0, this.searchOverlay.cssWidth, this.searchOverlay.cssHeight);
2801
- return;
2802
- }
2803
- const overlay = this.ensureSearchOverlay();
2804
- if (!overlay)
2805
- return;
2806
- const renderer = t?.renderer;
2807
- const charW = typeof renderer?.charWidth === 'number' ? renderer.charWidth : 0;
2808
- const charH = typeof renderer?.charHeight === 'number' ? renderer.charHeight : 0;
2809
- const cols = typeof t.cols === 'number' ? t.cols : 0;
2810
- const rows = typeof t.rows === 'number' ? t.rows : 0;
2811
- const scrollbackLen = typeof t.getScrollbackLength === 'function' ? t.getScrollbackLength() : 0;
2812
- const viewportY = Math.max(0, Math.floor(typeof t.getViewportY === 'function' ? t.getViewportY() : t.viewportY ?? 0));
2813
- const viewportTop = Math.max(0, scrollbackLen - viewportY);
2814
- if (charW <= 0 || charH <= 0 || cols <= 0 || rows <= 0) {
2815
- overlay.ctx.clearRect(0, 0, overlay.cssWidth, overlay.cssHeight);
2816
- return;
2817
- }
2818
- overlay.ctx.clearRect(0, 0, overlay.cssWidth, overlay.cssHeight);
2819
- overlay.ctx.fillStyle = TERMINAL_SEARCH_MATCH_BACKGROUND;
2820
- for (let y = 0; y < rows; y += 1) {
2821
- const absRow = viewportTop + y;
2822
- const list = rowIndex.get(absRow);
2823
- if (!list || list.length === 0)
2824
- continue;
2825
- for (const m of list) {
2826
- const col = Math.max(0, Math.min(cols - 1, m.col));
2827
- const maxLen = Math.max(0, cols - col);
2828
- const len = Math.max(0, Math.min(maxLen, m.len));
2829
- if (len <= 0)
2830
- continue;
2831
- overlay.ctx.fillRect(col * charW, y * charH, len * charW, charH);
2832
- }
2833
- }
2834
- }
2835
- focus(options) {
2836
- this.terminal?.focus();
2837
- this.focusInputSurface(options);
2838
- this.scheduleInputSurfaceRefocus(options);
2839
- }
2840
- setConnected(isConnected) {
2841
- this.setState(isConnected ? TerminalState.CONNECTED : TerminalState.READY);
2842
- if (isConnected) {
2843
- this.scheduleFabricRendererAttach();
2844
- }
2845
- }
2846
- forceResize() {
2847
- this.flushPendingPresentationScale();
2848
- this.performResize('force');
2849
- this.requestDemandRender(true);
2850
- }
2851
- /**
2852
- * Measures the host capacity without changing the currently rendered PTY grid.
2853
- * This is used by active shared-geometry views before an explicit transport resize.
2854
- */
2855
- measureHostDimensions() {
2856
- if (!this.isReady() || !this.terminal) {
2857
- return undefined;
2858
- }
2859
- return this.proposeFitDimensions();
2860
- }
2861
- async forceResizeAndWaitForPresentation() {
2862
- while (true) {
2863
- const fontMetricSeq = await this.waitForStableFontMetrics();
2864
- await this.requestPresentationFrame();
2865
- await this.waitForStableFontMetrics();
2866
- if (fontMetricSeq === this.fontMetricSeq) {
2867
- return;
2868
- }
2869
- }
2870
- }
2871
- /**
2872
- * Forces a resize and waits until the terminal renderer has committed a full frame.
2873
- * Unlike {@link forceResizeAndWaitForPresentation}, this does not wait for the
2874
- * following browser paint frame.
2875
- */
2876
- async forceResizeAndWaitForCommittedFrame() {
2877
- if (this.isDisposed) {
2878
- throw new Error('Cannot present a disposed TerminalCore');
2879
- }
2880
- if (!this.terminal || !this.isReady()) {
2881
- throw new Error('Cannot present a TerminalCore before initialization completes');
2882
- }
2883
- await new Promise((resolve, reject) => {
2884
- const request = {
2885
- baselineGeneration: this.committedFabricFrameGeneration,
2886
- frameHandle: null,
2887
- resolve: () => {
2888
- this.committedFrameRequests.delete(request);
2889
- resolve();
2890
- },
2891
- reject: error => {
2892
- this.committedFrameRequests.delete(request);
2893
- reject(error);
2894
- },
2895
- };
2896
- this.committedFrameRequests.add(request);
2897
- const start = () => {
2898
- if (this.isDisposed) {
2899
- this.rejectCommittedFrameRequest(request, new Error('TerminalCore was disposed before the committed frame completed'));
2900
- return;
2901
- }
2902
- if (this.config.rendererType === 'webgl' && !this.isFabricRendererActive()) {
2903
- void this.waitForFabricRendererAttach().then(() => {
2904
- if (this.isDisposed) {
2905
- this.rejectCommittedFrameRequest(request, new Error('TerminalCore was disposed before the committed frame completed'));
2906
- }
2907
- else {
2908
- this.forceCommittedFrame(request);
2909
- this.scheduleCommittedFrameCheck(request);
2910
- }
2911
- }).catch(error => this.rejectCommittedFrameRequest(request, error instanceof Error ? error : new Error(String(error))));
2912
- return;
2913
- }
2914
- this.forceCommittedFrame(request);
2915
- this.scheduleCommittedFrameCheck(request);
2916
- };
2917
- start();
2918
- });
2919
- }
2920
- forceCommittedFrame(request) {
2921
- this.forceResize();
2922
- if (!this.committedFrameRequests.has(request)
2923
- || this.config.rendererType !== 'webgl'
2924
- || !this.isFabricRendererActive()
2925
- || this.isVisualRenderSuspended()) {
2926
- return;
2927
- }
2928
- // A committed-frame fence is an explicit interactive presentation request.
2929
- // Render it in this task so shared background RAF work cannot add a frame of
2930
- // queueing latency; the full frame subsumes this core's pending demand render.
2931
- terminalRenderScheduler.cancel(this.renderTask);
2932
- this.demandRenderForceAll = false;
2933
- this.renderDemandFrame(true);
2934
- }
2935
- requestPresentationFrame() {
2936
- if (this.isDisposed) {
2937
- return Promise.reject(new Error('Cannot present a disposed TerminalCore'));
2938
- }
2939
- if (!this.terminal || !this.isReady()) {
2940
- return Promise.reject(new Error('Cannot present a TerminalCore before initialization completes'));
2941
- }
2942
- if (this.config.rendererType === 'webgl' && !this.isFabricRendererActive()) {
2943
- return this.waitForFabricRendererAttach().then(() => {
2944
- if (this.isDisposed)
2945
- throw new Error('TerminalCore was disposed before presentation started');
2946
- this.forceResize();
2947
- return this.createPresentationRequest();
2948
- });
2949
- }
2950
- return this.createPresentationRequest();
2951
- }
2952
- createPresentationRequest() {
2953
- if (this.pendingPresentationRequest) {
2954
- this.forceResize();
2955
- return this.pendingPresentationRequest.promise;
2956
- }
2957
- let resolve;
2958
- let reject;
2959
- const promise = new Promise((resolvePromise, rejectPromise) => {
2960
- resolve = resolvePromise;
2961
- reject = rejectPromise;
2962
- });
2963
- const request = { promise, resolve, reject, frameHandle: null };
2964
- this.pendingPresentationRequest = request;
2965
- this.presentationRequests.add(request);
2966
- this.forceResize();
2967
- return promise;
2968
- }
2969
- setFixedDimensions(dimensions, options) {
2970
- const next = normalizeTerminalDimensions(dimensions);
2971
- if (sameTerminalDimensions(this.fixedDimensions, next) || (!this.fixedDimensions && !next)) {
2972
- return;
2973
- }
2974
- this.fixedDimensions = next;
2975
- this.config = { ...this.config, fixedDimensions: next };
2976
- if (!this.terminal) {
2977
- return;
2978
- }
2979
- const previousSuppression = this.suppressResizeNotifications;
2980
- if (options?.notifyResize === false) {
2981
- this.suppressResizeNotifications = true;
2982
- }
2983
- try {
2984
- this.forceResize();
2985
- }
2986
- finally {
2987
- this.suppressResizeNotifications = previousSuppression;
2988
- }
2989
- }
2990
- setAppearance(appearance) {
2991
- if (!appearance || typeof appearance !== 'object') {
2992
- return;
2993
- }
2994
- if (appearance.theme) {
2995
- this.setTheme(appearance.theme);
2996
- }
2997
- if (typeof appearance.fontSize === 'number') {
2998
- this.setFontSize(appearance.fontSize);
2999
- }
3000
- if (typeof appearance.fontFamily === 'string') {
3001
- this.setFontFamily(appearance.fontFamily);
3002
- }
3003
- if (typeof appearance.presentationScale === 'number') {
3004
- this.setPresentationScale(appearance.presentationScale);
3005
- }
3006
- }
3007
- setScrollbarOptions(options) {
3008
- normalizeTerminalScrollbarOptions(options);
3009
- const next = normalizeTerminalScrollbarOptions({ ...this.scrollbarOptions, ...options });
3010
- this.scrollbarOptions = next;
3011
- this.config = { ...this.config, scrollbar: next };
3012
- this.scrollbarOverlay?.setOptions(next);
3013
- }
3014
- setTheme(theme) {
3015
- const mapped = mapThemeToGhostty(theme);
3016
- // Persist latest theme so a future re-initialization can reuse it.
3017
- this.config = { ...this.config, theme: mapped };
3018
- this.themeColorTranslator = buildThemeColorTranslator(this.terminalThemeSource, mapped);
3019
- this.scrollbarOverlay?.setBackgroundColor(mapped.background);
3020
- if (!this.terminal) {
3021
- return;
3022
- }
3023
- const terminalAny = this.terminal;
3024
- // If search is active, apply the theme but keep the search selection colors.
3025
- if (this.searchQuery.trim().length > 0) {
3026
- this.searchThemeRestore = { ...mapped };
3027
- this.applyRendererTheme(terminalAny, this.applySearchThemeOverride(mapped));
3028
- this.scheduleRenderSearchOverlay();
3029
- return;
3030
- }
3031
- this.applyRendererTheme(terminalAny, mapped);
3032
- }
3033
- setFontSize(size) {
3034
- this.logicalFontSize = TerminalCore.normalizeFontSize(size);
3035
- this.config = { ...this.config, fontSize: this.logicalFontSize };
3036
- if (!this.terminal) {
3037
- return;
3038
- }
3039
- this.terminal.options.fontSize = this.resolveEffectiveFontSize();
3040
- this.fabricView?.renderer.setAppearance({ fontSize: this.resolveEffectiveFontSize() });
3041
- this.performResize('font');
3042
- void this.refreshFontMetricsAfterLoad('font_size');
3043
- }
3044
- setPresentationScale(scale) {
3045
- const nextScale = TerminalCore.normalizePresentationScale(scale);
3046
- const currentTargetScale = this.pendingPresentationScale ?? this.presentationScale;
3047
- if (samePresentationScale(nextScale, currentTargetScale)) {
3048
- return;
3049
- }
3050
- this.pendingPresentationScale = nextScale;
3051
- this.config = { ...this.config, presentationScale: nextScale };
3052
- this.applyPresentationScaleStyles();
3053
- if (typeof window === 'undefined') {
3054
- this.commitPresentationScale(nextScale);
3055
- return;
3056
- }
3057
- if (this.presentationScaleRaf !== null) {
3058
- return;
3059
- }
3060
- this.presentationScaleRaf = window.requestAnimationFrame(() => {
3061
- this.presentationScaleRaf = null;
3062
- const targetScale = this.pendingPresentationScale ?? this.presentationScale;
3063
- this.pendingPresentationScale = null;
3064
- this.commitPresentationScale(targetScale);
3065
- });
3066
- }
3067
- setFontFamily(family) {
3068
- const nextFamily = String(family ?? '').trim();
3069
- if (!nextFamily) {
3070
- return;
3071
- }
3072
- this.config = { ...this.config, fontFamily: nextFamily };
3073
- if (!this.terminal) {
3074
- return;
3075
- }
3076
- this.terminal.options.fontFamily = nextFamily;
3077
- this.fabricView?.renderer.setAppearance({
3078
- fontFamily: nextFamily,
3079
- fontSize: this.resolveEffectiveFontSize(),
3080
- });
3081
- this.performResize('font');
3082
- this.forceFullRender();
3083
- void this.refreshFontMetricsAfterLoad('font_family');
3084
- }
3085
- beginVisualSuspend(options = {}) {
3086
- const id = this.visualRenderState.nextSuspendId;
3087
- this.visualRenderState.nextSuspendId += 1;
3088
- const reason = options.reason ?? 'external';
3089
- let disposed = false;
3090
- this.visualRenderState.suspendDepth += 1;
3091
- this.visualRenderState.activeReasons.set(id, reason);
3092
- // Legacy compatibility: keep the handle lifecycle observable, but never
3093
- // freeze visible terminal rendering in the live fabric path.
3094
- this.visualRenderState.suspendDepth = Math.max(0, this.visualRenderState.suspendDepth - 1);
3095
- return {
3096
- id,
3097
- reason,
3098
- dispose: () => {
3099
- if (disposed) {
3100
- return;
3101
- }
3102
- disposed = true;
3103
- this.endVisualSuspend(id);
3104
- },
3105
- };
3106
- }
3107
- registerLinkProvider(provider) {
3108
- if (!provider || this.registeredLinkProviders.has(provider)) {
3109
- return;
3110
- }
3111
- this.registeredLinkProviders.add(provider);
3112
- this.applyRegisteredLinkProviders();
3113
- }
3114
- dispose() {
3115
- this.isDisposed = true;
3116
- const disposalError = new Error('TerminalCore was disposed before presentation completed');
3117
- this.rejectFabricAttachWaiters(disposalError);
3118
- this.rejectAllPresentationRequests(disposalError);
3119
- this.initializationOperation?.lifecycleAbortController.abort();
3120
- this.initializationOperation?.request.cancel();
3121
- if (this.focusResizeRaf !== null) {
3122
- cancelAnimationFrame(this.focusResizeRaf);
3123
- this.focusResizeRaf = null;
3124
- }
3125
- if (this.presentationScaleRaf !== null) {
3126
- cancelAnimationFrame(this.presentationScaleRaf);
3127
- this.presentationScaleRaf = null;
3128
- }
3129
- if (this.clearResizeSuppressionRaf !== null) {
3130
- cancelAnimationFrame(this.clearResizeSuppressionRaf);
3131
- this.clearResizeSuppressionRaf = null;
3132
- }
3133
- if (this.resizeRaf !== null) {
3134
- cancelAnimationFrame(this.resizeRaf);
3135
- this.resizeRaf = null;
3136
- }
3137
- this.cancelDemandRender();
3138
- this.unbindResponsiveListeners?.();
3139
- this.unbindResponsiveListeners = null;
3140
- this.resizeObserver?.disconnect();
3141
- this.resizeObserver = null;
3142
- if (this.resizeDebounceTimer) {
3143
- clearTimeout(this.resizeDebounceTimer);
3144
- this.resizeDebounceTimer = null;
3145
- }
3146
- this.visualRenderState.suspendDepth = 0;
3147
- this.visualRenderState.activeReasons.clear();
3148
- this.visualRenderState.pendingDemandRender = false;
3149
- this.visualRenderState.pendingForceFullRender = false;
3150
- this.visualRenderState.pendingResizeReason = null;
3151
- this.visualRenderState.pendingSearchOverlayRender = false;
3152
- if (this.replayingHistoryTimer) {
3153
- clearTimeout(this.replayingHistoryTimer);
3154
- this.replayingHistoryTimer = null;
3155
- }
3156
- this.disposeInputBridge();
3157
- this.disposeTerminalEventListeners();
3158
- this.disposeSearchOverlay();
3159
- this.scrollbarOverlay?.dispose();
3160
- this.scrollbarOverlay = null;
3161
- this.fabricAttachSeq += 1;
3162
- this.cancelFabricAttachSchedule?.();
3163
- this.cancelFabricAttachSchedule = null;
3164
- this.cancelInputRefocusSchedule?.();
3165
- this.cancelInputRefocusSchedule = null;
3166
- this.fabricView?.dispose();
3167
- this.fabricView = null;
3168
- this.terminal?.dispose();
3169
- this.terminal = null;
3170
- this.ownedRuntimeMemory = null;
3171
- this.ownedRuntime = null;
3172
- this.inputElement?.remove();
3173
- this.inputElement = null;
3174
- this.viewportHost?.remove();
3175
- this.viewportHost = null;
3176
- this.renderHost = null;
3177
- this.fitAddon = null;
3178
- this.registeredLinkProviders.clear();
3179
- this.appliedLinkProviders.clear();
3180
- this.setState(TerminalState.DISPOSED);
3181
- }
3182
- disposeInputBridge() {
3183
- this.inputBridge?.dispose();
3184
- this.inputBridge = null;
3185
- }
3186
- disposeTerminalEventListeners() {
3187
- for (const dispose of this.terminalEventDisposables) {
3188
- dispose();
3189
- }
3190
- this.terminalEventDisposables = [];
3191
- }
3192
- trackTerminalEventDisposable(disposable) {
3193
- if (!disposable?.dispose) {
3194
- return;
3195
- }
3196
- this.terminalEventDisposables.push(() => disposable.dispose?.());
3197
- }
3198
- applyRegisteredLinkProviders() {
3199
- if (!this.terminal || typeof this.terminal.registerLinkProvider !== 'function') {
3200
- return;
3201
- }
3202
- for (const provider of this.registeredLinkProviders) {
3203
- if (this.appliedLinkProviders.has(provider)) {
3204
- continue;
3205
- }
3206
- try {
3207
- this.terminal.registerLinkProvider(provider);
3208
- this.appliedLinkProviders.add(provider);
3209
- }
3210
- catch (error) {
3211
- this.logger.warn('[TerminalCore] Failed to register link provider', { error });
3212
- }
3213
- }
3214
- }
3215
- static normalizeResponsiveConfig(value) {
3216
- const raw = (typeof value === 'object' && value) ? value : {};
3217
- return {
3218
- fitOnFocus: Boolean(raw.fitOnFocus),
3219
- emitResizeOnFocus: Boolean(raw.emitResizeOnFocus),
3220
- notifyResizeOnlyWhenFocused: Boolean(raw.notifyResizeOnlyWhenFocused),
3221
- reportHostDimensionsWithFixedGrid: Boolean(raw.reportHostDimensionsWithFixedGrid),
3222
- };
3223
- }
3224
- static normalizeClipboardConfig(value) {
3225
- const raw = (typeof value === 'object' && value) ? value : {};
3226
- return {
3227
- copyOnSelect: raw.copyOnSelect !== false,
3228
- };
3229
- }
3230
- static normalizeFitConfig(value) {
3231
- const raw = (typeof value === 'object' && value) ? value : {};
3232
- return {
3233
- scrollbarReservePx: normalizeNonNegativePixels(raw.scrollbarReservePx, GHOSTTY_DEFAULT_SCROLLBAR_RESERVE_PX),
3234
- };
3235
- }
3236
- static normalizeFontSize(value) {
3237
- const size = Number(value);
3238
- if (!Number.isFinite(size) || size <= 0) {
3239
- return 12;
3240
- }
3241
- return size;
3242
- }
3243
- static normalizePresentationScale(value) {
3244
- const scale = Number(value);
3245
- if (!Number.isFinite(scale) || scale <= 1.001) {
3246
- return 1;
3247
- }
3248
- return Math.min(scale, 4);
3249
- }
3250
- resolveEffectiveFontSize(scale = this.pendingPresentationScale ?? this.presentationScale) {
3251
- return Math.max(1, this.logicalFontSize * scale);
3252
- }
3253
- isVisualRenderSuspended() {
3254
- return this.visualRenderState.suspendDepth > 0;
3255
- }
3256
- markPendingDemandRender(forceAll) {
3257
- this.visualRenderState.pendingDemandRender = true;
3258
- this.visualRenderState.pendingForceFullRender = this.visualRenderState.pendingForceFullRender || forceAll;
3259
- this.demandRenderForceAll = this.demandRenderForceAll || forceAll;
3260
- }
3261
- markPendingResize(reason) {
3262
- this.visualRenderState.pendingResizeReason = this.mergePendingResizeReason(this.visualRenderState.pendingResizeReason, reason);
3263
- }
3264
- mergePendingResizeReason(current, next) {
3265
- if (!current) {
3266
- return next;
3267
- }
3268
- if (current === 'force' || next === 'force') {
3269
- return 'force';
3270
- }
3271
- if (current === 'font' || next === 'font') {
3272
- return 'font';
3273
- }
3274
- if (current === 'focus' || next === 'focus') {
3275
- return 'focus';
3276
- }
3277
- if (current === 'post_init' || next === 'post_init') {
3278
- return 'post_init';
3279
- }
3280
- return 'observer';
3281
- }
3282
- endVisualSuspend(id) {
3283
- if (!this.visualRenderState.activeReasons.has(id)) {
3284
- return;
3285
- }
3286
- this.visualRenderState.activeReasons.delete(id);
3287
- this.visualRenderState.suspendDepth = Math.max(0, this.visualRenderState.suspendDepth - 1);
3288
- if (this.visualRenderState.suspendDepth > 0) {
3289
- return;
3290
- }
3291
- this.flushDeferredVisualWork();
3292
- }
3293
- flushDeferredVisualWork() {
3294
- if (this.isDisposed || !this.terminal) {
3295
- return;
3296
- }
3297
- const resizeReason = this.visualRenderState.pendingResizeReason;
3298
- const shouldRender = this.visualRenderState.pendingDemandRender || this.demandRenderForceAll;
3299
- const shouldForceRender = this.visualRenderState.pendingForceFullRender || this.demandRenderForceAll;
3300
- const shouldRenderSearchOverlay = this.visualRenderState.pendingSearchOverlayRender;
3301
- this.visualRenderState.pendingResizeReason = null;
3302
- this.visualRenderState.pendingDemandRender = false;
3303
- this.visualRenderState.pendingForceFullRender = false;
3304
- this.visualRenderState.pendingSearchOverlayRender = false;
3305
- this.demandRenderForceAll = false;
3306
- if (resizeReason) {
3307
- this.performResizeNow(resizeReason);
3308
- }
3309
- if (shouldRender) {
3310
- this.requestDemandRender(shouldForceRender);
3311
- }
3312
- if (shouldRenderSearchOverlay) {
3313
- this.scheduleRenderSearchOverlay();
3314
- }
3315
- }
3316
- flushPendingPresentationScale() {
3317
- if (this.pendingPresentationScale === null) {
3318
- return;
3319
- }
3320
- const targetScale = this.pendingPresentationScale;
3321
- this.pendingPresentationScale = null;
3322
- if (this.presentationScaleRaf !== null) {
3323
- cancelAnimationFrame(this.presentationScaleRaf);
3324
- this.presentationScaleRaf = null;
3325
- }
3326
- this.commitPresentationScale(targetScale);
3327
- }
3328
- commitPresentationScale(scale) {
3329
- const nextScale = TerminalCore.normalizePresentationScale(scale);
3330
- if (samePresentationScale(nextScale, this.presentationScale)) {
3331
- this.applyPresentationScaleStyles();
3332
- return;
3333
- }
3334
- this.presentationScale = nextScale;
3335
- this.applyPresentationScaleStyles();
3336
- if (!this.terminal) {
3337
- return;
3338
- }
3339
- this.suppressResizeNotifications = true;
3340
- if (this.clearResizeSuppressionRaf !== null) {
3341
- cancelAnimationFrame(this.clearResizeSuppressionRaf);
3342
- }
3343
- this.clearResizeSuppressionRaf = requestAnimationFrame(() => {
3344
- this.clearResizeSuppressionRaf = null;
3345
- this.suppressResizeNotifications = false;
3346
- });
3347
- this.terminal.options.fontSize = this.resolveEffectiveFontSize(nextScale);
3348
- this.fabricView?.renderer.setAppearance({ fontSize: this.resolveEffectiveFontSize(nextScale) });
3349
- if (this.isVisualRenderSuspended()) {
3350
- this.markPendingResize('force');
3351
- this.markPendingDemandRender(true);
3352
- this.scheduleRenderSearchOverlay();
3353
- return;
3354
- }
3355
- this.performResize('force');
3356
- this.forceFullRender();
3357
- this.scheduleRenderSearchOverlay();
3358
- }
3359
- resizeFabricToHost() {
3360
- const host = this.renderHost ?? this.container;
3361
- if (!host) {
3362
- return;
3363
- }
3364
- const width = host.clientWidth;
3365
- const height = host.clientHeight;
3366
- if (width <= 0 || height <= 0) {
3367
- return;
3368
- }
3369
- this.fabricView?.renderer.resize(width, height);
3370
- this.syncGhosttyInputGeometryToFabric();
3371
- }
3372
- isFabricRendererActive() {
3373
- return Boolean(this.fabricView?.renderer.isActive());
3374
- }
3375
- forceFullRender() {
3376
- if (!this.terminal || !this.isReady()) {
3377
- return;
3378
- }
3379
- if (this.isVisualRenderSuspended()) {
3380
- this.markPendingDemandRender(true);
3381
- return;
3382
- }
3383
- this.renderDemandFrame(true);
3384
- }
3385
- requestDemandRender(forceAll) {
3386
- if (!this.terminal || this.isDisposed) {
3387
- return;
3388
- }
3389
- if (this.isVisualRenderSuspended()) {
3390
- this.markPendingDemandRender(forceAll);
3391
- return;
3392
- }
3393
- this.demandRenderForceAll = this.demandRenderForceAll || forceAll;
3394
- terminalRenderScheduler.schedule(this.renderTask, this.demandRenderForceAll);
3395
- }
3396
- cancelDemandRender() {
3397
- terminalRenderScheduler.cancel(this.renderTask);
3398
- this.demandRenderForceAll = false;
3399
- this.stopGhosttyRenderLoop();
3400
- }
3401
- stopGhosttyRenderLoop() {
3402
- const terminalAny = this.terminal;
3403
- if (!terminalAny || typeof terminalAny.animationFrameId !== 'number') {
3404
- return;
3405
- }
3406
- cancelAnimationFrame(terminalAny.animationFrameId);
3407
- terminalAny.animationFrameId = undefined;
3408
- }
3409
- renderDemandFrame(forceAll) {
3410
- if (!this.terminal || this.isDisposed) {
3411
- return;
3412
- }
3413
- const terminalAny = this.terminal;
3414
- if (terminalAny.isDisposed || terminalAny.isOpen === false || !terminalAny.renderer?.render || !terminalAny.wasmTerm) {
3415
- return;
3416
- }
3417
- const presentationRequest = forceAll ? this.pendingPresentationRequest : null;
3418
- const expectedRows = Math.max(0, Math.floor(Number(terminalAny.wasmTerm.getDimensions?.().rows ?? 0)));
3419
- const fabricFrameGeneration = this.committedFabricFrameGeneration;
3420
- try {
3421
- const startedAt = performance.now();
3422
- this.keepDemandCursorVisible(terminalAny.renderer);
3423
- terminalAny.renderer.render(terminalAny.wasmTerm, forceAll, terminalAny.viewportY ?? 0, terminalAny, terminalAny.scrollbarOpacity);
3424
- this.settleCommittedFrameRequests();
3425
- const cursor = terminalAny.wasmTerm.getCursor?.();
3426
- if (cursor && typeof cursor.y === 'number' && cursor.y !== terminalAny.lastCursorY) {
3427
- terminalAny.lastCursorY = cursor.y;
3428
- terminalAny.cursorMoveEmitter?.fire?.();
3429
- }
3430
- this.syncImeInputAnchor();
3431
- this.scrollbarOverlay?.sync();
3432
- const durationMs = performance.now() - startedAt;
3433
- getPerfProbe()?.onTerminalRender?.(durationMs);
3434
- if (this.config.rendererType !== 'webgl'
3435
- || this.committedFabricFrameGeneration > fabricFrameGeneration) {
3436
- try {
3437
- this.eventHandlers.onRender?.(durationMs);
3438
- }
3439
- catch (error) {
3440
- this.logger.debug('[TerminalCore] onRender observer failed', { error });
3441
- }
3442
- }
3443
- if (presentationRequest && this.pendingPresentationRequest === presentationRequest) {
3444
- if (this.config.rendererType === 'webgl') {
3445
- const committed = this.lastCommittedFabricFrame;
3446
- if (!committed
3447
- || committed.generation <= fabricFrameGeneration
3448
- || expectedRows <= 0
3449
- || committed.result.renderedRows !== expectedRows) {
3450
- this.rejectPresentationRequest(presentationRequest, new Error('The forced Beamterm frame was incomplete '
3451
- + `(expected_rows=${expectedRows} rendered_rows=${committed?.result.renderedRows ?? 0})`));
3452
- return;
3453
- }
3454
- const fabricRenderer = this.fabricView?.renderer;
3455
- if (!fabricRenderer) {
3456
- throw new Error('The Beamterm renderer detached before its frame completed');
3457
- }
3458
- fabricRenderer.finishSubmittedFrame();
3459
- this.submittedFabricFrameGeneration = committed.generation;
3460
- }
3461
- this.pendingPresentationRequest = null;
3462
- presentationRequest.frameHandle = requestAnimationFrame(() => {
3463
- if (!this.presentationRequests.has(presentationRequest))
3464
- return;
3465
- if (!this.presentationRequests.delete(presentationRequest))
3466
- return;
3467
- presentationRequest.frameHandle = null;
3468
- presentationRequest.resolve();
3469
- });
3470
- }
3471
- }
3472
- catch (error) {
3473
- if (presentationRequest && this.presentationRequests.has(presentationRequest)) {
3474
- this.rejectPresentationRequest(presentationRequest, error instanceof Error ? error : new Error(String(error)));
3475
- }
3476
- this.logger.debug('[TerminalCore] Force render failed', { error });
3477
- }
3478
- }
3479
- rejectPresentationRequest(request, error) {
3480
- if (!this.presentationRequests.delete(request))
3481
- return;
3482
- if (this.pendingPresentationRequest === request) {
3483
- this.pendingPresentationRequest = null;
3484
- }
3485
- if (request.frameHandle !== null) {
3486
- cancelAnimationFrame(request.frameHandle);
3487
- request.frameHandle = null;
3488
- }
3489
- request.reject(error);
3490
- }
3491
- scheduleCommittedFrameCheck(request) {
3492
- if (!this.committedFrameRequests.has(request) || request.frameHandle !== null)
3493
- return;
3494
- queueMicrotask(() => {
3495
- if (!this.committedFrameRequests.has(request) || request.frameHandle !== null)
3496
- return;
3497
- request.frameHandle = requestAnimationFrame(() => {
3498
- request.frameHandle = null;
3499
- this.checkCommittedFrameRequest(request);
3500
- });
3501
- });
3502
- }
3503
- checkCommittedFrameRequest(request) {
3504
- if (!this.committedFrameRequests.has(request))
3505
- return;
3506
- if (this.isDisposed) {
3507
- this.rejectCommittedFrameRequest(request, new Error('TerminalCore was disposed before the committed frame completed'));
3508
- return;
3509
- }
3510
- if (this.config.rendererType !== 'webgl') {
3511
- this.resolveCommittedFrameRequest(request);
3512
- return;
3513
- }
3514
- if (this.committedFabricFrameGeneration > request.baselineGeneration) {
3515
- this.settleCommittedFrameRequests();
3516
- return;
3517
- }
3518
- this.scheduleCommittedFrameCheck(request);
3519
- }
3520
- settleCommittedFrameRequests() {
3521
- const ready = [...this.committedFrameRequests].filter(request => this.committedFabricFrameGeneration > request.baselineGeneration);
3522
- if (ready.length === 0)
3523
- return;
3524
- try {
3525
- if (this.submittedFabricFrameGeneration < this.committedFabricFrameGeneration) {
3526
- this.fabricView?.renderer.finishSubmittedFrame();
3527
- this.submittedFabricFrameGeneration = this.committedFabricFrameGeneration;
3528
- }
3529
- for (const request of ready)
3530
- this.resolveCommittedFrameRequest(request);
3531
- }
3532
- catch (error) {
3533
- const failure = error instanceof Error ? error : new Error(String(error));
3534
- for (const request of ready)
3535
- this.rejectCommittedFrameRequest(request, failure);
3536
- }
3537
- }
3538
- resolveCommittedFrameRequest(request) {
3539
- if (!this.committedFrameRequests.has(request))
3540
- return;
3541
- if (request.frameHandle !== null) {
3542
- cancelAnimationFrame(request.frameHandle);
3543
- request.frameHandle = null;
3544
- }
3545
- request.resolve();
3546
- }
3547
- rejectCommittedFrameRequest(request, error) {
3548
- if (!this.committedFrameRequests.delete(request))
3549
- return;
3550
- if (request.frameHandle !== null) {
3551
- cancelAnimationFrame(request.frameHandle);
3552
- request.frameHandle = null;
3553
- }
3554
- request.reject(error);
3555
- }
3556
- rejectAllPresentationRequests(error) {
3557
- for (const request of [...this.presentationRequests]) {
3558
- this.rejectPresentationRequest(request, error);
3559
- }
3560
- for (const request of [...this.committedFrameRequests]) {
3561
- this.rejectCommittedFrameRequest(request, error);
3562
- }
3563
- }
3564
- }
3565
- TerminalCore.nextRenderTaskId = 1;
3566
- //# sourceMappingURL=TerminalCore.js.map