@floegence/floeterm-terminal-web 0.4.32 → 0.5.1

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 (49) hide show
  1. package/README.md +86 -56
  2. package/dist/core/TerminalCore.d.ts +27 -1
  3. package/dist/core/TerminalCore.d.ts.map +1 -1
  4. package/dist/core/TerminalCore.js +490 -41
  5. package/dist/core/TerminalCore.js.map +1 -1
  6. package/dist/core/TerminalRenderScheduler.d.ts +4 -0
  7. package/dist/core/TerminalRenderScheduler.d.ts.map +1 -1
  8. package/dist/core/TerminalRenderScheduler.js +35 -2
  9. package/dist/core/TerminalRenderScheduler.js.map +1 -1
  10. package/dist/fabric/BeamtermFabricRenderer.d.ts +59 -0
  11. package/dist/fabric/BeamtermFabricRenderer.d.ts.map +1 -0
  12. package/dist/fabric/BeamtermFabricRenderer.js +448 -0
  13. package/dist/fabric/BeamtermFabricRenderer.js.map +1 -0
  14. package/dist/fabric/TerminalFabricCoordinator.d.ts +40 -0
  15. package/dist/fabric/TerminalFabricCoordinator.d.ts.map +1 -0
  16. package/dist/fabric/TerminalFabricCoordinator.js +140 -0
  17. package/dist/fabric/TerminalFabricCoordinator.js.map +1 -0
  18. package/dist/fabric/TerminalLiveFabric.d.ts +84 -0
  19. package/dist/fabric/TerminalLiveFabric.d.ts.map +1 -0
  20. package/dist/fabric/TerminalLiveFabric.js +211 -0
  21. package/dist/fabric/TerminalLiveFabric.js.map +1 -0
  22. package/dist/fabric/types.d.ts +112 -0
  23. package/dist/fabric/types.d.ts.map +1 -0
  24. package/dist/fabric/types.js +2 -0
  25. package/dist/fabric/types.js.map +1 -0
  26. package/dist/index.d.ts +5 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +3 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/internal/TerminalInitializationScheduler.d.ts +22 -0
  31. package/dist/internal/TerminalInitializationScheduler.d.ts.map +1 -0
  32. package/dist/internal/TerminalInitializationScheduler.js +79 -0
  33. package/dist/internal/TerminalInitializationScheduler.js.map +1 -0
  34. package/dist/internal/scheduleUiTurn.d.ts +5 -0
  35. package/dist/internal/scheduleUiTurn.d.ts.map +1 -0
  36. package/dist/internal/scheduleUiTurn.js +37 -0
  37. package/dist/internal/scheduleUiTurn.js.map +1 -0
  38. package/dist/manager/TerminalInstanceController.d.ts +71 -0
  39. package/dist/manager/TerminalInstanceController.d.ts.map +1 -0
  40. package/dist/manager/TerminalInstanceController.js +670 -0
  41. package/dist/manager/TerminalInstanceController.js.map +1 -0
  42. package/dist/types.d.ts +48 -6
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/types.js.map +1 -1
  45. package/package.json +3 -12
  46. package/dist/hooks/useTerminalInstance.d.ts +0 -3
  47. package/dist/hooks/useTerminalInstance.d.ts.map +0 -1
  48. package/dist/hooks/useTerminalInstance.js +0 -544
  49. package/dist/hooks/useTerminalInstance.js.map +0 -1
@@ -1,8 +1,11 @@
1
1
  import { filterXtermAutoResponses } from '../utils/xtermAutoResponseFilter';
2
2
  import { createConsoleLogger, noopLogger } from '../utils/logger';
3
+ import { cursorToFabricCursor, mapGhosttyRowToFabricCells, renderReasonFromForce, terminalLiveFabric, themeToFabricTheme, } from '../fabric/TerminalLiveFabric';
4
+ import { terminalFabricCoordinator } from '../fabric/TerminalFabricCoordinator';
3
5
  import { TerminalState, } from '../types';
4
6
  import { resolveTerminalInputElement, TerminalInputBridge } from './TerminalInputBridge';
5
7
  import { terminalRenderScheduler } from './TerminalRenderScheduler';
8
+ import { scheduleUiTurn } from '../internal/scheduleUiTurn';
6
9
  const TERMINAL_SEARCH_MAX_RESULTS = 5000;
7
10
  const PRESENTATION_SCALE_EPSILON = 0.0001;
8
11
  const GHOSTTY_DEFAULT_SCROLLBAR_RESERVE_PX = 15;
@@ -77,6 +80,25 @@ function resolveCanvasLocalDisplaySize(canvas, coordinateRoot) {
77
80
  dpr: Number.isFinite(dpr) && dpr > 0 ? dpr : 1,
78
81
  };
79
82
  }
83
+ function syncCanvasOverlaySize(overlay, termCanvas, coordinateRoot) {
84
+ const { cssWidth, cssHeight, dpr } = resolveCanvasLocalDisplaySize(termCanvas, coordinateRoot);
85
+ if (overlay.canvas.width === termCanvas.width
86
+ && overlay.canvas.height === termCanvas.height
87
+ && overlay.cssWidth === cssWidth
88
+ && overlay.cssHeight === cssHeight
89
+ && overlay.dpr === dpr) {
90
+ return;
91
+ }
92
+ overlay.dpr = dpr;
93
+ overlay.cssWidth = cssWidth;
94
+ overlay.cssHeight = cssHeight;
95
+ overlay.canvas.style.width = `${cssWidth}px`;
96
+ overlay.canvas.style.height = `${cssHeight}px`;
97
+ overlay.canvas.width = termCanvas.width;
98
+ overlay.canvas.height = termCanvas.height;
99
+ overlay.ctx.setTransform(1, 0, 0, 1, 0, 0);
100
+ overlay.ctx.scale(dpr, dpr);
101
+ }
80
102
  const getPerfProbe = () => {
81
103
  if (typeof window === 'undefined') {
82
104
  return undefined;
@@ -240,6 +262,11 @@ function canSkipCachedRowRender(renderer, row) {
240
262
  }
241
263
  return true;
242
264
  }
265
+ function canSkipNativeCanvasRowRender(renderer, row, fabricActive) {
266
+ void renderer;
267
+ void row;
268
+ return fabricActive;
269
+ }
243
270
  function samePresentationScale(left, right) {
244
271
  return Math.abs(left - right) <= PRESENTATION_SCALE_EPSILON;
245
272
  }
@@ -268,11 +295,61 @@ function normalizeNonNegativePixels(value, fallback) {
268
295
  function hasUsableClientSize(element) {
269
296
  return Boolean(element && element.clientWidth > 0 && element.clientHeight > 0);
270
297
  }
298
+ function formatCssPixelValue(value) {
299
+ const normalized = Number.isFinite(value) ? Math.max(1, value) : 1;
300
+ return `${Math.round(normalized * 1000) / 1000}px`;
301
+ }
271
302
  function hasTransientRendererState(renderer) {
272
303
  return Boolean(renderer.currentSelectionCoords
273
304
  || (renderer.hoveredHyperlinkId ?? 0) > 0
274
305
  || renderer.hoveredLinkRange);
275
306
  }
307
+ function forceTransientRowsOnExit(renderer) {
308
+ const active = hasTransientRendererState(renderer);
309
+ const previous = Boolean(renderer.__floetermRowRenderCacheHadTransientState);
310
+ renderer.__floetermRowRenderCacheHadTransientState = active;
311
+ return previous && !active;
312
+ }
313
+ function normalizeNumberField(record, key) {
314
+ if (!record || typeof record !== 'object') {
315
+ return null;
316
+ }
317
+ const value = Number(record[key]);
318
+ return Number.isFinite(value) ? value : null;
319
+ }
320
+ function normalizeSelectionRange(value) {
321
+ const startCol = normalizeNumberField(value, 'startCol');
322
+ const startRow = normalizeNumberField(value, 'startRow');
323
+ const endCol = normalizeNumberField(value, 'endCol');
324
+ const endRow = normalizeNumberField(value, 'endRow');
325
+ if (startCol === null || startRow === null || endCol === null || endRow === null) {
326
+ return null;
327
+ }
328
+ return {
329
+ startCol: Math.max(0, Math.floor(startCol)),
330
+ startRow: Math.max(0, Math.floor(startRow)),
331
+ endCol: Math.max(0, Math.floor(endCol)),
332
+ endRow: Math.max(0, Math.floor(endRow)),
333
+ };
334
+ }
335
+ function normalizeHoverRange(value) {
336
+ const startX = normalizeNumberField(value, 'startX');
337
+ const startY = normalizeNumberField(value, 'startY');
338
+ const endX = normalizeNumberField(value, 'endX');
339
+ const endY = normalizeNumberField(value, 'endY');
340
+ if (startX === null || startY === null || endX === null || endY === null) {
341
+ return null;
342
+ }
343
+ return {
344
+ startX: Math.max(0, Math.floor(startX)),
345
+ startY: Math.max(0, Math.floor(startY)),
346
+ endX: Math.max(0, Math.floor(endX)),
347
+ endY: Math.max(0, Math.floor(endY)),
348
+ };
349
+ }
350
+ function normalizeFabricIdentifier(value) {
351
+ return value.trim().replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'terminal';
352
+ }
276
353
  // TerminalCore provides a focused wrapper around ghostty-web (xterm.js API-compatible) and its fit addon.
277
354
  export class TerminalCore {
278
355
  constructor(container, config = {}, eventHandlers = {}, logger = createConsoleLogger()) {
@@ -282,6 +359,9 @@ export class TerminalCore {
282
359
  this.fitAddon = null;
283
360
  this.needsFullRenderOnNextWrite = false;
284
361
  this.demandRenderForceAll = false;
362
+ this.fabricView = null;
363
+ this.fabricAttachSeq = 0;
364
+ this.cancelFabricAttachSchedule = null;
285
365
  this.viewportHost = null;
286
366
  this.renderHost = null;
287
367
  this.resizeObserver = null;
@@ -345,6 +425,7 @@ export class TerminalCore {
345
425
  this.renderDemandFrame(forceAll);
346
426
  },
347
427
  };
428
+ this.fabricViewId = `floeterm-view-${this.renderTask.id}`;
348
429
  TerminalCore.nextRenderTaskId += 1;
349
430
  }
350
431
  // initialize creates the ghostty-web terminal instance and binds addons.
@@ -459,6 +540,10 @@ export class TerminalCore {
459
540
  fitAddon.proposeDimensions = () => this.proposeFitDimensions();
460
541
  }
461
542
  proposeFitDimensions() {
543
+ const fabricDimensions = this.proposeFabricFitDimensions();
544
+ if (fabricDimensions) {
545
+ return fabricDimensions;
546
+ }
462
547
  const terminalAny = this.terminal;
463
548
  const renderer = terminalAny?.renderer;
464
549
  const metrics = typeof renderer?.getMetrics === 'function' ? renderer.getMetrics() : null;
@@ -488,12 +573,90 @@ export class TerminalCore {
488
573
  rows: Math.max(MIN_TERMINAL_ROWS, Math.floor(availableHeight / cellHeight)),
489
574
  };
490
575
  }
576
+ proposeFabricFitDimensions() {
577
+ if (this.config.rendererType !== 'webgl') {
578
+ return null;
579
+ }
580
+ const geometry = this.fabricView?.renderer.getGeometry();
581
+ if (!geometry) {
582
+ return null;
583
+ }
584
+ const cols = Math.floor(Number(geometry.cols));
585
+ const rows = Math.floor(Number(geometry.rows));
586
+ if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols <= 0 || rows <= 0) {
587
+ return null;
588
+ }
589
+ return {
590
+ cols: Math.max(MIN_TERMINAL_COLS, cols),
591
+ rows: Math.max(MIN_TERMINAL_ROWS, rows),
592
+ };
593
+ }
594
+ syncGhosttyInputGeometryToFabric() {
595
+ if (!this.terminal || !this.isFabricRendererActive()) {
596
+ return;
597
+ }
598
+ const geometry = this.fabricView?.renderer.getGeometry();
599
+ if (!geometry) {
600
+ return;
601
+ }
602
+ const terminalAny = this.terminal;
603
+ const renderer = terminalAny.renderer;
604
+ const canvas = renderer?.getCanvas?.();
605
+ const cols = Math.floor(Number(terminalAny.cols ?? geometry.cols));
606
+ const rows = Math.floor(Number(terminalAny.rows ?? geometry.rows));
607
+ const cellWidth = Number(geometry.cellWidth);
608
+ const cellHeight = Number(geometry.cellHeight);
609
+ if (!renderer
610
+ || !canvas
611
+ || !Number.isFinite(cols)
612
+ || !Number.isFinite(rows)
613
+ || !Number.isFinite(cellWidth)
614
+ || !Number.isFinite(cellHeight)
615
+ || cols <= 0
616
+ || rows <= 0
617
+ || cellWidth <= 0
618
+ || cellHeight <= 0) {
619
+ return;
620
+ }
621
+ const currentMetrics = renderer.getMetrics?.() ?? renderer.metrics ?? {};
622
+ const baselineRatio = Number(currentMetrics.height) > 0
623
+ ? Number(currentMetrics.baseline ?? cellHeight * 0.8) / Number(currentMetrics.height)
624
+ : 0.8;
625
+ const nextMetrics = {
626
+ ...currentMetrics,
627
+ width: cellWidth,
628
+ height: cellHeight,
629
+ baseline: Math.max(1, Math.round(cellHeight * baselineRatio)),
630
+ };
631
+ renderer.metrics = nextMetrics;
632
+ const gridWidth = cols * cellWidth;
633
+ const gridHeight = rows * cellHeight;
634
+ canvas.style.width = formatCssPixelValue(gridWidth);
635
+ canvas.style.height = formatCssPixelValue(gridHeight);
636
+ canvas.style.maxWidth = 'none';
637
+ canvas.style.maxHeight = 'none';
638
+ const canvasDpr = canvas.width > 0 && gridWidth > 0
639
+ ? canvas.width / gridWidth
640
+ : window.devicePixelRatio ?? 1;
641
+ const dpr = Number.isFinite(canvasDpr) && canvasDpr > 0 ? canvasDpr : 1;
642
+ const expectedWidth = Math.max(1, Math.round(gridWidth * dpr));
643
+ const expectedHeight = Math.max(1, Math.round(gridHeight * dpr));
644
+ if (canvas.width !== expectedWidth || canvas.height !== expectedHeight) {
645
+ canvas.width = expectedWidth;
646
+ canvas.height = expectedHeight;
647
+ }
648
+ canvas.style.position = 'absolute';
649
+ canvas.style.left = '0';
650
+ canvas.style.top = '0';
651
+ canvas.style.inset = '0 auto auto 0';
652
+ canvas.style.zIndex = '1';
653
+ }
491
654
  resolveFitElement(terminalElement) {
492
655
  const candidates = [
493
- terminalElement,
494
656
  this.renderHost,
495
657
  this.viewportHost,
496
658
  this.container,
659
+ terminalElement,
497
660
  ];
498
661
  return candidates.find(hasUsableClientSize) ?? terminalElement ?? this.renderHost ?? this.container;
499
662
  }
@@ -591,16 +754,26 @@ export class TerminalCore {
591
754
  const viewportChanged = Number.isFinite(incomingViewportY)
592
755
  && Number.isFinite(currentViewportY)
593
756
  && incomingViewportY !== currentViewportY;
594
- renderer.__floetermRowRenderCacheForceAll = Boolean(args[1]) || viewportChanged;
757
+ const forceTransientExit = forceTransientRowsOnExit(renderer);
758
+ const forceAll = Boolean(args[1]) || viewportChanged || forceTransientExit;
759
+ renderer.__floetermRowRenderCacheForceAll = forceAll;
760
+ const fabricFrame = this.beginFabricFrame(forceAll, args[0]);
595
761
  try {
596
- return originalRender(...args);
762
+ const renderArgs = [...args];
763
+ renderArgs[1] = forceAll;
764
+ return originalRender(...renderArgs);
597
765
  }
598
766
  finally {
767
+ this.finishFabricFrame(fabricFrame);
599
768
  renderer.__floetermRowRenderCacheForceAll = false;
600
769
  }
601
770
  };
602
771
  renderer.renderLine = (cells, row, cols) => {
603
772
  const themedCells = this.translateCellsForTheme(cells);
773
+ const fabricActive = this.writeFabricRow(renderer, row, themedCells, cols);
774
+ if (canSkipNativeCanvasRowRender(renderer, row, fabricActive)) {
775
+ return undefined;
776
+ }
604
777
  const signature = buildRowSignature(renderer, themedCells, row, cols);
605
778
  if (canSkipCachedRowRender(renderer, row) && cache.get(row) === signature) {
606
779
  return undefined;
@@ -614,6 +787,95 @@ export class TerminalCore {
614
787
  return result;
615
788
  };
616
789
  }
790
+ beginFabricFrame(forceAll, buffer) {
791
+ const renderer = this.fabricView?.renderer;
792
+ if (!renderer?.isActive()) {
793
+ return null;
794
+ }
795
+ const dimensions = this.resolveFabricDimensions(buffer);
796
+ if (!dimensions) {
797
+ return null;
798
+ }
799
+ const frame = terminalFabricCoordinator.beginFrame(renderReasonFromForce(forceAll), forceAll);
800
+ const theme = this.resolveFabricTheme();
801
+ renderer.startFrame(frame, {
802
+ cols: dimensions.cols,
803
+ rows: dimensions.rows,
804
+ theme: themeToFabricTheme(theme),
805
+ });
806
+ return frame;
807
+ }
808
+ resolveFabricDimensions(buffer) {
809
+ const raw = buffer;
810
+ const dimensions = raw?.getDimensions?.();
811
+ const cols = Math.floor(Number(dimensions?.cols ?? this.terminal?.cols ?? 0));
812
+ const rows = Math.floor(Number(dimensions?.rows ?? this.terminal?.rows ?? 0));
813
+ if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols <= 0 || rows <= 0) {
814
+ return null;
815
+ }
816
+ return { cols, rows };
817
+ }
818
+ writeFabricRow(renderer, row, cells, cols) {
819
+ const fabricRenderer = this.fabricView?.renderer;
820
+ if (!fabricRenderer?.isActive()) {
821
+ return false;
822
+ }
823
+ fabricRenderer.writeRow(row, mapGhosttyRowToFabricCells(renderer, row, cells, cols, this.resolveFabricRowRenderHints(renderer)), cols);
824
+ return true;
825
+ }
826
+ resolveFabricRowRenderHints(renderer) {
827
+ return {
828
+ selection: this.resolveFabricSelectionHint(renderer),
829
+ hover: {
830
+ hyperlinkId: Number(renderer.hoveredHyperlinkId ?? 0),
831
+ range: normalizeHoverRange(renderer.hoveredLinkRange),
832
+ },
833
+ };
834
+ }
835
+ resolveFabricSelectionHint(renderer) {
836
+ const range = normalizeSelectionRange(renderer.currentSelectionCoords);
837
+ if (!range) {
838
+ return null;
839
+ }
840
+ const theme = this.resolveFabricTheme();
841
+ return {
842
+ ...range,
843
+ background: parseThemeColor(theme.selectionBackground ?? TERMINAL_SELECTION_BACKGROUND)
844
+ ?? parseThemeColor(TERMINAL_SELECTION_BACKGROUND),
845
+ foreground: parseThemeColor(theme.selectionForeground ?? TERMINAL_SELECTION_FOREGROUND)
846
+ ?? parseThemeColor(TERMINAL_SELECTION_FOREGROUND),
847
+ };
848
+ }
849
+ syncFabricSelectionState() {
850
+ const renderer = this.terminal?.renderer;
851
+ const selectionManager = this.terminal?.selectionManager;
852
+ if (!renderer || !selectionManager) {
853
+ return;
854
+ }
855
+ renderer.currentSelectionCoords = selectionManager.hasSelection?.()
856
+ ? selectionManager.getSelectionCoords?.() ?? null
857
+ : null;
858
+ renderer.__floetermRowRenderCache?.clear();
859
+ this.demandRenderForceAll = true;
860
+ }
861
+ finishFabricFrame(frame) {
862
+ const fabricRenderer = this.fabricView?.renderer;
863
+ if (!frame || !fabricRenderer?.isActive()) {
864
+ return;
865
+ }
866
+ const terminalAny = this.terminal;
867
+ const result = fabricRenderer.finishFrame(cursorToFabricCursor(terminalAny?.wasmTerm?.getCursor?.()));
868
+ if (result.rendered) {
869
+ terminalFabricCoordinator.completeFrame(frame, result.renderedRows, result.dirtyCells);
870
+ }
871
+ }
872
+ resolveFabricTheme() {
873
+ if (this.searchQuery.trim().length > 0 && this.searchThemeRestore) {
874
+ return this.applySearchThemeOverride(this.searchThemeRestore);
875
+ }
876
+ const configured = mapThemeToGhostty(this.config.theme);
877
+ return Object.keys(configured).length > 0 ? configured : this.terminalThemeSource;
878
+ }
617
879
  translateCellsForTheme(cells) {
618
880
  const translator = this.themeColorTranslator;
619
881
  if (!translator) {
@@ -706,9 +968,72 @@ export class TerminalCore {
706
968
  : null;
707
969
  selectionManager.requestRender = () => {
708
970
  originalRequestRender?.();
971
+ this.syncFabricSelectionState();
709
972
  this.requestDemandRender(false);
710
973
  };
711
974
  }
975
+ async attachFabricRenderer() {
976
+ if (this.fabricView || this.config.rendererType !== 'webgl' || !this.terminal) {
977
+ return;
978
+ }
979
+ const terminalAny = this.terminal;
980
+ const theme = Object.keys(this.terminalThemeSource).length > 0
981
+ ? this.terminalThemeSource
982
+ : mapThemeToGhostty(this.config.theme);
983
+ const fontFamily = typeof terminalAny.options?.fontFamily === 'string'
984
+ ? terminalAny.options.fontFamily
985
+ : typeof this.config.fontFamily === 'string'
986
+ ? this.config.fontFamily
987
+ : '"JetBrains Mono", monospace';
988
+ const fontSize = Number(terminalAny.options?.fontSize ?? this.resolveEffectiveFontSize());
989
+ const sessionId = normalizeFabricIdentifier(typeof this.config.sessionId === 'string' ? this.config.sessionId : this.fabricViewId);
990
+ this.fabricView = await terminalLiveFabric.attachView({
991
+ sessionId,
992
+ viewId: this.fabricViewId,
993
+ container: this.renderHost ?? this.container,
994
+ logger: this.logger,
995
+ fontFamily,
996
+ fontSize: Number.isFinite(fontSize) && fontSize > 0 ? fontSize : this.resolveEffectiveFontSize(),
997
+ theme,
998
+ getGhosttyCanvas: () => terminalAny.renderer?.getCanvas?.() ?? null,
999
+ focusInputSurface: () => this.inputBridge?.focus(),
1000
+ forwardWheel: event => {
1001
+ this.container.dispatchEvent(new WheelEvent(event.type, event));
1002
+ },
1003
+ });
1004
+ this.fabricView.renderer.setAppearance({ theme, fontFamily, fontSize });
1005
+ this.performResize('force');
1006
+ this.syncGhosttyInputGeometryToFabric();
1007
+ this.forceFullRender();
1008
+ }
1009
+ scheduleFabricRendererAttach() {
1010
+ if (this.config.rendererType !== 'webgl' || this.fabricView || !this.terminal) {
1011
+ return;
1012
+ }
1013
+ if (this.cancelFabricAttachSchedule !== null) {
1014
+ return;
1015
+ }
1016
+ const seq = ++this.fabricAttachSeq;
1017
+ const attach = () => {
1018
+ this.cancelFabricAttachSchedule = null;
1019
+ if (this.isDisposed || seq !== this.fabricAttachSeq || this.fabricView) {
1020
+ return;
1021
+ }
1022
+ this.attachFabricRenderer().catch(error => {
1023
+ if (this.isDisposed || seq !== this.fabricAttachSeq) {
1024
+ return;
1025
+ }
1026
+ this.logger.warn('[TerminalCore] Beamterm live fabric attach failed; keeping ghostty canvas live', { error });
1027
+ });
1028
+ };
1029
+ this.cancelFabricAttachSchedule = scheduleUiTurn(() => {
1030
+ if (this.isDisposed || seq !== this.fabricAttachSeq || this.fabricView) {
1031
+ this.cancelFabricAttachSchedule = null;
1032
+ return;
1033
+ }
1034
+ this.cancelFabricAttachSchedule = scheduleUiTurn(attach);
1035
+ });
1036
+ }
712
1037
  setupInputBridge() {
713
1038
  this.disposeInputBridge();
714
1039
  const input = resolveTerminalInputElement(this.container);
@@ -853,6 +1178,7 @@ export class TerminalCore {
853
1178
  }
854
1179
  if (typeof this.terminal.onSelectionChange === 'function') {
855
1180
  const disposable = this.terminal.onSelectionChange(() => {
1181
+ this.syncFabricSelectionState();
856
1182
  this.requestDemandRender(false);
857
1183
  });
858
1184
  this.trackTerminalEventDisposable(disposable);
@@ -950,7 +1276,16 @@ export class TerminalCore {
950
1276
  const startSeq = this.resizeNotifySeq;
951
1277
  const before = this.lastNotifiedSize;
952
1278
  try {
1279
+ if (this.isFabricRendererActive()) {
1280
+ this.resizeFabricToHost();
1281
+ }
953
1282
  this.fitAddon.fit();
1283
+ if (this.isFabricRendererActive()) {
1284
+ this.syncGhosttyInputGeometryToFabric();
1285
+ }
1286
+ if (!this.isFabricRendererActive()) {
1287
+ this.resizeFabricToHost();
1288
+ }
954
1289
  }
955
1290
  catch (error) {
956
1291
  this.logger.debug('[TerminalCore] Resize failed', { error });
@@ -979,6 +1314,7 @@ export class TerminalCore {
979
1314
  if (changed) {
980
1315
  try {
981
1316
  this.terminal.resize(dims.cols, dims.rows);
1317
+ this.resizeFabricToHost();
982
1318
  }
983
1319
  catch (error) {
984
1320
  this.logger.debug('[TerminalCore] Fixed-dimension resize failed', { error });
@@ -1254,6 +1590,82 @@ export class TerminalCore {
1254
1590
  bufferLength: this.terminal.buffer.active.length
1255
1591
  };
1256
1592
  }
1593
+ readBufferLine(row, options = {}) {
1594
+ if (!this.terminal) {
1595
+ return '';
1596
+ }
1597
+ const normalizedRow = Math.floor(Number(row));
1598
+ if (!Number.isFinite(normalizedRow) || normalizedRow < 0) {
1599
+ return '';
1600
+ }
1601
+ try {
1602
+ const line = this.terminal.buffer?.active?.getLine?.(normalizedRow);
1603
+ const text = line?.translateToString?.(options.trimRight ?? false);
1604
+ return typeof text === 'string' ? text : '';
1605
+ }
1606
+ catch {
1607
+ return '';
1608
+ }
1609
+ }
1610
+ readBufferLines(startRow, endRowInclusive, options = {}) {
1611
+ const start = Math.floor(Number(startRow));
1612
+ const end = Math.floor(Number(endRowInclusive));
1613
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) {
1614
+ return [];
1615
+ }
1616
+ const lines = [];
1617
+ for (let row = Math.max(0, start); row <= end; row += 1) {
1618
+ const text = this.readBufferLine(row, options);
1619
+ if (text.length > 0) {
1620
+ lines.push({ row, text });
1621
+ }
1622
+ }
1623
+ return lines;
1624
+ }
1625
+ getTouchScrollRuntime() {
1626
+ const terminal = this.terminal;
1627
+ if (!terminal) {
1628
+ return null;
1629
+ }
1630
+ return {
1631
+ scrollLines: amount => {
1632
+ const normalizedAmount = Math.trunc(Number(amount));
1633
+ if (!Number.isFinite(normalizedAmount) || normalizedAmount === 0) {
1634
+ return false;
1635
+ }
1636
+ if (typeof terminal.scrollLines !== 'function') {
1637
+ return false;
1638
+ }
1639
+ terminal.scrollLines(normalizedAmount);
1640
+ this.requestDemandRender(false);
1641
+ return true;
1642
+ },
1643
+ getScrollbackLength: () => {
1644
+ if (typeof terminal.getScrollbackLength !== 'function') {
1645
+ return 0;
1646
+ }
1647
+ const length = Number(terminal.getScrollbackLength());
1648
+ return Number.isFinite(length) && length > 0 ? length : 0;
1649
+ },
1650
+ isAlternateScreen: () => {
1651
+ if (typeof terminal.isAlternateScreen !== 'function') {
1652
+ return false;
1653
+ }
1654
+ return Boolean(terminal.isAlternateScreen());
1655
+ },
1656
+ sendAlternateScreenInput: data => {
1657
+ const text = String(data ?? '');
1658
+ if (!text) {
1659
+ return;
1660
+ }
1661
+ if (typeof terminal.input === 'function') {
1662
+ terminal.input(text, true);
1663
+ return;
1664
+ }
1665
+ this.eventHandlers.onData?.(text);
1666
+ },
1667
+ };
1668
+ }
1257
1669
  findNext(term, options) {
1258
1670
  return this.findInternal(term, options, 1);
1259
1671
  }
@@ -1534,6 +1946,7 @@ export class TerminalCore {
1534
1946
  const restore = this.searchThemeRestore;
1535
1947
  this.searchThemeRestore = null;
1536
1948
  this.applyRendererTheme(terminalAny, restore);
1949
+ this.forceFullRender();
1537
1950
  }
1538
1951
  applySearchThemeOverride(theme) {
1539
1952
  return {
@@ -1550,6 +1963,7 @@ export class TerminalCore {
1550
1963
  this.terminal.options.theme = theme;
1551
1964
  }
1552
1965
  terminalAny.renderer?.setTheme?.(theme);
1966
+ this.fabricView?.renderer.setAppearance({ theme });
1553
1967
  if (this.isVisualRenderSuspended()) {
1554
1968
  this.markPendingDemandRender(true);
1555
1969
  return;
@@ -1642,6 +2056,43 @@ export class TerminalCore {
1642
2056
  }
1643
2057
  this.searchOverlay = null;
1644
2058
  }
2059
+ createCanvasOverlay(zIndex) {
2060
+ const t = this.terminal;
2061
+ if (!t)
2062
+ return null;
2063
+ const renderer = t?.renderer;
2064
+ const termCanvas = renderer?.getCanvas?.();
2065
+ if (!termCanvas)
2066
+ return null;
2067
+ try {
2068
+ const style = getComputedStyle(this.container);
2069
+ if (style.position === 'static')
2070
+ this.container.style.position = 'relative';
2071
+ }
2072
+ catch {
2073
+ }
2074
+ const overlay = document.createElement('canvas');
2075
+ overlay.style.position = 'absolute';
2076
+ overlay.style.top = '0';
2077
+ overlay.style.left = '0';
2078
+ overlay.style.pointerEvents = 'none';
2079
+ overlay.style.zIndex = zIndex;
2080
+ overlay.style.mixBlendMode = 'normal';
2081
+ const ctx = overlay.getContext('2d');
2082
+ if (!ctx)
2083
+ return null;
2084
+ this.container.appendChild(overlay);
2085
+ const state = {
2086
+ canvas: overlay,
2087
+ ctx,
2088
+ dpr: 1,
2089
+ cssWidth: 0,
2090
+ cssHeight: 0,
2091
+ termCanvas,
2092
+ };
2093
+ syncCanvasOverlaySize(state, termCanvas, this.container);
2094
+ return state;
2095
+ }
1645
2096
  ensureSearchOverlay() {
1646
2097
  const t = this.terminal;
1647
2098
  if (!t)
@@ -1662,40 +2113,10 @@ export class TerminalCore {
1662
2113
  this.disposeSearchOverlay();
1663
2114
  }
1664
2115
  if (!this.searchOverlay) {
1665
- const overlay = document.createElement('canvas');
1666
- overlay.style.position = 'absolute';
1667
- overlay.style.top = '0';
1668
- overlay.style.left = '0';
1669
- overlay.style.pointerEvents = 'none';
1670
- overlay.style.zIndex = '5';
1671
- const ctx = overlay.getContext('2d');
1672
- if (!ctx)
1673
- return null;
1674
- this.container.appendChild(overlay);
1675
- this.searchOverlay = {
1676
- canvas: overlay,
1677
- ctx,
1678
- dpr: 1,
1679
- cssWidth: 0,
1680
- cssHeight: 0,
1681
- termCanvas
1682
- };
2116
+ this.searchOverlay = this.createCanvasOverlay('5');
1683
2117
  }
1684
- const { cssWidth, cssHeight, dpr } = resolveCanvasLocalDisplaySize(termCanvas, this.container);
1685
- if (this.searchOverlay.canvas.width !== termCanvas.width ||
1686
- this.searchOverlay.canvas.height !== termCanvas.height ||
1687
- this.searchOverlay.cssWidth !== cssWidth ||
1688
- this.searchOverlay.cssHeight !== cssHeight ||
1689
- this.searchOverlay.dpr !== dpr) {
1690
- this.searchOverlay.dpr = dpr;
1691
- this.searchOverlay.cssWidth = cssWidth;
1692
- this.searchOverlay.cssHeight = cssHeight;
1693
- this.searchOverlay.canvas.style.width = `${cssWidth}px`;
1694
- this.searchOverlay.canvas.style.height = `${cssHeight}px`;
1695
- this.searchOverlay.canvas.width = termCanvas.width;
1696
- this.searchOverlay.canvas.height = termCanvas.height;
1697
- this.searchOverlay.ctx.setTransform(1, 0, 0, 1, 0, 0);
1698
- this.searchOverlay.ctx.scale(this.searchOverlay.dpr, this.searchOverlay.dpr);
2118
+ if (this.searchOverlay) {
2119
+ syncCanvasOverlaySize(this.searchOverlay, termCanvas, this.container);
1699
2120
  }
1700
2121
  return this.searchOverlay;
1701
2122
  }
@@ -1712,16 +2133,16 @@ export class TerminalCore {
1712
2133
  });
1713
2134
  }
1714
2135
  renderSearchOverlay() {
1715
- const overlay = this.ensureSearchOverlay();
1716
- if (!overlay)
1717
- return;
1718
2136
  const t = this.terminal;
1719
2137
  const queryActive = this.searchQuery.trim().length > 0;
1720
2138
  const rowIndex = this.searchRowIndex;
1721
2139
  if (!queryActive || rowIndex.size === 0 || !t) {
1722
- overlay.ctx.clearRect(0, 0, overlay.cssWidth, overlay.cssHeight);
2140
+ this.searchOverlay?.ctx.clearRect(0, 0, this.searchOverlay.cssWidth, this.searchOverlay.cssHeight);
1723
2141
  return;
1724
2142
  }
2143
+ const overlay = this.ensureSearchOverlay();
2144
+ if (!overlay)
2145
+ return;
1725
2146
  const renderer = t?.renderer;
1726
2147
  const charW = typeof renderer?.charWidth === 'number' ? renderer.charWidth : 0;
1727
2148
  const charH = typeof renderer?.charHeight === 'number' ? renderer.charHeight : 0;
@@ -1759,6 +2180,9 @@ export class TerminalCore {
1759
2180
  }
1760
2181
  setConnected(isConnected) {
1761
2182
  this.setState(isConnected ? TerminalState.CONNECTED : TerminalState.READY);
2183
+ if (isConnected) {
2184
+ this.scheduleFabricRendererAttach();
2185
+ }
1762
2186
  }
1763
2187
  forceResize() {
1764
2188
  this.flushPendingPresentationScale();
@@ -1819,6 +2243,7 @@ export class TerminalCore {
1819
2243
  return;
1820
2244
  }
1821
2245
  this.terminal.options.fontSize = this.resolveEffectiveFontSize();
2246
+ this.fabricView?.renderer.setAppearance({ fontSize: this.resolveEffectiveFontSize() });
1822
2247
  this.performResize('font');
1823
2248
  void this.refreshFontMetricsAfterLoad('font_size');
1824
2249
  }
@@ -1855,6 +2280,10 @@ export class TerminalCore {
1855
2280
  return;
1856
2281
  }
1857
2282
  this.terminal.options.fontFamily = nextFamily;
2283
+ this.fabricView?.renderer.setAppearance({
2284
+ fontFamily: nextFamily,
2285
+ fontSize: this.resolveEffectiveFontSize(),
2286
+ });
1858
2287
  this.performResize('font');
1859
2288
  this.forceFullRender();
1860
2289
  void this.refreshFontMetricsAfterLoad('font_family');
@@ -1866,6 +2295,9 @@ export class TerminalCore {
1866
2295
  let disposed = false;
1867
2296
  this.visualRenderState.suspendDepth += 1;
1868
2297
  this.visualRenderState.activeReasons.set(id, reason);
2298
+ // Legacy compatibility: keep the handle lifecycle observable, but never
2299
+ // freeze visible terminal rendering in the live fabric path.
2300
+ this.visualRenderState.suspendDepth = Math.max(0, this.visualRenderState.suspendDepth - 1);
1869
2301
  return {
1870
2302
  id,
1871
2303
  reason,
@@ -1925,6 +2357,11 @@ export class TerminalCore {
1925
2357
  this.disposeInputBridge();
1926
2358
  this.disposeTerminalEventListeners();
1927
2359
  this.disposeSearchOverlay();
2360
+ this.fabricAttachSeq += 1;
2361
+ this.cancelFabricAttachSchedule?.();
2362
+ this.cancelFabricAttachSchedule = null;
2363
+ this.fabricView?.dispose();
2364
+ this.fabricView = null;
1928
2365
  this.terminal?.dispose();
1929
2366
  this.terminal = null;
1930
2367
  this.viewportHost = null;
@@ -2098,16 +2535,28 @@ export class TerminalCore {
2098
2535
  this.suppressResizeNotifications = false;
2099
2536
  });
2100
2537
  this.terminal.options.fontSize = this.resolveEffectiveFontSize(nextScale);
2538
+ this.fabricView?.renderer.setAppearance({ fontSize: this.resolveEffectiveFontSize(nextScale) });
2101
2539
  if (this.isVisualRenderSuspended()) {
2102
2540
  this.markPendingResize('force');
2103
2541
  this.markPendingDemandRender(true);
2104
2542
  this.scheduleRenderSearchOverlay();
2105
2543
  return;
2106
2544
  }
2107
- this.fitAddon?.fit();
2545
+ this.performResize('force');
2108
2546
  this.forceFullRender();
2109
2547
  this.scheduleRenderSearchOverlay();
2110
2548
  }
2549
+ resizeFabricToHost() {
2550
+ const host = this.renderHost ?? this.container;
2551
+ if (!host) {
2552
+ return;
2553
+ }
2554
+ this.fabricView?.renderer.resize(host.clientWidth, host.clientHeight);
2555
+ this.syncGhosttyInputGeometryToFabric();
2556
+ }
2557
+ isFabricRendererActive() {
2558
+ return Boolean(this.fabricView?.renderer.isActive());
2559
+ }
2111
2560
  forceFullRender() {
2112
2561
  if (!this.terminal || !this.isReady()) {
2113
2562
  return;