@aurigax-ai/ghostty-web 0.3.1 → 0.3.3

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/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;
@@ -1009,6 +1060,8 @@ export class GhosttyTerminal {
1009
1060
  */
1010
1061
  configure(config: GhosttyTerminalConfig): void {
1011
1062
  const abi = this.abi;
1063
+ this.paletteStale = true;
1064
+ this.changed = true;
1012
1065
  if (config.scrollbackLimit !== undefined) {
1013
1066
  this.setOption('SCROLLBACK_MAX_BYTES', 0);
1014
1067
  abi.with(4, (ptr) => {
@@ -1170,46 +1223,35 @@ export class GhosttyTerminal {
1170
1223
 
1171
1224
  private readTerminalU32(data: string): number {
1172
1225
  const abi = this.abi;
1173
- return abi.with(8, (out) => {
1174
- abi.check(
1175
- 'ghostty_terminal_get',
1176
- this.handle,
1177
- abi.enumValue('GhosttyTerminalData', data),
1178
- out
1179
- );
1180
- return abi.view().getUint32(out, true);
1181
- });
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);
1182
1229
  }
1183
1230
 
1184
1231
  private readTerminalU16(data: string): number {
1185
1232
  const abi = this.abi;
1186
- return abi.with(8, (out) => {
1187
- abi.check(
1188
- 'ghostty_terminal_get',
1189
- this.handle,
1190
- abi.enumValue('GhosttyTerminalData', data),
1191
- out
1192
- );
1193
- return abi.view().getUint16(out, true);
1194
- });
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);
1195
1236
  }
1196
1237
 
1197
1238
  private readRenderU32(data: string): number {
1198
1239
  const abi = this.abi;
1199
- return abi.with(8, (out) => {
1200
- abi.check(
1201
- 'ghostty_render_state_get',
1202
- this.renderState,
1203
- abi.enumValue('GhosttyRenderStateData', data),
1204
- out
1205
- );
1206
- return abi.view().getUint32(out, true);
1207
- });
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);
1208
1248
  }
1209
1249
 
1210
- private readColors(): void {
1250
+ /** Reads the colors cells are drawn with; the 256-color palette only when `palette`. */
1251
+ private readColors(palette: boolean): void {
1211
1252
  const abi = this.abi;
1212
- abi.withSized('GhosttyRenderStateColors', (ptr) => {
1253
+ if (palette) {
1254
+ const ptr = abi.scratchSized('GhosttyRenderStateColors');
1213
1255
  abi.check(
1214
1256
  'ghostty_render_state_get',
1215
1257
  this.renderState,
@@ -1218,16 +1260,16 @@ export class GhosttyTerminal {
1218
1260
  );
1219
1261
  const view = abi.view();
1220
1262
  const at = (f: string) => ptr + abi.offset('GhosttyRenderStateColors', f);
1221
- const palette: RGB[] = [];
1222
- 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));
1223
1265
  this.colors = {
1224
1266
  background: rgbAt(view, at('background')),
1225
1267
  foreground: rgbAt(view, at('foreground')),
1226
1268
  cursor: view.getUint8(at('cursor_has_value')) ? rgbAt(view, at('cursor')) : null,
1227
- palette,
1269
+ palette: colors,
1228
1270
  };
1229
- });
1230
- this.readTerminalColors();
1271
+ }
1272
+ this.readTerminalColors(palette);
1231
1273
  }
1232
1274
 
1233
1275
  /**
@@ -1235,28 +1277,27 @@ export class GhosttyTerminal {
1235
1277
  * ghostty_terminal_set, so the effective colors (a program's OSC override,
1236
1278
  * else the default) are read from the terminal itself.
1237
1279
  */
1238
- private readTerminalColors(): void {
1280
+ private readTerminalColors(palette: boolean): void {
1239
1281
  const abi = this.abi;
1240
1282
  const data = (name: string) => abi.enumValue('GhosttyTerminalData', name);
1241
- const rgb = (name: string): RGB | null =>
1242
- abi.with(4, (ptr) =>
1243
- abi.call('ghostty_terminal_get', this.handle, data(name), ptr) === GHOSTTY_SUCCESS
1244
- ? rgbAt(abi.view(), ptr)
1245
- : null
1246
- );
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
+ };
1247
1289
  this.colors.background = rgb('COLOR_BACKGROUND') ?? this.colors.background;
1248
1290
  this.colors.foreground = rgb('COLOR_FOREGROUND') ?? this.colors.foreground;
1249
1291
  this.colors.cursor = rgb('COLOR_CURSOR') ?? this.colors.cursor;
1250
- abi.with(256 * 3, (ptr) => {
1251
- if (
1252
- abi.call('ghostty_terminal_get', this.handle, data('COLOR_PALETTE'), ptr) !==
1253
- GHOSTTY_SUCCESS
1254
- ) {
1255
- return;
1256
- }
1257
- const view = abi.view();
1258
- for (let i = 0; i < 256; i++) this.colors.palette[i] = rgbAt(view, ptr + i * 3);
1259
- });
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);
1260
1301
  }
1261
1302
 
1262
1303
  private extractRows(all: boolean): void {
@@ -1476,6 +1517,10 @@ export class GhosttyTerminal {
1476
1517
  const total = this._cols * this._rows;
1477
1518
  while (this.cellPool.length < total) this.cellPool.push(GhosttyTerminal.emptyCell());
1478
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
+ }
1479
1524
  this.rowDirty = new Array(this._rows).fill(true);
1480
1525
  this.dirty = DirtyState.FULL;
1481
1526
  }
@@ -192,7 +192,6 @@ export class InputHandler {
192
192
  private mousedownListener: ((e: MouseEvent) => void) | null = null;
193
193
  private mouseupListener: ((e: MouseEvent) => void) | null = null;
194
194
  private mousemoveListener: ((e: MouseEvent) => void) | null = null;
195
- private wheelListener: ((e: WheelEvent) => void) | null = null;
196
195
  private isComposing = false;
197
196
  private isDisposed = false;
198
197
  /**
@@ -319,9 +318,6 @@ export class InputHandler {
319
318
 
320
319
  this.mousemoveListener = this.handleMouseMove.bind(this);
321
320
  this.container.addEventListener('mousemove', this.mousemoveListener);
322
-
323
- this.wheelListener = this.handleWheel.bind(this);
324
- this.container.addEventListener('wheel', this.wheelListener, { passive: false });
325
321
  }
326
322
 
327
323
  /**
@@ -925,7 +921,7 @@ export class InputHandler {
925
921
  /**
926
922
  * Handle wheel event (scroll)
927
923
  */
928
- private handleWheel(event: WheelEvent): void {
924
+ handleWheel(event: WheelEvent): void {
929
925
  if (this.isDisposed) return;
930
926
  if (!this.mouseConfig?.hasMouseTracking()) return;
931
927
 
@@ -1129,11 +1125,6 @@ export class InputHandler {
1129
1125
  this.mousemoveListener = null;
1130
1126
  }
1131
1127
 
1132
- if (this.wheelListener) {
1133
- this.container.removeEventListener('wheel', this.wheelListener);
1134
- this.wheelListener = null;
1135
- }
1136
-
1137
1128
  this.isDisposed = true;
1138
1129
  }
1139
1130
 
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();
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,7 +578,7 @@ 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();
@@ -638,10 +638,16 @@ export class Terminal implements ITerminalCore {
638
638
 
639
639
  /** Replaces a WebGL renderer whose context was lost with a canvas renderer on the same canvas. */
640
640
  private fallBackToCanvas(): void {
641
- if (!this.renderer || !this.canvas || !(this.renderer instanceof WebglRenderer)) return;
642
- const hoveredLink = this.renderer.getHoveredHyperlinkId();
643
- this.renderer.dispose();
644
- 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();
645
651
  renderer.onNeedsFrame = () => this.requestFrame();
646
652
  renderer.setHoveredHyperlinkId(hoveredLink);
647
653
  renderer.setFocused(this.focused);
@@ -652,10 +658,47 @@ export class Terminal implements ITerminalCore {
652
658
  }
653
659
  this.renderer = renderer;
654
660
  renderer.resize(this.cols, this.rows);
655
- if (this.wasmTerm) {
656
- 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;
657
684
  }
658
- 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);
659
702
  }
660
703
 
661
704
  /**
@@ -706,9 +749,7 @@ export class Terminal implements ITerminalCore {
706
749
 
707
750
  if (this.awaitingEcho) {
708
751
  this.awaitingEcho = false;
709
- if (this.renderer && this.wasmTerm) {
710
- this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
711
- }
752
+ this.draw(false, this.scrollbarOpacity);
712
753
  }
713
754
 
714
755
  // Render will happen on next animation frame
@@ -800,6 +841,7 @@ export class Terminal implements ITerminalCore {
800
841
 
801
842
  // Resize WASM terminal (may reallocate buffers, invalidating TypedArray views)
802
843
  this.wasmTerm!.resize(cols, rows);
844
+ this.refreshMarkers(false);
803
845
 
804
846
  // Resize renderer
805
847
  this.renderer!.resize(cols, rows);
@@ -808,7 +850,7 @@ export class Terminal implements ITerminalCore {
808
850
  this.resizeEmitter.fire({ cols, rows });
809
851
 
810
852
  // Force full render
811
- this.renderer!.render(this.wasmTerm!, true, this.viewportY, this);
853
+ this.draw(true);
812
854
  } catch (e) {
813
855
  console.error('Terminal resize failed:', e);
814
856
  }
@@ -1271,12 +1313,12 @@ export class Terminal implements ITerminalCore {
1271
1313
  * Start the render loop
1272
1314
  */
1273
1315
  private startRenderLoop(): void {
1274
- if (this.animationFrameId) return;
1316
+ if (this.animationFrameId || this.paused) return;
1275
1317
  const loop = () => {
1276
1318
  this.animationFrameId = undefined;
1277
1319
  if (this.isDisposed || !this.isOpen) return;
1278
1320
  const drew = this.wasmTerm!.update() !== DirtyState.NONE;
1279
- this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity);
1321
+ this.draw(false, this.scrollbarOpacity);
1280
1322
  if (drew) this.renderEmitter.fire({ start: 0, end: this.rows - 1 });
1281
1323
  const cursor = this.wasmTerm!.getCursor();
1282
1324
  if (cursor.y !== this.lastCursorY) {
@@ -1667,6 +1709,11 @@ export class Terminal implements ITerminalCore {
1667
1709
  return;
1668
1710
  }
1669
1711
 
1712
+ if (this.wasmTerm?.hasMouseTracking()) {
1713
+ this.inputHandler?.handleWheel(e);
1714
+ return;
1715
+ }
1716
+
1670
1717
  // Check if in alternate screen mode (vim, less, htop, etc.)
1671
1718
  const isAltScreen = this.wasmTerm?.isAlternateScreen() ?? false;
1672
1719
 
@@ -1675,7 +1722,8 @@ export class Terminal implements ITerminalCore {
1675
1722
  // Applications like vim handle scrolling internally
1676
1723
  // Standard: ~3 arrow presses per wheel "click"
1677
1724
  const direction = e.deltaY > 0 ? 'down' : 'up';
1678
- const count = Math.min(Math.abs(Math.round(e.deltaY / 33)), 5); // Cap at 5
1725
+ const lines = e.deltaMode === WheelEvent.DOM_DELTA_LINE ? e.deltaY : e.deltaY / 33;
1726
+ const count = Math.min(Math.abs(Math.round(lines)), 5); // Cap at 5
1679
1727
 
1680
1728
  for (let i = 0; i < count; i++) {
1681
1729
  if (direction === 'up') {
@@ -1889,9 +1937,7 @@ export class Terminal implements ITerminalCore {
1889
1937
  this.scrollbarOpacity = progress;
1890
1938
 
1891
1939
  // Trigger render to show updated opacity
1892
- if (this.renderer && this.wasmTerm) {
1893
- this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
1894
- }
1940
+ this.draw(false, this.scrollbarOpacity);
1895
1941
 
1896
1942
  if (progress < 1) {
1897
1943
  requestAnimationFrame(animate);
@@ -1913,9 +1959,7 @@ export class Terminal implements ITerminalCore {
1913
1959
  this.scrollbarOpacity = startOpacity * (1 - progress);
1914
1960
 
1915
1961
  // Trigger render to show updated opacity
1916
- if (this.renderer && this.wasmTerm) {
1917
- this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
1918
- }
1962
+ this.draw(false, this.scrollbarOpacity);
1919
1963
 
1920
1964
  if (progress < 1) {
1921
1965
  requestAnimationFrame(animate);
@@ -1923,9 +1967,7 @@ export class Terminal implements ITerminalCore {
1923
1967
  this.scrollbarVisible = false;
1924
1968
  this.scrollbarOpacity = 0;
1925
1969
  // Final render to clear scrollbar completely
1926
- if (this.renderer && this.wasmTerm) {
1927
- this.renderer.render(this.wasmTerm, false, this.viewportY, this, 0);
1928
- }
1970
+ this.draw(false, 0);
1929
1971
  }
1930
1972
  };
1931
1973
  animate();
@@ -1973,6 +2015,8 @@ export class Terminal implements ITerminalCore {
1973
2015
  if (!this.wasmTerm) return undefined;
1974
2016
  const y = this.wasmTerm.cursorPosition().y + cursorYOffset;
1975
2017
  if (y < 0 || y >= this.rows) return undefined;
2018
+ // Starts watching the active screen for discarded rows before the marker exists.
2019
+ if (this.wasmTerm.rowsDiscarded()) this.refreshMarkers(true);
1976
2020
  const row = this.wasmTerm.trackRow(y);
1977
2021
  if (!row) return undefined;
1978
2022
  const marker = new Marker(row);
@@ -2065,8 +2109,10 @@ export class Terminal implements ITerminalCore {
2065
2109
  if (alternate !== this.lastAlternate) {
2066
2110
  this.lastAlternate = alternate;
2067
2111
  (this.buffer as BufferNamespace)._fireBufferChange(this.buffer.active);
2112
+ this.refreshMarkers(true);
2113
+ } else {
2114
+ this.refreshMarkers(false);
2068
2115
  }
2069
- for (const marker of [...this.markers]) marker.refresh();
2070
2116
  this.writeParsedEmitter.fire();
2071
2117
  this.wake();
2072
2118
  }
@@ -2088,6 +2134,13 @@ export class Terminal implements ITerminalCore {
2088
2134
  this.scrollEmitter.fire(this.buffer.active.viewportY);
2089
2135
  }
2090
2136
 
2137
+ /** Disposes the markers whose line is gone; only rows the engine discarded can be. */
2138
+ private refreshMarkers(force: boolean): void {
2139
+ if (this.markers.size === 0 || !this.wasmTerm) return;
2140
+ if (!this.wasmTerm.rowsDiscarded() && !force) return;
2141
+ for (const marker of [...this.markers]) marker.refresh();
2142
+ }
2143
+
2091
2144
  private disposeMarkers(): void {
2092
2145
  for (const marker of [...this.markers]) marker.dispose();
2093
2146
  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.1",
3
+ "version": "0.3.3",
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",