@alchemy.run/sigil 0.0.0-alpha.6 → 0.0.0-alpha.8

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 (37) hide show
  1. package/README.md +1 -1
  2. package/dist/{Text-BobFKi74.d.ts → Text-DV9CuzAT.d.ts} +1 -1
  3. package/dist/ansi.d.ts +1 -1
  4. package/dist/ansi.js +2 -2
  5. package/dist/capabilities.d.ts +4 -4
  6. package/dist/capabilities.js +2 -2
  7. package/dist/{color-policy-BMzMwV7Q.d.ts → color-policy-CCxuHIdD.d.ts} +2 -2
  8. package/dist/{color-policy-SVj1pYTA.js → color-policy-DlrZXC0f.js} +31 -13
  9. package/dist/{color-profile-u0Nhe9Nv.d.ts → color-profile-CyeHnG1T.d.ts} +1 -1
  10. package/dist/color.d.ts +2 -2
  11. package/dist/{detect-BuTXtY6e.js → detect-B3dL4Q11.js} +3 -2
  12. package/dist/{detect-Bh4yGP6w.d.ts → detect-Db6GbKOm.d.ts} +9 -0
  13. package/dist/{index-DZ88EXJv.d.ts → index-D48vQhhe.d.ts} +2 -2
  14. package/dist/index.d.ts +10 -6
  15. package/dist/index.js +68 -60
  16. package/dist/{osc-CCH7xDoS.js → osc-BFKKSqpg.js} +1 -1
  17. package/dist/{paint-C19minOS.d.ts → paint-Cx-zC_sX.d.ts} +2 -2
  18. package/dist/{query-vaIeGOkH.d.ts → query-BNc2B8GD.d.ts} +1 -1
  19. package/dist/router.d.ts +1 -1
  20. package/dist/router.js +1 -1
  21. package/dist/{screen-BOSLQ8fF.d.ts → screen-CgC2WlVM.d.ts} +1 -1
  22. package/dist/screen.d.ts +3 -3
  23. package/dist/{session-aZr9O8h3.js → session-Cg6STjFV.js} +42 -7
  24. package/dist/{store-CgrG9K4y.d.ts → store-C1P5fOUi.d.ts} +1 -1
  25. package/dist/terminal.d.ts +8 -5
  26. package/dist/terminal.js +1 -1
  27. package/dist/{use-focus-BzqAJi0n.js → use-focus-BNG0xsb7.js} +1 -1
  28. package/package.json +1 -1
  29. package/src/capabilities/detect.ts +15 -1
  30. package/src/capabilities/store.ts +17 -2
  31. package/src/components/App.tsx +10 -1
  32. package/src/hooks/use-window-size.ts +8 -19
  33. package/src/ink.tsx +93 -23
  34. package/src/terminal/screen-presenter.ts +58 -4
  35. package/src/terminal/session.ts +3 -2
  36. package/src/testing/terminal.ts +15 -3
  37. package/src/utils.ts +0 -40
@@ -1,7 +1,7 @@
1
1
  import { G as pasteEnd, K as pasteStart, S as ansiEscapes } from "./sgr-BhwaWAJB.js";
2
- import { a as setPointerShape, c as setWorkingDirectory, i as setClipboard, l as tmuxPassthrough, n as notify, o as setTerminalProgress, s as setWindowTitle, u as cliCursor } from "./osc-CCH7xDoS.js";
3
- import { i as getCapabilities, t as colorState } from "./color-policy-SVj1pYTA.js";
4
- import { n as cellsEqual } from "./cell-_ZVhbfl0.js";
2
+ import { a as setPointerShape, c as setWorkingDirectory, i as setClipboard, l as tmuxPassthrough, n as notify, o as setTerminalProgress, s as setWindowTitle, u as cliCursor } from "./osc-BFKKSqpg.js";
3
+ import { i as getCapabilities, t as colorState } from "./color-policy-DlrZXC0f.js";
4
+ import { n as cellsEqual, t as cellAttributes } from "./cell-_ZVhbfl0.js";
5
5
  import { n as serializeScreen, t as serializeLine } from "./serialize-BTkAZgw1.js";
6
6
  //#region src/cursor-position.ts
7
7
  const showCursorEscape = ansiEscapes.cursorShow;
@@ -322,13 +322,29 @@ var ScreenPresenter = class {
322
322
  willPresent(screen, cursor, fullscreen = false, forceRewrite = false) {
323
323
  return forceRewrite || this.#screen === void 0 || !screensEqual(this.#screen, screen) || cursorPositionChanged(cursor, this.#cursor) || fullscreen !== this.#fullscreen;
324
324
  }
325
- clear() {
326
- this.#write(buildReturnToBottomPrefix(this.#cursorWasShown, this.#lineCount, this.#cursor) + ansiEscapes.eraseLines(this.#lineCount));
325
+ /**
326
+ Erases the presented frame from the cursor upward.
327
+
328
+ `columns` is the terminal's current width. When it is narrower than the width
329
+ the frame was painted at, a reflowing emulator (xterm.js, Ghostty, kitty,
330
+ iTerm2, WezTerm, tmux, …) has already rewrapped every wider row onto several
331
+ physical rows before the resize event reaches us, so the erase has to cover
332
+ that physical footprint. Erasing only the logical row count leaves the frame's
333
+ top rows behind as ghosts, one more batch per resize event.
334
+ */
335
+ clear(options = {}) {
336
+ this.#write(buildReturnToBottomPrefix(this.#cursorWasShown, this.#lineCount, this.#cursor) + ansiEscapes.eraseLines(this.#physicalLineCount(options.columns)));
327
337
  this.#lineCount = 0;
328
338
  this.#cursor = void 0;
329
339
  this.#cursorWasShown = false;
330
340
  this.#fullscreen = false;
331
341
  }
342
+ #physicalLineCount(columns) {
343
+ if (this.#screen === void 0 || this.#lineCount === 0 || columns === void 0 || columns < 1) return this.#lineCount;
344
+ let rows = 0;
345
+ for (const line of this.#screen.toRows()) rows += Math.max(1, Math.ceil(contentWidth(line) / columns));
346
+ return rows + (this.#fullscreen ? 0 : 1);
347
+ }
332
348
  reset() {
333
349
  this.#screen = void 0;
334
350
  this.#lineCount = 0;
@@ -353,6 +369,24 @@ var ScreenPresenter = class {
353
369
  this.#fullscreen = fullscreen;
354
370
  }
355
371
  };
372
+ /**
373
+ The columns a row occupies once written: everything up to its last visible
374
+ cell. Trailing unstyled blanks are trimmed by the serializer and never reach
375
+ the terminal, while styled blanks (a highlighted tab's padding) do.
376
+ */
377
+ function contentWidth(line) {
378
+ let width = 0;
379
+ let end = 0;
380
+ for (const cell of line) {
381
+ width += cell.width;
382
+ if (!isBlank(cell)) end = width;
383
+ }
384
+ return end;
385
+ }
386
+ function isBlank(cell) {
387
+ const { style } = cell;
388
+ return (cell.grapheme === " " || cell.width === 0) && style.foreground === void 0 && style.background === void 0 && style.underlineColor === void 0 && style.underline === "none" && style.attributes === cellAttributes.none && cell.hyperlink === void 0;
389
+ }
356
390
  function findFirstChangedRow(previous, next) {
357
391
  const height = Math.max(previous.height, next.height);
358
392
  for (let y = 0; y < height; y++) if (!rowsEqual(previous, next, y)) return y;
@@ -437,8 +471,9 @@ var TerminalSession = class {
437
471
  willPresent(screen, options = {}) {
438
472
  return this.#presenter.willPresent(screen, this.cursor.position, options.fullscreen, options.forceRewrite);
439
473
  }
440
- clearFrame() {
441
- this.#presenter.clear();
474
+ /** Erases the presented frame; pass the current `columns` after a resize so the erase covers rows the emulator rewrapped. */
475
+ clearFrame(options = {}) {
476
+ this.#presenter.clear(options);
442
477
  }
443
478
  resetFrame() {
444
479
  this.#presenter.reset();
@@ -1,4 +1,4 @@
1
- import { t as Capabilities } from "./detect-Bh4yGP6w.js";
1
+ import { t as Capabilities } from "./detect-Db6GbKOm.js";
2
2
  //#region src/stream.d.ts
3
3
  type OutputStream = NodeJS.WritableStream & {
4
4
  isTTY?: boolean;
@@ -1,9 +1,9 @@
1
1
  import { a as CursorShape } from "./escapes-CB_6CWOE.js";
2
2
  import { n as TerminalProgressState, t as ClipboardSelection } from "./osc-Cn0fw77g.js";
3
- import { i as OutputStream, t as CapabilitiesStore } from "./store-CgrG9K4y.js";
4
- import { t as ColorProfile } from "./color-profile-u0Nhe9Nv.js";
5
- import { t as ColorPolicy } from "./color-policy-BMzMwV7Q.js";
6
- import { n as Screen } from "./screen-BOSLQ8fF.js";
3
+ import { i as OutputStream, t as CapabilitiesStore } from "./store-C1P5fOUi.js";
4
+ import { t as ColorProfile } from "./color-profile-CyeHnG1T.js";
5
+ import { t as ColorPolicy } from "./color-policy-CCxuHIdD.js";
6
+ import { n as Screen } from "./screen-CgC2WlVM.js";
7
7
  import { t as CursorPosition } from "./cursor-position-D2LAkRG0.js";
8
8
  //#region src/input-parser.d.ts
9
9
  type InputEvent = string | {
@@ -77,7 +77,10 @@ declare class TerminalSession {
77
77
  readonly fullscreen?: boolean;
78
78
  readonly forceRewrite?: boolean;
79
79
  }): boolean;
80
- clearFrame(): void;
80
+ /** Erases the presented frame; pass the current `columns` after a resize so the erase covers rows the emulator rewrapped. */
81
+ clearFrame(options?: {
82
+ readonly columns?: number;
83
+ }): void;
81
84
  resetFrame(): void;
82
85
  finishFrame(): void;
83
86
  setColorPolicy(policy: ColorPolicy): void;
package/dist/terminal.js CHANGED
@@ -1,2 +1,2 @@
1
- import { n as TerminalInput, r as parseMouseEvent, t as TerminalSession } from "./session-aZr9O8h3.js";
1
+ import { n as TerminalInput, r as parseMouseEvent, t as TerminalSession } from "./session-Cg6STjFV.js";
2
2
  export { TerminalInput, TerminalSession, parseMouseEvent };
@@ -564,7 +564,7 @@ const detectKittySupport = (stdin, stdout, onSupported) => {
564
564
  //#endregion
565
565
  //#region package.json
566
566
  var name = "@alchemy.run/sigil";
567
- var version = "0.0.0-alpha.6";
567
+ var version = "0.0.0-alpha.8";
568
568
  //#endregion
569
569
  //#region src/reconciler.ts
570
570
  if (isSigilDev) await import("./devtools-DbthxoD1.js").catch(() => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alchemy.run/sigil",
3
- "version": "0.0.0-alpha.6",
3
+ "version": "0.0.0-alpha.8",
4
4
  "description": "React for CLIs. A self-contained fork of Ink with an integrated TypeScript Yoga layout engine.",
5
5
  "keywords": [
6
6
  "cli",
@@ -85,8 +85,21 @@ export type Capabilities = {
85
85
  /**
86
86
  Current terminal dimensions in cells, plus pixel geometry once the
87
87
  terminal has answered the query.
88
+
89
+ `source` tells where the cell dimensions come from. `"pty"` is the
90
+ stream's own `columns`/`rows`, updated by the OS on SIGWINCH — which says
91
+ nothing about whether the emulator has finished rewrapping its screen.
92
+ `"terminal"` is the emulator's in-band size report (mode 2048), which
93
+ arrives in the input stream after the rewrap and so describes the screen
94
+ exactly as later output will find it. While the terminal reports, its
95
+ size wins over the stream's.
88
96
  */
89
- size: { columns: number; rows: number; pixels: PixelGeometry | undefined };
97
+ size: {
98
+ columns: number;
99
+ rows: number;
100
+ pixels: PixelGeometry | undefined;
101
+ source: "pty" | "terminal";
102
+ };
90
103
 
91
104
  platform: NodeJS.Platform;
92
105
 
@@ -580,6 +593,7 @@ export function detectCapabilities({ stdout = process.stdout }: DetectOptions =
580
593
  ? { columns: stdout.columns, rows: stdout.rows }
581
594
  : terminalSize()),
582
595
  pixels: undefined,
596
+ source: "pty",
583
597
  },
584
598
  focused: undefined,
585
599
  theme: {
@@ -128,11 +128,17 @@ const createStore = (stdin: StoreStdin, stdout: StoreStdout): CapabilitiesStore
128
128
  let resizeSubscribers = 0;
129
129
 
130
130
  let focused: boolean | undefined;
131
+ // The size from the terminal's latest in-band report (mode 2048). While
132
+ // set it is authoritative: the report is ordered in the input stream after
133
+ // the emulator rewrapped, whereas the stream's `columns`/`rows` only say
134
+ // what the OS told the PTY, before or after the emulator caught up.
135
+ let reportedSize: { columns: number; rows: number } | undefined;
131
136
  let snapshot: Capabilities | undefined;
132
137
  let snapshotQuery: TerminalQueryResult | undefined;
133
138
  let snapshotColumns: number | undefined;
134
139
  let snapshotRows: number | undefined;
135
140
  let snapshotFocused: boolean | undefined;
141
+ let snapshotReportedSize: typeof reportedSize;
136
142
 
137
143
  const current = (): Capabilities => {
138
144
  const query = getTerminalQuery(stdout);
@@ -142,15 +148,20 @@ const createStore = (stdin: StoreStdin, stdout: StoreStdout): CapabilitiesStore
142
148
  query !== snapshotQuery ||
143
149
  columns !== snapshotColumns ||
144
150
  rows !== snapshotRows ||
145
- focused !== snapshotFocused
151
+ focused !== snapshotFocused ||
152
+ reportedSize !== snapshotReportedSize
146
153
  ) {
147
154
  const detected = detectCapabilities({ stdout });
148
155
  const applied = query ? applyTerminalQuery(detected, query) : detected;
149
- snapshot = focused === undefined ? applied : { ...applied, focused };
156
+ const sized = reportedSize
157
+ ? { ...applied, size: { ...applied.size, ...reportedSize, source: "terminal" as const } }
158
+ : applied;
159
+ snapshot = focused === undefined ? sized : { ...sized, focused };
150
160
  snapshotQuery = query;
151
161
  snapshotColumns = columns;
152
162
  snapshotRows = rows;
153
163
  snapshotFocused = focused;
164
+ snapshotReportedSize = reportedSize;
154
165
  }
155
166
 
156
167
  return snapshot;
@@ -217,6 +228,9 @@ const createStore = (stdin: StoreStdin, stdout: StoreStdout): CapabilitiesStore
217
228
  integration?.setReportFeed?.(false);
218
229
  removeExitHandler?.();
219
230
  removeExitHandler = undefined;
231
+ // No further reports will arrive; the stream is the only size source
232
+ // again and the last report would go stale on the next resize.
233
+ reportedSize = undefined;
220
234
  }
221
235
  };
222
236
 
@@ -255,6 +269,7 @@ const createStore = (stdin: StoreStdin, stdout: StoreStdout): CapabilitiesStore
255
269
  if (resize) {
256
270
  const [, rows, columns, pixelHeight, pixelWidth] = resize.map(Number);
257
271
  if (columns && rows) {
272
+ reportedSize = { columns, rows };
258
273
  patchTerminalQuery(stdout, {
259
274
  textAreaPixels:
260
275
  pixelWidth && pixelHeight ? { width: pixelWidth, height: pixelHeight } : undefined,
@@ -109,6 +109,7 @@ export function App({
109
109
  internal_eventEmitter.current.setMaxListeners(Infinity);
110
110
  // Store the currently attached readable listener to avoid stale closure issues
111
111
  const readableListenerRef = useRef<(() => void) | undefined>(undefined);
112
+ const isMountedRef = useRef(true);
112
113
  const pendingInputFlushRef = useRef<NodeJS.Timeout | undefined>(undefined);
113
114
  // Small delay to let chunked escape sequences complete before flushing as literal input.
114
115
  const pendingInputFlushDelayMilliseconds = 20;
@@ -417,7 +418,12 @@ export function App({
417
418
  ? refreshTerminalQuery(stdin, stdout)
418
419
  : ensureTerminalQuery(stdin, stdout));
419
420
  } finally {
420
- attachReadableListener();
421
+ // A slow terminal response may arrive after this renderer has been
422
+ // replaced. Do not leave its reader attached to consume input meant
423
+ // for a subsequent renderer.
424
+ if (isMountedRef.current) {
425
+ attachReadableListener();
426
+ }
421
427
  handleSetRawMode(false);
422
428
  }
423
429
  },
@@ -741,7 +747,10 @@ export function App({
741
747
 
742
748
  // Handle cursor visibility, raw mode, and bracketed paste mode cleanup on unmount
743
749
  useEffect(() => {
750
+ isMountedRef.current = true;
751
+
744
752
  return () => {
753
+ isMountedRef.current = false;
745
754
  const canWriteToStdout = !stdout.destroyed && !stdout.writableEnded;
746
755
 
747
756
  if (interactive && canWriteToStdout) {
@@ -1,7 +1,6 @@
1
- import { useState, useEffect } from "react";
1
+ import { useMemo } from "react";
2
2
 
3
- import { useStdout } from "#/hooks/use-stdout.ts";
4
- import { getWindowSize } from "#/utils.ts";
3
+ import { useCapabilities } from "#/hooks/use-capabilities.ts";
5
4
 
6
5
  /**
7
6
  Dimensions of the terminal window.
@@ -20,22 +19,12 @@ export type WindowSize = {
20
19
 
21
20
  /**
22
21
  A React hook that returns the current terminal window dimensions and re-renders the component whenever the terminal is resized.
22
+
23
+ Reads the capabilities store, so on terminals that send in-band size reports
24
+ (mode 2048) the dimensions are the emulator's own, arriving after it has
25
+ rewrapped its screen; elsewhere they are the stream's `columns`/`rows`.
23
26
  */
24
27
  export const useWindowSize = (): WindowSize => {
25
- const { stdout } = useStdout();
26
- const [size, setSize] = useState<WindowSize>(() => getWindowSize(stdout));
27
-
28
- useEffect(() => {
29
- const onResize = () => {
30
- setSize(getWindowSize(stdout));
31
- };
32
-
33
- stdout.on("resize", onResize);
34
-
35
- return () => {
36
- stdout.off("resize", onResize);
37
- };
38
- }, [stdout]);
39
-
40
- return size;
28
+ const { size } = useCapabilities();
29
+ return useMemo(() => ({ columns: size.columns, rows: size.rows }), [size.columns, size.rows]);
41
30
  };
package/src/ink.tsx CHANGED
@@ -33,7 +33,6 @@ import { createInlinePresenter } from "#/terminal/inline-presenter.ts";
33
33
  import { createRenderScheduler } from "#/terminal/render-scheduler.ts";
34
34
  import { TerminalSession } from "#/terminal/session.ts";
35
35
  import { type Throttled } from "#/throttle.ts";
36
- import { getWindowSize } from "#/utils.ts";
37
36
  import { Yoga } from "#/yoga/index.ts";
38
37
 
39
38
  const noop = () => {};
@@ -64,6 +63,11 @@ function bottomRows(screen: Screen, height: number): Screen {
64
63
  return cropped;
65
64
  }
66
65
 
66
+ // How long the reported terminal size must hold still before a resize is
67
+ // acted on. Window drags deliver an event per frame (~16 ms), and the
68
+ // emulator needs a frame or so to agree with the PTY size either way.
69
+ const RESIZE_SETTLE_MS = 50;
70
+
67
71
  const shouldClearTerminalForFrame = ({
68
72
  isTTY,
69
73
  viewportRows,
@@ -381,8 +385,20 @@ export const createInk = (options: Options): Ink => {
381
385
  let lastOutputToRender = "";
382
386
  let lastOutputHeight = 0;
383
387
  let lastScreen: Screen | undefined;
384
- let lastTerminalWidth = getWindowSize(options.stdout).columns;
385
- let lastTerminalHeight = getWindowSize(options.stdout).rows;
388
+ // The terminal size comes from the capabilities store: the stream's
389
+ // `columns`/`rows`, or — on terminals that send in-band size reports —
390
+ // the emulator's own figure, which is the one later output will meet.
391
+ const windowSize = () => capabilitiesStore.current.size;
392
+ let lastTerminalWidth = windowSize().columns;
393
+ let lastTerminalHeight = windowSize().rows;
394
+ // Resize events arrive in bursts (one per frame of a window drag), and the
395
+ // emulator's rewrap and the PTY size never update atomically — depending on
396
+ // the terminal app the PTY is resized before or after the screen is
397
+ // rewrapped. Anything written mid-burst lands on a screen whose width is
398
+ // not the reported one, and the rows that leaves behind can never be
399
+ // accounted for by a later erase. Events therefore only arm this settle
400
+ // timer; the frame is erased and repainted once the size has held still.
401
+ let resizeSettle: ReturnType<typeof setTimeout> | undefined;
386
402
 
387
403
  // This variable is used only in debug mode to store full static output
388
404
  // so that it's rerendered every time, not just new static parts, like in non-debug mode
@@ -431,11 +447,17 @@ export const createInk = (options: Options): Ink => {
431
447
  if (options.patchConsole) installConsolePatch();
432
448
 
433
449
  if (interactive) {
434
- options.stdout.on("resize", resized);
435
-
436
- unsubscribeResize = () => {
437
- options.stdout.off("resize", resized);
438
- };
450
+ // Follow the store rather than the stream's `resize` event: the store
451
+ // folds in the terminal's in-band size reports where available, and
452
+ // says which source a size came from.
453
+ let seenColumns = lastTerminalWidth;
454
+ let seenRows = lastTerminalHeight;
455
+ unsubscribeResize = capabilitiesStore.subscribe(({ size }) => {
456
+ if (size.columns === seenColumns && size.rows === seenRows) return;
457
+ seenColumns = size.columns;
458
+ seenRows = size.rows;
459
+ resized(size.source);
460
+ });
439
461
  }
440
462
 
441
463
  initKittyKeyboard();
@@ -450,9 +472,28 @@ export const createInk = (options: Options): Ink => {
450
472
 
451
473
  void exitPromise.catch(noop);
452
474
 
453
- function resized(): void {
454
- const currentWidth = getWindowSize(options.stdout).columns;
455
- const currentHeight = getWindowSize(options.stdout).rows;
475
+ function resized(source: "pty" | "terminal"): void {
476
+ if (resizeSettle !== undefined) {
477
+ clearTimeout(resizeSettle);
478
+ resizeSettle = undefined;
479
+ }
480
+
481
+ // An in-band report is the emulator's own word, sent after it rewrapped
482
+ // and ordered with everything else in the stream, so there is nothing
483
+ // to wait for. A PTY size may run ahead of or behind the emulator.
484
+ if (source === "terminal") {
485
+ settleResize();
486
+ return;
487
+ }
488
+
489
+ resizeSettle = setTimeout(settleResize, RESIZE_SETTLE_MS);
490
+ }
491
+
492
+ function settleResize(): void {
493
+ resizeSettle = undefined;
494
+ if (isUnmounted || isUnmounting) return;
495
+
496
+ const { columns: currentWidth, rows: currentHeight } = windowSize();
456
497
 
457
498
  // A width decrease rewraps lines and any height change moves content
458
499
  // through scrollback, so the incremental render state no longer
@@ -460,11 +501,15 @@ export const createInk = (options: Options): Ink => {
460
501
  // render to be a full rewrite instead of an incremental diff that
461
502
  // would skip "unchanged" lines over stale screen content.
462
503
  if (currentWidth < lastTerminalWidth || currentHeight !== lastTerminalHeight) {
463
- // Clearing erases the full previous frame line count from
464
- // the cursor upward — after a height grow that also covers frame
465
- // lines the emulator pulled back from scrollback, so no extra
466
- // erase is needed for them.
467
- clearLiveOutput();
504
+ // Clearing erases the full previous frame from the cursor upward —
505
+ // after a height grow that also covers frame lines the emulator
506
+ // pulled back from scrollback, so no extra erase is needed for them.
507
+ // The current width lets the presenter count the rows a reflowing
508
+ // emulator has already rewrapped after a width shrink; the logical
509
+ // line count alone under-erases and leaves the frame's top rows
510
+ // behind. Terminals that never rewrap keep the logical count, since
511
+ // the rewrap-aware one would erase rows above the frame there.
512
+ clearLiveOutput(rewrapsOnResize() ? currentWidth : undefined);
468
513
  resetLiveOutput();
469
514
  lastOutput = "";
470
515
  lastOutputToRender = "";
@@ -475,12 +520,20 @@ export const createInk = (options: Options): Ink => {
475
520
  lastOutputHeight = 0;
476
521
  }
477
522
 
523
+ lastTerminalWidth = currentWidth;
524
+ lastTerminalHeight = currentHeight;
525
+
478
526
  calculateLayout();
479
527
  dom.emitLayoutListeners(rootNode);
480
528
  onRender();
529
+ }
481
530
 
482
- lastTerminalWidth = currentWidth;
483
- lastTerminalHeight = currentHeight;
531
+ // Whether the terminal rewraps existing screen rows when its width
532
+ // changes. Nearly every modern emulator does (xterm.js, Ghostty, kitty,
533
+ // iTerm2, WezTerm, Alacritty, Windows Terminal, tmux); Apple's Terminal.app
534
+ // and the classic Windows console keep rows as they were written.
535
+ function rewrapsOnResize(): boolean {
536
+ return !isWindows && capabilitiesStore.current.terminal.name !== "apple-terminal";
484
537
  }
485
538
 
486
539
  function handleAppExit(errorOrResult?: unknown): void {
@@ -519,9 +572,9 @@ export const createInk = (options: Options): Ink => {
519
572
  }
520
573
  }
521
574
 
522
- function clearLiveOutput(): void {
575
+ function clearLiveOutput(columns?: number): void {
523
576
  if (isScreenReaderEnabled) accessiblePresenter!.clear();
524
- else terminal.clearFrame();
577
+ else terminal.clearFrame({ columns });
525
578
  }
526
579
 
527
580
  function finishLiveOutput(): void {
@@ -535,7 +588,7 @@ export const createInk = (options: Options): Ink => {
535
588
  }
536
589
 
537
590
  function calculateLayout(): void {
538
- const terminalWidth = getWindowSize(options.stdout).columns;
591
+ const terminalWidth = windowSize().columns;
539
592
 
540
593
  rootNode.yogaNode!.setWidth(terminalWidth);
541
594
 
@@ -566,6 +619,18 @@ export const createInk = (options: Options): Ink => {
566
619
  return;
567
620
  }
568
621
 
622
+ // A resize burst is still settling: the emulator is rewrapping the screen
623
+ // under us, and the frame will be erased and repainted as a whole once it
624
+ // holds still. Writing now would leave rows no later erase can find.
625
+ if (resizeSettle !== undefined && !isUnmounting) {
626
+ if (nextRenderCommit) {
627
+ nextRenderCommit.resolve();
628
+ nextRenderCommit = undefined;
629
+ }
630
+
631
+ return;
632
+ }
633
+
569
634
  if (nextRenderCommit) {
570
635
  nextRenderCommit.resolve();
571
636
  nextRenderCommit = undefined;
@@ -642,7 +707,7 @@ export const createInk = (options: Options): Ink => {
642
707
  return;
643
708
  }
644
709
 
645
- const terminalWidth = getWindowSize(options.stdout).columns;
710
+ const terminalWidth = windowSize().columns;
646
711
 
647
712
  const wrappedOutput = wrapAnsi(output, terminalWidth, {
648
713
  trim: false,
@@ -796,6 +861,11 @@ export const createInk = (options: Options): Ink => {
796
861
 
797
862
  isUnmounting = true;
798
863
 
864
+ if (resizeSettle !== undefined) {
865
+ clearTimeout(resizeSettle);
866
+ resizeSettle = undefined;
867
+ }
868
+
799
869
  unsubscribeBeforeExit?.();
800
870
  unsubscribeBeforeExit = undefined;
801
871
 
@@ -1163,7 +1233,7 @@ export const createInk = (options: Options): Ink => {
1163
1233
 
1164
1234
  // Detect fullscreen: output fills or exceeds terminal height.
1165
1235
  // Only apply when writing to a real TTY — piped output always gets trailing newlines.
1166
- const viewportRows = isTTY ? getWindowSize(options.stdout).rows : 24;
1236
+ const viewportRows = isTTY ? windowSize().rows : 24;
1167
1237
 
1168
1238
  // Clamp the frame to the viewport, keeping its bottom rows. Rows above
1169
1239
  // the top margin cannot be updated or erased in place, and the
@@ -7,9 +7,9 @@ import {
7
7
  cursorPositionChanged,
8
8
  type CursorPosition,
9
9
  } from "#/cursor-position.ts";
10
- import { cellsEqual } from "#/screen/cell.ts";
10
+ import { cellAttributes, cellsEqual, type Cell } from "#/screen/cell.ts";
11
11
  import type { ColorProfile } from "#/screen/color-profile.ts";
12
- import type { Screen } from "#/screen/screen.ts";
12
+ import type { Line, Screen } from "#/screen/screen.ts";
13
13
  import { serializeLine, serializeScreen } from "#/screen/serialize.ts";
14
14
 
15
15
  type Write = (data: string) => boolean;
@@ -123,10 +123,20 @@ export class ScreenPresenter {
123
123
  );
124
124
  }
125
125
 
126
- clear(): void {
126
+ /**
127
+ Erases the presented frame from the cursor upward.
128
+
129
+ `columns` is the terminal's current width. When it is narrower than the width
130
+ the frame was painted at, a reflowing emulator (xterm.js, Ghostty, kitty,
131
+ iTerm2, WezTerm, tmux, …) has already rewrapped every wider row onto several
132
+ physical rows before the resize event reaches us, so the erase has to cover
133
+ that physical footprint. Erasing only the logical row count leaves the frame's
134
+ top rows behind as ghosts, one more batch per resize event.
135
+ */
136
+ clear(options: { readonly columns?: number } = {}): void {
127
137
  this.#write(
128
138
  buildReturnToBottomPrefix(this.#cursorWasShown, this.#lineCount, this.#cursor) +
129
- ansiEscapes.eraseLines(this.#lineCount),
139
+ ansiEscapes.eraseLines(this.#physicalLineCount(options.columns)),
130
140
  );
131
141
  this.#lineCount = 0;
132
142
  this.#cursor = undefined;
@@ -134,6 +144,22 @@ export class ScreenPresenter {
134
144
  this.#fullscreen = false;
135
145
  }
136
146
 
147
+ #physicalLineCount(columns: number | undefined): number {
148
+ if (
149
+ this.#screen === undefined ||
150
+ this.#lineCount === 0 ||
151
+ columns === undefined ||
152
+ columns < 1
153
+ ) {
154
+ return this.#lineCount;
155
+ }
156
+ let rows = 0;
157
+ for (const line of this.#screen.toRows()) {
158
+ rows += Math.max(1, Math.ceil(contentWidth(line) / columns));
159
+ }
160
+ return rows + (this.#fullscreen ? 0 : 1);
161
+ }
162
+
137
163
  reset(): void {
138
164
  this.#screen = undefined;
139
165
  this.#lineCount = 0;
@@ -162,6 +188,34 @@ export class ScreenPresenter {
162
188
  }
163
189
  }
164
190
 
191
+ /**
192
+ The columns a row occupies once written: everything up to its last visible
193
+ cell. Trailing unstyled blanks are trimmed by the serializer and never reach
194
+ the terminal, while styled blanks (a highlighted tab's padding) do.
195
+ */
196
+ function contentWidth(line: Line): number {
197
+ let width = 0;
198
+ let end = 0;
199
+ for (const cell of line) {
200
+ width += cell.width;
201
+ if (!isBlank(cell)) end = width;
202
+ }
203
+ return end;
204
+ }
205
+
206
+ function isBlank(cell: Cell): boolean {
207
+ const { style } = cell;
208
+ return (
209
+ (cell.grapheme === " " || cell.width === 0) &&
210
+ style.foreground === undefined &&
211
+ style.background === undefined &&
212
+ style.underlineColor === undefined &&
213
+ style.underline === "none" &&
214
+ style.attributes === cellAttributes.none &&
215
+ cell.hyperlink === undefined
216
+ );
217
+ }
218
+
165
219
  function findFirstChangedRow(previous: Screen, next: Screen): number | undefined {
166
220
  const height = Math.max(previous.height, next.height);
167
221
  for (let y = 0; y < height; y++) {
@@ -140,8 +140,9 @@ export class TerminalSession {
140
140
  );
141
141
  }
142
142
 
143
- clearFrame(): void {
144
- this.#presenter.clear();
143
+ /** Erases the presented frame; pass the current `columns` after a resize so the erase covers rows the emulator rewrapped. */
144
+ clearFrame(options: { readonly columns?: number } = {}): void {
145
+ this.#presenter.clear(options);
145
146
  }
146
147
 
147
148
  resetFrame(): void {
@@ -100,7 +100,13 @@ export type TerminalApp = {
100
100
  */
101
101
  type: (text: string) => void;
102
102
 
103
- resize: (columns: number, rows: number) => void;
103
+ /**
104
+ Resizes the PTY and the emulator. Real terminal apps never do both at
105
+ once: `emulatorLag` (ms) delays the emulator's rewrap behind the PTY
106
+ resize, simulating an app that reports the new size to the process
107
+ before its screen has been rewrapped.
108
+ */
109
+ resize: (columns: number, rows: number, options?: { readonly emulatorLag?: number }) => void;
104
110
 
105
111
  /**
106
112
  Flips the emulated OS color scheme (Ghostty engine only) — the app
@@ -306,9 +312,15 @@ export const launchTerminal = async (
306
312
  type: (text) => {
307
313
  child.write(text);
308
314
  },
309
- resize: (nextColumns, nextRows) => {
315
+ resize: (nextColumns, nextRows, options = {}) => {
310
316
  child.resize(nextColumns, nextRows);
311
- emulator.resize(nextColumns, nextRows);
317
+ if (options.emulatorLag === undefined) {
318
+ emulator.resize(nextColumns, nextRows);
319
+ return;
320
+ }
321
+ setTimeout(() => {
322
+ if (!closed) emulator.resize(nextColumns, nextRows);
323
+ }, options.emulatorLag);
312
324
  },
313
325
  setColorScheme: (scheme) => {
314
326
  if (!emulator.setColorScheme) {