@aurigax-ai/ghostty-web 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,7 +66,9 @@ in the output stream:
66
66
  | `modes.mouseTrackingMode` | `none`, `x10`, `vt200`, `drag` or `any` |
67
67
 
68
68
  The render loop runs only while something changes (output, input, scrolling, selection, cursor
69
- blink) and sleeps otherwise.
69
+ blink) and sleeps otherwise. `setPaused(true)` stops drawing a terminal that is off screen while it
70
+ keeps parsing output; `setPaused(false)` redraws it whole. `setPaused(true, { releaseRenderer: true })`
71
+ also gives up its WebGL context until then, since browsers keep only a few.
70
72
 
71
73
  ## Use
72
74
 
package/lib/abi.ts CHANGED
@@ -32,6 +32,9 @@ interface TypeLayout {
32
32
  values?: Record<string, number>;
33
33
  }
34
34
 
35
+ const encoder = new TextEncoder();
36
+ const decoder = new TextDecoder();
37
+
35
38
  const I32 = 0x7f;
36
39
  const I64 = 0x7e;
37
40
 
@@ -95,13 +98,16 @@ export class GhosttyCallError extends Error {
95
98
  export class Abi {
96
99
  private readonly types: Record<string, TypeLayout>;
97
100
  private readonly freeSlots: number[] = [];
101
+ private scratchPtr = 0;
102
+ private scratchLen = 0;
103
+ private readonly input = { ptr: 0, len: 0, busy: false };
98
104
 
99
105
  constructor(readonly exports: VtExports) {
100
106
  const ptr = this.fn('ghostty_type_json')() as number;
101
107
  const bytes = new Uint8Array(exports.memory.buffer);
102
108
  let end = ptr;
103
109
  while (bytes[end] !== 0) end++;
104
- this.types = JSON.parse(new TextDecoder().decode(bytes.subarray(ptr, end))).types;
110
+ this.types = JSON.parse(decoder.decode(bytes.subarray(ptr, end))).types;
105
111
  }
106
112
 
107
113
  fn(name: string): Fn {
@@ -188,6 +194,69 @@ export class Abi {
188
194
  });
189
195
  }
190
196
 
197
+ /**
198
+ * A zeroed buffer of at least `len` bytes, the same one on every call, for a
199
+ * read that is done with it before anything else calls scratch().
200
+ */
201
+ scratch(len: number): number {
202
+ if (len > this.scratchLen) {
203
+ if (this.scratchPtr) this.free(this.scratchPtr, this.scratchLen);
204
+ this.scratchPtr = 0;
205
+ this.scratchLen = 0;
206
+ const size = Math.max(len, 64);
207
+ this.scratchPtr = this.alloc(size);
208
+ this.scratchLen = size;
209
+ } else {
210
+ this.bytes().fill(0, this.scratchPtr, this.scratchPtr + len);
211
+ }
212
+ return this.scratchPtr;
213
+ }
214
+
215
+ /** scratch() holding a sized struct (its leading `size` field set), as GHOSTTY_INIT_SIZED does. */
216
+ scratchSized(type: string): number {
217
+ const size = this.sizeOf(type);
218
+ const ptr = this.scratch(size);
219
+ this.view().setUint32(ptr, size, true);
220
+ return ptr;
221
+ }
222
+
223
+ /**
224
+ * Runs `use` on text (as UTF-8) or bytes copied into a grow-only buffer kept
225
+ * between calls. A call from inside `use` (a terminal written to from one of
226
+ * the callbacks a write runs) gets a buffer of its own.
227
+ */
228
+ withInput<T>(data: string | Uint8Array, use: (ptr: number, len: number) => T): T {
229
+ if (this.input.busy) {
230
+ return this.withBytes(typeof data === 'string' ? encoder.encode(data) : data, use);
231
+ }
232
+ // UTF-8 takes at most 3 bytes per UTF-16 code unit.
233
+ const max = typeof data === 'string' ? data.length * 3 : data.length;
234
+ if (max === 0) return use(0, 0);
235
+ const input = this.input;
236
+ if (max > input.len) {
237
+ if (input.ptr) this.free(input.ptr, input.len);
238
+ input.ptr = 0;
239
+ input.len = 0;
240
+ const len = Math.max(max, 4096);
241
+ input.ptr = this.call('ghostty_wasm_alloc', len);
242
+ if (input.ptr === 0) throw new GhosttyCallError('ghostty_wasm_alloc', -1);
243
+ input.len = len;
244
+ }
245
+ const bytes = this.bytes();
246
+ let len = data.length;
247
+ if (typeof data === 'string') {
248
+ len = encoder.encodeInto(data, bytes.subarray(input.ptr, input.ptr + input.len)).written;
249
+ } else {
250
+ bytes.set(data, input.ptr);
251
+ }
252
+ input.busy = true;
253
+ try {
254
+ return use(input.ptr, len);
255
+ } finally {
256
+ input.busy = false;
257
+ }
258
+ }
259
+
191
260
  /** Runs a constructor that writes an opaque handle into an out slot and returns the handle. */
192
261
  newHandle(name: string, construct: (slot: number) => number): number {
193
262
  const slot = this.call('ghostty_wasm_alloc_opaque');
@@ -203,7 +272,7 @@ export class Abi {
203
272
 
204
273
  string(ptr: number, len: number): string {
205
274
  if (len === 0) return '';
206
- return new TextDecoder().decode(this.bytes().slice(ptr, ptr + len));
275
+ return decoder.decode(this.bytes().subarray(ptr, ptr + len));
207
276
  }
208
277
 
209
278
  /** Registers a JavaScript callback in the function table and returns its index (the C function pointer). */
package/lib/buffer.ts CHANGED
@@ -180,9 +180,7 @@ export class Buffer implements IBuffer {
180
180
  // So scrollbackOffset = y directly!
181
181
  const scrollbackOffset = y;
182
182
  cells = wasmTerm.getScrollbackLine(scrollbackOffset);
183
- // TODO: We'd need WASM API to check if scrollback line is wrapped
184
- // For now, assume not wrapped
185
- isWrapped = false;
183
+ isWrapped = wasmTerm.isScrollbackRowWrapped(scrollbackOffset);
186
184
  } else {
187
185
  // Accessing visible screen
188
186
  lineNumber = this.bufferType === 'normal' ? y - scrollbackLength : y;
package/lib/ghostty.ts CHANGED
@@ -229,7 +229,9 @@ export interface TrackedRow {
229
229
 
230
230
  const POINT_ACTIVE = 'ACTIVE';
231
231
  const POINT_HISTORY = 'HISTORY';
232
+ const POINT_SCREEN = 'SCREEN';
232
233
  type PointSpace = typeof POINT_ACTIVE | typeof POINT_HISTORY;
234
+ type PointTag = PointSpace | typeof POINT_SCREEN;
233
235
 
234
236
  const GRAPHEME_CAP = 16;
235
237
  const GRAPHEME_CLUSTER_MODE = 2027;
@@ -560,6 +562,8 @@ export class GhosttyTerminal {
560
562
  private readonly layout: CellLayout;
561
563
  private readonly scratch: ExtractScratch;
562
564
  private cellPool: GhosttyCell[] = [];
565
+ /** Each row of cellPool as an array of the same cell objects. */
566
+ private rowViews: GhosttyCell[][] = [];
563
567
  private rowDirty: boolean[] = [];
564
568
  private dirty: DirtyState = DirtyState.FULL;
565
569
  private changed = true;
@@ -569,8 +573,13 @@ export class GhosttyTerminal {
569
573
  cursor: null,
570
574
  palette: [],
571
575
  };
576
+ /** libghostty-vt changes the palette only with a full redraw, or through configure(). */
577
+ private paletteStale = true;
578
+ private cursor: RenderStateCursor | null = null;
572
579
  private responses: string[] = [];
573
580
  private callbacks: number[] = [];
581
+ /** A tracked row at the top of each screen, keyed by whether it is the alternate one. */
582
+ private screenTops = new Map<boolean, TrackedRow>();
574
583
 
575
584
  private readonly bellEmitter = new EventEmitter<void>();
576
585
  private readonly titleEmitter = new EventEmitter<string>();
@@ -658,8 +667,7 @@ export class GhosttyTerminal {
658
667
  // ==========================================================================
659
668
 
660
669
  write(data: string | Uint8Array): void {
661
- const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
662
- this.abi.withBytes(bytes, (ptr, len) =>
670
+ this.abi.withInput(data, (ptr, len) =>
663
671
  this.abi.call('ghostty_terminal_vt_write', this.handle, ptr, len)
664
672
  );
665
673
  this.changed = true;
@@ -681,6 +689,8 @@ export class GhosttyTerminal {
681
689
  abi.call('ghostty_render_state_row_iterator_free', this.rowIterator);
682
690
  abi.call('ghostty_render_state_free', this.renderState);
683
691
  abi.free(this.scratch.base, this.scratch.size);
692
+ for (const top of this.screenTops.values()) top.free();
693
+ this.screenTops.clear();
684
694
  abi.call('ghostty_terminal_free', this.handle);
685
695
  for (const index of this.callbacks) abi.removeCallback(index);
686
696
  this.callbacks = [];
@@ -713,43 +723,50 @@ export class GhosttyTerminal {
713
723
  abi.check('ghostty_render_state_update', this.renderState, this.handle);
714
724
  const dirty = this.readRenderU32('DIRTY') as DirtyState;
715
725
  if (dirty > this.dirty || this.dirty === DirtyState.NONE) this.dirty = dirty;
716
- this.readColors();
726
+ this.cursor = null;
727
+ this.readColors(this.dirty === DirtyState.FULL || this.paletteStale);
728
+ this.paletteStale = false;
717
729
  this.extractRows(this.dirty === DirtyState.FULL);
718
730
  return this.dirty;
719
731
  }
720
732
 
733
+ /** The cursor as of the last update; the same object until the render state changes. */
721
734
  getCursor(): RenderStateCursor {
722
735
  this.update();
736
+ this.cursor ??= this.readCursor();
737
+ return this.cursor;
738
+ }
739
+
740
+ private readCursor(): RenderStateCursor {
723
741
  const abi = this.abi;
724
- return abi.withSized('GhosttyRenderStateCursor', (ptr) => {
725
- abi.check(
726
- 'ghostty_render_state_get',
727
- this.renderState,
728
- abi.enumValue('GhosttyRenderStateData', 'CURSOR'),
729
- ptr
730
- );
731
- const view = abi.view();
732
- const at = (f: string) => ptr + abi.offset('GhosttyRenderStateCursor', f);
733
- const inViewport = view.getUint8(at('viewport_has_value')) !== 0;
734
- const x = inViewport ? view.getUint16(at('viewport_x'), true) : -1;
735
- const y = inViewport ? view.getUint16(at('viewport_y'), true) : -1;
736
- const style = view.getInt32(at('visual_style'), true);
737
- const styles = 'GhosttyRenderStateCursorVisualStyle';
738
- return {
739
- x,
740
- y,
741
- viewportX: x,
742
- viewportY: y,
743
- visible: inViewport && view.getUint8(at('visible')) !== 0,
744
- blinking: view.getUint8(at('blinking')) !== 0,
745
- style:
746
- style === abi.enumValue(styles, 'BAR')
747
- ? 'bar'
748
- : style === abi.enumValue(styles, 'UNDERLINE')
749
- ? 'underline'
750
- : 'block',
751
- };
752
- });
742
+ const ptr = abi.scratchSized('GhosttyRenderStateCursor');
743
+ abi.check(
744
+ 'ghostty_render_state_get',
745
+ this.renderState,
746
+ abi.enumValue('GhosttyRenderStateData', 'CURSOR'),
747
+ ptr
748
+ );
749
+ const view = abi.view();
750
+ const at = (f: string) => ptr + abi.offset('GhosttyRenderStateCursor', f);
751
+ const inViewport = view.getUint8(at('viewport_has_value')) !== 0;
752
+ const x = inViewport ? view.getUint16(at('viewport_x'), true) : -1;
753
+ const y = inViewport ? view.getUint16(at('viewport_y'), true) : -1;
754
+ const style = view.getInt32(at('visual_style'), true);
755
+ const styles = 'GhosttyRenderStateCursorVisualStyle';
756
+ return {
757
+ x,
758
+ y,
759
+ viewportX: x,
760
+ viewportY: y,
761
+ visible: inViewport && view.getUint8(at('visible')) !== 0,
762
+ blinking: view.getUint8(at('blinking')) !== 0,
763
+ style:
764
+ style === abi.enumValue(styles, 'BAR')
765
+ ? 'bar'
766
+ : style === abi.enumValue(styles, 'UNDERLINE')
767
+ ? 'underline'
768
+ : 'block',
769
+ };
753
770
  }
754
771
 
755
772
  /**
@@ -792,6 +809,17 @@ export class GhosttyTerminal {
792
809
  return this.cellPool.slice(start, start + this._cols).map((cell) => ({ ...cell }));
793
810
  }
794
811
 
812
+ /**
813
+ * A row's cells as the renderer reads them: the terminal's own cell objects,
814
+ * updated in place by later updates, so read them before the next one and
815
+ * never modify them. getLine() returns a copy to keep.
816
+ */
817
+ getLineView(y: number): readonly GhosttyCell[] | null {
818
+ if (y < 0 || y >= this._rows) return null;
819
+ this.update();
820
+ return this.rowViews[y];
821
+ }
822
+
795
823
  isDirty(): boolean {
796
824
  return this.update() !== DirtyState.NONE;
797
825
  }
@@ -833,13 +861,41 @@ export class GhosttyTerminal {
833
861
 
834
862
  /** Tracks the start of a row of the active area. */
835
863
  trackRow(activeY: number): TrackedRow | null {
864
+ return this.trackLineStart(POINT_ACTIVE, activeY);
865
+ }
866
+
867
+ /**
868
+ * Whether the terminal may have discarded rows since the last call, which is
869
+ * the only way a tracked row loses its line: libghostty-vt prunes scrollback
870
+ * from its first page and resets a screen whole, and either marks every
871
+ * tracked row on that page stale. A tracked row at the top of each screen
872
+ * goes stale with them. A screen is watched from the first call made while it
873
+ * is active.
874
+ */
875
+ rowsDiscarded(): boolean {
876
+ let discarded = false;
877
+ for (const [alternate, top] of this.screenTops) {
878
+ if (top.line() === 0) continue;
879
+ discarded = true;
880
+ top.free();
881
+ this.screenTops.delete(alternate);
882
+ }
883
+ const alternate = this.isAlternateScreen();
884
+ if (!this.screenTops.has(alternate)) {
885
+ const top = this.trackLineStart(POINT_SCREEN, 0);
886
+ if (top) this.screenTops.set(alternate, top);
887
+ }
888
+ return discarded;
889
+ }
890
+
891
+ private trackLineStart(tag: PointTag, y: number): TrackedRow | null {
836
892
  const abi = this.abi;
837
893
  const ref = abi.with(abi.sizeOf('GhosttyPoint'), (point) => {
838
894
  const view = abi.view();
839
- view.setInt32(point, abi.enumValue('GhosttyPointTag', POINT_ACTIVE), true);
895
+ view.setInt32(point, abi.enumValue('GhosttyPointTag', tag), true);
840
896
  const coord = point + abi.offset('GhosttyPoint', 'value');
841
897
  view.setUint16(coord + abi.offset('GhosttyPointCoordinate', 'x'), 0, true);
842
- view.setUint32(coord + abi.offset('GhosttyPointCoordinate', 'y'), activeY, true);
898
+ view.setUint32(coord + abi.offset('GhosttyPointCoordinate', 'y'), y, true);
843
899
  const slot = abi.call('ghostty_wasm_alloc_opaque');
844
900
  try {
845
901
  const result = abi.call('ghostty_terminal_grid_ref_track', this.handle, point, slot);
@@ -853,16 +909,11 @@ export class GhosttyTerminal {
853
909
  return {
854
910
  line: () => {
855
911
  if (freed) return null;
856
- return abi.with(abi.sizeOf('GhosttyPointCoordinate'), (out) => {
857
- const result = abi.call(
858
- 'ghostty_tracked_grid_ref_point',
859
- ref,
860
- abi.enumValue('GhosttyPointTag', 'SCREEN'),
861
- out
862
- );
863
- if (result !== GHOSTTY_SUCCESS) return null;
864
- return abi.view().getUint32(out + abi.offset('GhosttyPointCoordinate', 'y'), true);
865
- });
912
+ const out = abi.scratch(abi.sizeOf('GhosttyPointCoordinate'));
913
+ const tag = abi.enumValue('GhosttyPointTag', POINT_SCREEN);
914
+ if (abi.call('ghostty_tracked_grid_ref_point', ref, tag, out) !== GHOSTTY_SUCCESS)
915
+ return null;
916
+ return abi.view().getUint32(out + abi.offset('GhosttyPointCoordinate', 'y'), true);
866
917
  },
867
918
  free: () => {
868
919
  if (freed) return;
@@ -930,9 +981,19 @@ export class GhosttyTerminal {
930
981
  }
931
982
 
932
983
  isRowWrapped(row: number): boolean {
984
+ return this.rowContinues(POINT_ACTIVE, row);
985
+ }
986
+
987
+ /** Whether a scrollback line continues the one before it; offset 0 is the oldest line. */
988
+ isScrollbackRowWrapped(offset: number): boolean {
989
+ if (offset < 0 || offset >= this.getScrollbackLength()) return false;
990
+ return this.rowContinues(POINT_HISTORY, offset);
991
+ }
992
+
993
+ private rowContinues(point: PointSpace, row: number): boolean {
933
994
  const abi = this.abi;
934
995
  return (
935
- this.gridRef(POINT_ACTIVE, 0, row, (ref) =>
996
+ this.gridRef(point, 0, row, (ref) =>
936
997
  abi.with(8, (rowPtr) => {
937
998
  if (abi.call('ghostty_grid_ref_row', ref, rowPtr) !== GHOSTTY_SUCCESS) return false;
938
999
  const raw = abi.view().getBigUint64(rowPtr, true);
@@ -999,7 +1060,10 @@ export class GhosttyTerminal {
999
1060
  */
1000
1061
  configure(config: GhosttyTerminalConfig): void {
1001
1062
  const abi = this.abi;
1063
+ this.paletteStale = true;
1064
+ this.changed = true;
1002
1065
  if (config.scrollbackLimit !== undefined) {
1066
+ this.setOption('SCROLLBACK_MAX_BYTES', 0);
1003
1067
  abi.with(4, (ptr) => {
1004
1068
  abi.view().setUint32(ptr, config.scrollbackLimit!, true);
1005
1069
  this.setOption('SCROLLBACK_MAX_LINES', ptr);
@@ -1159,46 +1223,35 @@ export class GhosttyTerminal {
1159
1223
 
1160
1224
  private readTerminalU32(data: string): number {
1161
1225
  const abi = this.abi;
1162
- return abi.with(8, (out) => {
1163
- abi.check(
1164
- 'ghostty_terminal_get',
1165
- this.handle,
1166
- abi.enumValue('GhosttyTerminalData', data),
1167
- out
1168
- );
1169
- return abi.view().getUint32(out, true);
1170
- });
1226
+ const out = abi.scratch(8);
1227
+ abi.check('ghostty_terminal_get', this.handle, abi.enumValue('GhosttyTerminalData', data), out);
1228
+ return abi.view().getUint32(out, true);
1171
1229
  }
1172
1230
 
1173
1231
  private readTerminalU16(data: string): number {
1174
1232
  const abi = this.abi;
1175
- return abi.with(8, (out) => {
1176
- abi.check(
1177
- 'ghostty_terminal_get',
1178
- this.handle,
1179
- abi.enumValue('GhosttyTerminalData', data),
1180
- out
1181
- );
1182
- return abi.view().getUint16(out, true);
1183
- });
1233
+ const out = abi.scratch(8);
1234
+ abi.check('ghostty_terminal_get', this.handle, abi.enumValue('GhosttyTerminalData', data), out);
1235
+ return abi.view().getUint16(out, true);
1184
1236
  }
1185
1237
 
1186
1238
  private readRenderU32(data: string): number {
1187
1239
  const abi = this.abi;
1188
- return abi.with(8, (out) => {
1189
- abi.check(
1190
- 'ghostty_render_state_get',
1191
- this.renderState,
1192
- abi.enumValue('GhosttyRenderStateData', data),
1193
- out
1194
- );
1195
- return abi.view().getUint32(out, true);
1196
- });
1240
+ const out = abi.scratch(8);
1241
+ abi.check(
1242
+ 'ghostty_render_state_get',
1243
+ this.renderState,
1244
+ abi.enumValue('GhosttyRenderStateData', data),
1245
+ out
1246
+ );
1247
+ return abi.view().getUint32(out, true);
1197
1248
  }
1198
1249
 
1199
- private readColors(): void {
1250
+ /** Reads the colors cells are drawn with; the 256-color palette only when `palette`. */
1251
+ private readColors(palette: boolean): void {
1200
1252
  const abi = this.abi;
1201
- abi.withSized('GhosttyRenderStateColors', (ptr) => {
1253
+ if (palette) {
1254
+ const ptr = abi.scratchSized('GhosttyRenderStateColors');
1202
1255
  abi.check(
1203
1256
  'ghostty_render_state_get',
1204
1257
  this.renderState,
@@ -1207,16 +1260,16 @@ export class GhosttyTerminal {
1207
1260
  );
1208
1261
  const view = abi.view();
1209
1262
  const at = (f: string) => ptr + abi.offset('GhosttyRenderStateColors', f);
1210
- const palette: RGB[] = [];
1211
- for (let i = 0; i < 256; i++) palette.push(rgbAt(view, at('palette') + i * 3));
1263
+ const colors: RGB[] = [];
1264
+ for (let i = 0; i < 256; i++) colors.push(rgbAt(view, at('palette') + i * 3));
1212
1265
  this.colors = {
1213
1266
  background: rgbAt(view, at('background')),
1214
1267
  foreground: rgbAt(view, at('foreground')),
1215
1268
  cursor: view.getUint8(at('cursor_has_value')) ? rgbAt(view, at('cursor')) : null,
1216
- palette,
1269
+ palette: colors,
1217
1270
  };
1218
- });
1219
- this.readTerminalColors();
1271
+ }
1272
+ this.readTerminalColors(palette);
1220
1273
  }
1221
1274
 
1222
1275
  /**
@@ -1224,28 +1277,27 @@ export class GhosttyTerminal {
1224
1277
  * ghostty_terminal_set, so the effective colors (a program's OSC override,
1225
1278
  * else the default) are read from the terminal itself.
1226
1279
  */
1227
- private readTerminalColors(): void {
1280
+ private readTerminalColors(palette: boolean): void {
1228
1281
  const abi = this.abi;
1229
1282
  const data = (name: string) => abi.enumValue('GhosttyTerminalData', name);
1230
- const rgb = (name: string): RGB | null =>
1231
- abi.with(4, (ptr) =>
1232
- abi.call('ghostty_terminal_get', this.handle, data(name), ptr) === GHOSTTY_SUCCESS
1233
- ? rgbAt(abi.view(), ptr)
1234
- : null
1235
- );
1283
+ const rgb = (name: string): RGB | null => {
1284
+ const ptr = abi.scratch(4);
1285
+ return abi.call('ghostty_terminal_get', this.handle, data(name), ptr) === GHOSTTY_SUCCESS
1286
+ ? rgbAt(abi.view(), ptr)
1287
+ : null;
1288
+ };
1236
1289
  this.colors.background = rgb('COLOR_BACKGROUND') ?? this.colors.background;
1237
1290
  this.colors.foreground = rgb('COLOR_FOREGROUND') ?? this.colors.foreground;
1238
1291
  this.colors.cursor = rgb('COLOR_CURSOR') ?? this.colors.cursor;
1239
- abi.with(256 * 3, (ptr) => {
1240
- if (
1241
- abi.call('ghostty_terminal_get', this.handle, data('COLOR_PALETTE'), ptr) !==
1242
- GHOSTTY_SUCCESS
1243
- ) {
1244
- return;
1245
- }
1246
- const view = abi.view();
1247
- for (let i = 0; i < 256; i++) this.colors.palette[i] = rgbAt(view, ptr + i * 3);
1248
- });
1292
+ if (!palette) return;
1293
+ const ptr = abi.scratch(256 * 3);
1294
+ if (
1295
+ abi.call('ghostty_terminal_get', this.handle, data('COLOR_PALETTE'), ptr) !== GHOSTTY_SUCCESS
1296
+ ) {
1297
+ return;
1298
+ }
1299
+ const view = abi.view();
1300
+ for (let i = 0; i < 256; i++) this.colors.palette[i] = rgbAt(view, ptr + i * 3);
1249
1301
  }
1250
1302
 
1251
1303
  private extractRows(all: boolean): void {
@@ -1465,6 +1517,10 @@ export class GhosttyTerminal {
1465
1517
  const total = this._cols * this._rows;
1466
1518
  while (this.cellPool.length < total) this.cellPool.push(GhosttyTerminal.emptyCell());
1467
1519
  this.cellPool.length = total;
1520
+ this.rowViews = [];
1521
+ for (let y = 0; y < this._rows; y++) {
1522
+ this.rowViews.push(this.cellPool.slice(y * this._cols, (y + 1) * this._cols));
1523
+ }
1468
1524
  this.rowDirty = new Array(this._rows).fill(true);
1469
1525
  this.dirty = DirtyState.FULL;
1470
1526
  }
@@ -409,8 +409,9 @@ export class InputHandler {
409
409
  }
410
410
  }
411
411
 
412
- // Allow Ctrl+V and Cmd+V to trigger paste event (don't preventDefault)
413
- if ((event.ctrlKey || event.metaKey) && event.code === 'KeyV') {
412
+ // Cmd+V pastes through the browser's paste event; Ctrl+V reaches the
413
+ // program as ^V, as in xterm.js.
414
+ if (event.metaKey && event.code === 'KeyV') {
414
415
  // Let the browser's native paste event fire
415
416
  return;
416
417
  }
package/lib/renderer.ts CHANGED
@@ -17,6 +17,8 @@ import { CellFlags } from './types';
17
17
  // Interface for objects that can be rendered
18
18
  export interface IRenderable {
19
19
  getLine(y: number): GhosttyCell[] | null;
20
+ /** A row's cells without a copy, read before the buffer next changes; getLine() when absent. */
21
+ getLineView?(y: number): readonly GhosttyCell[] | null;
20
22
  getCursor(): { x: number; y: number; visible: boolean };
21
23
  getDimensions(): { cols: number; rows: number };
22
24
  isRowDirty(y: number): boolean;
@@ -247,6 +249,8 @@ export abstract class TerminalRenderer {
247
249
  protected lastCursorPosition: { x: number; y: number } = { x: 0, y: 0 };
248
250
  private lastViewportY = 0;
249
251
  private fullRedrawPending = false;
252
+ /** The cursor and scrollbar the last finished frame showed. */
253
+ private shownOverlay: FrameOverlay | null = null;
250
254
 
251
255
  /** The buffer of the frame being drawn, for grapheme lookups. */
252
256
  protected currentBuffer: IRenderable | null = null;
@@ -290,7 +294,7 @@ export abstract class TerminalRenderer {
290
294
  }
291
295
 
292
296
  /** Draws one viewport row. */
293
- protected abstract drawLine(line: GhosttyCell[], y: number, cols: number): void;
297
+ protected abstract drawLine(line: readonly GhosttyCell[], y: number, cols: number): void;
294
298
  /** Whether the drawing surface no longer matches cols × rows. */
295
299
  protected abstract surfaceMismatch(cols: number, rows: number): boolean;
296
300
  /** Draws the cursor and scrollbar and presents the frame. */
@@ -334,14 +338,15 @@ export abstract class TerminalRenderer {
334
338
  this.cursorVisible !== this.lastCursorVisible || this.focused !== this.lastFocused;
335
339
  this.lastCursorVisible = this.cursorVisible;
336
340
  this.lastFocused = this.focused;
337
- if (this.cursorInRows && (cursorMoved || blinked)) {
341
+ // Scrolled back the cursor is not shown, and its row is not on screen where it is in the buffer.
342
+ if (this.cursorInRows && viewportY === 0 && (cursorMoved || blinked)) {
338
343
  if (!forceAll && !buffer.isRowDirty(cursor.y)) {
339
- const line = buffer.getLine(cursor.y);
344
+ const line = this.bufferLine(cursor.y);
340
345
  if (line) this.drawLine(line, cursor.y, dims.cols);
341
346
  }
342
347
  if (cursorMoved && this.lastCursorPosition.y !== cursor.y) {
343
348
  if (!forceAll && !buffer.isRowDirty(this.lastCursorPosition.y)) {
344
- const line = buffer.getLine(this.lastCursorPosition.y);
349
+ const line = this.bufferLine(this.lastCursorPosition.y);
345
350
  if (line) this.drawLine(line, this.lastCursorPosition.y, dims.cols);
346
351
  }
347
352
  }
@@ -364,14 +369,19 @@ export abstract class TerminalRenderer {
364
369
  }
365
370
  }
366
371
 
367
- const lineAt = (y: number): GhosttyCell[] | null => {
372
+ const lineAt = (y: number): readonly GhosttyCell[] | null => {
368
373
  if (viewportY > 0) {
369
374
  if (y < viewportY && scrollbackProvider) {
370
375
  return scrollbackProvider.getScrollbackLine(scrollbackLength - Math.floor(viewportY) + y);
371
376
  }
372
- return buffer.getLine(y - Math.floor(viewportY));
377
+ return this.bufferLine(y - Math.floor(viewportY));
373
378
  }
374
- return buffer.getLine(y);
379
+ return this.bufferLine(y);
380
+ };
381
+ // Scrolled back, viewport row y shows active row y - viewportY; rows above it show scrollback.
382
+ const rowDirty = (y: number): boolean => {
383
+ const active = y - Math.floor(viewportY);
384
+ return active >= 0 && buffer.isRowDirty(active);
375
385
  };
376
386
 
377
387
  const hyperlinkRows = new Set<number>();
@@ -401,12 +411,7 @@ export abstract class TerminalRenderer {
401
411
 
402
412
  const rowsToRender = new Set<number>();
403
413
  for (let y = 0; y < dims.rows; y++) {
404
- const needsRender =
405
- viewportY > 0 ||
406
- forceAll ||
407
- buffer.isRowDirty(y) ||
408
- selectionRows.has(y) ||
409
- hyperlinkRows.has(y);
414
+ const needsRender = forceAll || rowDirty(y) || selectionRows.has(y) || hyperlinkRows.has(y);
410
415
  if (needsRender) {
411
416
  rowsToRender.add(y);
412
417
  if (y > 0) rowsToRender.add(y - 1);
@@ -419,7 +424,7 @@ export abstract class TerminalRenderer {
419
424
  if (line) this.drawLine(line, y, dims.cols);
420
425
  }
421
426
 
422
- this.finishFrame({
427
+ const overlay: FrameOverlay = {
423
428
  cursorX: cursor.x,
424
429
  cursorY: cursor.y,
425
430
  showCursor: viewportY === 0 && cursor.visible && this.cursorVisible,
@@ -429,11 +434,23 @@ export abstract class TerminalRenderer {
429
434
  cols: dims.cols,
430
435
  rows: dims.rows,
431
436
  scrollbarOpacity: scrollbackProvider ? scrollbarOpacity : 0,
432
- });
437
+ };
438
+ // A frame with no row drawn and the same cursor and scrollbar has nothing to show.
439
+ if (rowsToRender.size > 0 || !sameOverlay(overlay, this.shownOverlay)) {
440
+ this.finishFrame(overlay);
441
+ this.shownOverlay = overlay;
442
+ }
433
443
  this.lastCursorPosition = { x: cursor.x, y: cursor.y };
434
444
  buffer.clearDirty();
435
445
  }
436
446
 
447
+ /** A row of the buffer being drawn, without a copy when the buffer offers one. */
448
+ protected bufferLine(y: number): readonly GhosttyCell[] | null {
449
+ const buffer = this.currentBuffer;
450
+ if (!buffer) return null;
451
+ return buffer.getLineView ? buffer.getLineView(y) : buffer.getLine(y);
452
+ }
453
+
437
454
  // ==========================================================================
438
455
  // Shared helpers
439
456
  // ==========================================================================
@@ -441,6 +458,7 @@ export abstract class TerminalRenderer {
441
458
  /** Makes the next frame redraw every row. */
442
459
  protected requestFullRedraw(): void {
443
460
  this.fullRedrawPending = true;
461
+ this.shownOverlay = null;
444
462
  }
445
463
 
446
464
  /** The text of a cell: its grapheme cluster when it has one, else its codepoint. */
@@ -625,7 +643,9 @@ export abstract class TerminalRenderer {
625
643
  }
626
644
 
627
645
  public setCursorStyle(style: CursorStyle): void {
646
+ if (style === this.cursorStyle) return;
628
647
  this.cursorStyle = style;
648
+ this.requestFullRedraw();
629
649
  }
630
650
 
631
651
  public setCursorBlink(enabled: boolean): void {
@@ -700,6 +720,21 @@ export function cssRgb(css: string): [number, number, number] {
700
720
  return [0, 0, 0];
701
721
  }
702
722
 
723
+ function sameOverlay(a: FrameOverlay, b: FrameOverlay | null): boolean {
724
+ return (
725
+ b !== null &&
726
+ a.cursorX === b.cursorX &&
727
+ a.cursorY === b.cursorY &&
728
+ a.showCursor === b.showCursor &&
729
+ a.focused === b.focused &&
730
+ a.viewportY === b.viewportY &&
731
+ a.scrollbackLength === b.scrollbackLength &&
732
+ a.cols === b.cols &&
733
+ a.rows === b.rows &&
734
+ a.scrollbarOpacity === b.scrollbarOpacity
735
+ );
736
+ }
737
+
703
738
  function sameRange(a: LinkRange | null, b: LinkRange | null): boolean {
704
739
  if (a === b) return true;
705
740
  if (!a || !b) return false;
@@ -774,7 +809,7 @@ export class CanvasRenderer extends TerminalRenderer {
774
809
  * of one color are one rect; runs of plain ASCII in one style are one
775
810
  * fillText, spaced to the cell grid with letterSpacing.
776
811
  */
777
- protected drawLine(line: GhosttyCell[], y: number, cols: number): void {
812
+ protected drawLine(line: readonly GhosttyCell[], y: number, cols: number): void {
778
813
  const ctx = this.ctx;
779
814
  const cellW = this.metrics.width;
780
815
  const cellH = this.metrics.height;
@@ -1007,7 +1042,7 @@ export class CanvasRenderer extends TerminalRenderer {
1007
1042
  );
1008
1043
  } else if (this.cursorStyle === 'block') {
1009
1044
  this.ctx.fillRect(cursorX, cursorY, this.metrics.width, this.metrics.height);
1010
- const line = this.currentBuffer?.getLine(y);
1045
+ const line = this.bufferLine(y);
1011
1046
  if (line?.[x]) {
1012
1047
  this.ctx.save();
1013
1048
  this.ctx.beginPath();
@@ -5,7 +5,6 @@
5
5
  * - Mouse drag selection
6
6
  * - Double-click word selection
7
7
  * - Text extraction from terminal buffer
8
- * - Automatic clipboard copy
9
8
  * - Visual selection highlighting (integrated into CanvasRenderer cell rendering)
10
9
  * - Auto-scroll during drag selection
11
10
  */
@@ -588,11 +587,7 @@ export class SelectionManager {
588
587
  }
589
588
 
590
589
  if (this.hasSelection()) {
591
- const text = this.getSelection();
592
- if (text) {
593
- this.copyToClipboard(text);
594
- this.selectionChangedEmitter.fire();
595
- }
590
+ if (this.getSelection()) this.selectionChangedEmitter.fire();
596
591
  }
597
592
  }
598
593
  };
@@ -613,11 +608,7 @@ export class SelectionManager {
613
608
  this.selectionEnd = { col: word.endCol, absoluteRow };
614
609
  this.requestRender();
615
610
 
616
- const text = this.getSelection();
617
- if (text) {
618
- this.copyToClipboard(text);
619
- this.selectionChangedEmitter.fire();
620
- }
611
+ if (this.getSelection()) this.selectionChangedEmitter.fire();
621
612
  }
622
613
  } else if (e.detail >= 3) {
623
614
  // Triple-click (or more) - select line content (like native Ghostty)
@@ -654,11 +645,7 @@ export class SelectionManager {
654
645
  this.selectionEnd = { col: endCol, absoluteRow };
655
646
  this.requestRender();
656
647
 
657
- const text = this.getSelection();
658
- if (text) {
659
- this.copyToClipboard(text);
660
- this.selectionChangedEmitter.fire();
661
- }
648
+ if (this.getSelection()) this.selectionChangedEmitter.fire();
662
649
  }
663
650
  }
664
651
  });
package/lib/terminal.ts CHANGED
@@ -263,7 +263,7 @@ export class Terminal implements ITerminalCore {
263
263
  this.wasmTerm?.configure(this.themeConfig());
264
264
  if (this.renderer && this.wasmTerm) {
265
265
  this.renderer.setTheme(this.options.theme);
266
- this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity);
266
+ this.draw(true, this.scrollbarOpacity);
267
267
  }
268
268
  break;
269
269
 
@@ -283,7 +283,7 @@ export class Terminal implements ITerminalCore {
283
283
  case 'minimumContrastRatio':
284
284
  if (this.renderer && this.wasmTerm) {
285
285
  this.renderer.setMinimumContrastRatio(this.options.minimumContrastRatio);
286
- this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity);
286
+ this.draw(true, this.scrollbarOpacity);
287
287
  }
288
288
  break;
289
289
 
@@ -329,7 +329,7 @@ export class Terminal implements ITerminalCore {
329
329
  this.renderer.resize(this.cols, this.rows);
330
330
 
331
331
  // Force full re-render with new font
332
- this.renderer.render(this.wasmTerm, true, this.viewportY, this);
332
+ this.draw(true);
333
333
  }
334
334
 
335
335
  /**
@@ -578,13 +578,10 @@ export class Terminal implements ITerminalCore {
578
578
  parent.addEventListener('wheel', this.handleWheel, { passive: false, capture: true });
579
579
 
580
580
  // Render initial blank screen (force full redraw)
581
- this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity);
581
+ this.draw(true, this.scrollbarOpacity);
582
582
 
583
583
  // Start render loop
584
584
  this.startRenderLoop();
585
-
586
- // Focus input (auto-focus so user can start typing immediately)
587
- this.focus();
588
585
  } catch (error) {
589
586
  // Clean up on error
590
587
  this.isOpen = false;
@@ -641,10 +638,16 @@ export class Terminal implements ITerminalCore {
641
638
 
642
639
  /** Replaces a WebGL renderer whose context was lost with a canvas renderer on the same canvas. */
643
640
  private fallBackToCanvas(): void {
644
- if (!this.renderer || !this.canvas || !(this.renderer instanceof WebglRenderer)) return;
645
- const hoveredLink = this.renderer.getHoveredHyperlinkId();
646
- this.renderer.dispose();
647
- const renderer = new CanvasRenderer(this.canvas, this.rendererOptions());
641
+ if (!this.canvas || !(this.renderer instanceof WebglRenderer)) return;
642
+ this.installRenderer(new CanvasRenderer(this.canvas, this.rendererOptions()));
643
+ this.wake();
644
+ }
645
+
646
+ /** Disposes the current renderer and draws with `renderer` from now on, in its state. */
647
+ private installRenderer(renderer: TerminalRenderer): void {
648
+ const old = this.renderer!;
649
+ const hoveredLink = old.getHoveredHyperlinkId();
650
+ old.dispose();
648
651
  renderer.onNeedsFrame = () => this.requestFrame();
649
652
  renderer.setHoveredHyperlinkId(hoveredLink);
650
653
  renderer.setFocused(this.focused);
@@ -655,10 +658,47 @@ export class Terminal implements ITerminalCore {
655
658
  }
656
659
  this.renderer = renderer;
657
660
  renderer.resize(this.cols, this.rows);
658
- if (this.wasmTerm) {
659
- renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity);
661
+ this.draw(true, this.scrollbarOpacity);
662
+ }
663
+
664
+ private paused = false;
665
+ private rendererReleased = false;
666
+
667
+ /**
668
+ * Pauses drawing, for a terminal that is not on screen. Writes still parse and
669
+ * the buffer, markers and events stay current, but no frame is drawn and
670
+ * onRender does not fire until resumed, which redraws the whole screen once.
671
+ * `releaseRenderer` also gives up a WebGL context until then (browsers keep
672
+ * only a few), at the cost of rebuilding the glyph atlas on resume.
673
+ */
674
+ setPaused(paused: boolean, options: { releaseRenderer?: boolean } = {}): void {
675
+ if (paused) {
676
+ this.paused = true;
677
+ this.cancelRenderLoop();
678
+ if (options.releaseRenderer && this.canvas && this.renderer instanceof WebglRenderer) {
679
+ // A canvas renderer that never draws keeps metrics and sizing until resume.
680
+ this.installRenderer(new CanvasRenderer(this.canvas, this.rendererOptions()));
681
+ this.rendererReleased = true;
682
+ }
683
+ return;
660
684
  }
661
- this.wake();
685
+ if (!this.paused) return;
686
+ this.paused = false;
687
+ if (!this.isOpen || this.isDisposed) return;
688
+ if (this.rendererReleased) {
689
+ this.rendererReleased = false;
690
+ this.installRenderer(this.createRenderer());
691
+ } else {
692
+ this.draw(true, this.scrollbarOpacity);
693
+ }
694
+ this.renderEmitter.fire({ start: 0, end: this.rows - 1 });
695
+ this.requestFrame();
696
+ }
697
+
698
+ /** Draws a frame now unless paused; forceAll redraws every row. */
699
+ private draw(forceAll: boolean, scrollbarOpacity?: number): void {
700
+ if (this.paused || !this.renderer || !this.wasmTerm) return;
701
+ this.renderer.render(this.wasmTerm, forceAll, this.viewportY, this, scrollbarOpacity);
662
702
  }
663
703
 
664
704
  /**
@@ -702,15 +742,14 @@ export class Terminal implements ITerminalCore {
702
742
 
703
743
  // Call callback if provided
704
744
  if (callback) {
705
- // Queue callback after next render
706
- requestAnimationFrame(callback);
745
+ // The data is parsed by now, so run the callback as soon as this task
746
+ // ends, as xterm.js does once a write is processed.
747
+ queueMicrotask(callback);
707
748
  }
708
749
 
709
750
  if (this.awaitingEcho) {
710
751
  this.awaitingEcho = false;
711
- if (this.renderer && this.wasmTerm) {
712
- this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
713
- }
752
+ this.draw(false, this.scrollbarOpacity);
714
753
  }
715
754
 
716
755
  // Render will happen on next animation frame
@@ -802,6 +841,7 @@ export class Terminal implements ITerminalCore {
802
841
 
803
842
  // Resize WASM terminal (may reallocate buffers, invalidating TypedArray views)
804
843
  this.wasmTerm!.resize(cols, rows);
844
+ this.refreshMarkers(false);
805
845
 
806
846
  // Resize renderer
807
847
  this.renderer!.resize(cols, rows);
@@ -810,7 +850,7 @@ export class Terminal implements ITerminalCore {
810
850
  this.resizeEmitter.fire({ cols, rows });
811
851
 
812
852
  // Force full render
813
- this.renderer!.render(this.wasmTerm!, true, this.viewportY, this);
853
+ this.draw(true);
814
854
  } catch (e) {
815
855
  console.error('Terminal resize failed:', e);
816
856
  }
@@ -857,16 +897,7 @@ export class Terminal implements ITerminalCore {
857
897
  */
858
898
  focus(): void {
859
899
  this.wake();
860
- if (this.isOpen && this.element) {
861
- // Focus immediately for immediate keyboard/wheel event handling
862
- this.element.focus();
863
-
864
- // Also schedule a delayed focus as backup to ensure it sticks
865
- // (some browsers may need this if DOM isn't fully settled)
866
- setTimeout(() => {
867
- this.element?.focus();
868
- }, 0);
869
- }
900
+ if (this.isOpen && this.element) this.element.focus();
870
901
  }
871
902
 
872
903
  /**
@@ -1282,12 +1313,12 @@ export class Terminal implements ITerminalCore {
1282
1313
  * Start the render loop
1283
1314
  */
1284
1315
  private startRenderLoop(): void {
1285
- if (this.animationFrameId) return;
1316
+ if (this.animationFrameId || this.paused) return;
1286
1317
  const loop = () => {
1287
1318
  this.animationFrameId = undefined;
1288
1319
  if (this.isDisposed || !this.isOpen) return;
1289
1320
  const drew = this.wasmTerm!.update() !== DirtyState.NONE;
1290
- this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity);
1321
+ this.draw(false, this.scrollbarOpacity);
1291
1322
  if (drew) this.renderEmitter.fire({ start: 0, end: this.rows - 1 });
1292
1323
  const cursor = this.wasmTerm!.getCursor();
1293
1324
  if (cursor.y !== this.lastCursorY) {
@@ -1900,9 +1931,7 @@ export class Terminal implements ITerminalCore {
1900
1931
  this.scrollbarOpacity = progress;
1901
1932
 
1902
1933
  // Trigger render to show updated opacity
1903
- if (this.renderer && this.wasmTerm) {
1904
- this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
1905
- }
1934
+ this.draw(false, this.scrollbarOpacity);
1906
1935
 
1907
1936
  if (progress < 1) {
1908
1937
  requestAnimationFrame(animate);
@@ -1924,9 +1953,7 @@ export class Terminal implements ITerminalCore {
1924
1953
  this.scrollbarOpacity = startOpacity * (1 - progress);
1925
1954
 
1926
1955
  // Trigger render to show updated opacity
1927
- if (this.renderer && this.wasmTerm) {
1928
- this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
1929
- }
1956
+ this.draw(false, this.scrollbarOpacity);
1930
1957
 
1931
1958
  if (progress < 1) {
1932
1959
  requestAnimationFrame(animate);
@@ -1934,9 +1961,7 @@ export class Terminal implements ITerminalCore {
1934
1961
  this.scrollbarVisible = false;
1935
1962
  this.scrollbarOpacity = 0;
1936
1963
  // Final render to clear scrollbar completely
1937
- if (this.renderer && this.wasmTerm) {
1938
- this.renderer.render(this.wasmTerm, false, this.viewportY, this, 0);
1939
- }
1964
+ this.draw(false, 0);
1940
1965
  }
1941
1966
  };
1942
1967
  animate();
@@ -1984,6 +2009,8 @@ export class Terminal implements ITerminalCore {
1984
2009
  if (!this.wasmTerm) return undefined;
1985
2010
  const y = this.wasmTerm.cursorPosition().y + cursorYOffset;
1986
2011
  if (y < 0 || y >= this.rows) return undefined;
2012
+ // Starts watching the active screen for discarded rows before the marker exists.
2013
+ if (this.wasmTerm.rowsDiscarded()) this.refreshMarkers(true);
1987
2014
  const row = this.wasmTerm.trackRow(y);
1988
2015
  if (!row) return undefined;
1989
2016
  const marker = new Marker(row);
@@ -2076,8 +2103,10 @@ export class Terminal implements ITerminalCore {
2076
2103
  if (alternate !== this.lastAlternate) {
2077
2104
  this.lastAlternate = alternate;
2078
2105
  (this.buffer as BufferNamespace)._fireBufferChange(this.buffer.active);
2106
+ this.refreshMarkers(true);
2107
+ } else {
2108
+ this.refreshMarkers(false);
2079
2109
  }
2080
- for (const marker of [...this.markers]) marker.refresh();
2081
2110
  this.writeParsedEmitter.fire();
2082
2111
  this.wake();
2083
2112
  }
@@ -2099,6 +2128,13 @@ export class Terminal implements ITerminalCore {
2099
2128
  this.scrollEmitter.fire(this.buffer.active.viewportY);
2100
2129
  }
2101
2130
 
2131
+ /** Disposes the markers whose line is gone; only rows the engine discarded can be. */
2132
+ private refreshMarkers(force: boolean): void {
2133
+ if (this.markers.size === 0 || !this.wasmTerm) return;
2134
+ if (!this.wasmTerm.rowsDiscarded() && !force) return;
2135
+ for (const marker of [...this.markers]) marker.refresh();
2136
+ }
2137
+
2102
2138
  private disposeMarkers(): void {
2103
2139
  for (const marker of [...this.markers]) marker.dispose();
2104
2140
  this.markers.clear();
@@ -524,7 +524,7 @@ export class WebglRenderer extends TerminalRenderer {
524
524
  // Rows
525
525
  // ==========================================================================
526
526
 
527
- protected drawLine(line: GhosttyCell[], y: number, cols: number): void {
527
+ protected drawLine(line: readonly GhosttyCell[], y: number, cols: number): void {
528
528
  if (y < 0 || y >= this.rows) return;
529
529
  const cells = this.cols * this.rows;
530
530
  const rects = this.rectWords;
@@ -721,7 +721,7 @@ export class WebglRenderer extends TerminalRenderer {
721
721
  colors[4] = theme.cursor;
722
722
  key = `${left},${top},${this.cursorStyle},${overlay.focused}`;
723
723
  if (overlay.focused && this.cursorStyle === 'block') {
724
- const cell = this.currentBuffer?.getLine(overlay.cursorY)?.[overlay.cursorX];
724
+ const cell = this.bufferLine(overlay.cursorY)?.[overlay.cursorX];
725
725
  if (cell && !isBlank(cell) && !(cell.flags & CellFlags.INVISIBLE)) {
726
726
  const entry = this.glyphFor(cell, overlay.cursorX, overlay.cursorY);
727
727
  if (entry) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aurigax-ai/ghostty-web",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Ghostty's terminal engine (libghostty-vt) in the browser: WebAssembly build, an xterm.js-shaped API and WebGL and canvas renderers. A hard fork of coder/ghostty-web.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.ts",