@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.
- package/README.md +1 -1
- package/dist/{Text-BobFKi74.d.ts → Text-DV9CuzAT.d.ts} +1 -1
- package/dist/ansi.d.ts +1 -1
- package/dist/ansi.js +2 -2
- package/dist/capabilities.d.ts +4 -4
- package/dist/capabilities.js +2 -2
- package/dist/{color-policy-BMzMwV7Q.d.ts → color-policy-CCxuHIdD.d.ts} +2 -2
- package/dist/{color-policy-SVj1pYTA.js → color-policy-DlrZXC0f.js} +31 -13
- package/dist/{color-profile-u0Nhe9Nv.d.ts → color-profile-CyeHnG1T.d.ts} +1 -1
- package/dist/color.d.ts +2 -2
- package/dist/{detect-BuTXtY6e.js → detect-B3dL4Q11.js} +3 -2
- package/dist/{detect-Bh4yGP6w.d.ts → detect-Db6GbKOm.d.ts} +9 -0
- package/dist/{index-DZ88EXJv.d.ts → index-D48vQhhe.d.ts} +2 -2
- package/dist/index.d.ts +10 -6
- package/dist/index.js +68 -60
- package/dist/{osc-CCH7xDoS.js → osc-BFKKSqpg.js} +1 -1
- package/dist/{paint-C19minOS.d.ts → paint-Cx-zC_sX.d.ts} +2 -2
- package/dist/{query-vaIeGOkH.d.ts → query-BNc2B8GD.d.ts} +1 -1
- package/dist/router.d.ts +1 -1
- package/dist/router.js +1 -1
- package/dist/{screen-BOSLQ8fF.d.ts → screen-CgC2WlVM.d.ts} +1 -1
- package/dist/screen.d.ts +3 -3
- package/dist/{session-aZr9O8h3.js → session-Cg6STjFV.js} +42 -7
- package/dist/{store-CgrG9K4y.d.ts → store-C1P5fOUi.d.ts} +1 -1
- package/dist/terminal.d.ts +8 -5
- package/dist/terminal.js +1 -1
- package/dist/{use-focus-BzqAJi0n.js → use-focus-BNG0xsb7.js} +1 -1
- package/package.json +1 -1
- package/src/capabilities/detect.ts +15 -1
- package/src/capabilities/store.ts +17 -2
- package/src/components/App.tsx +10 -1
- package/src/hooks/use-window-size.ts +8 -19
- package/src/ink.tsx +93 -23
- package/src/terminal/screen-presenter.ts +58 -4
- package/src/terminal/session.ts +3 -2
- package/src/testing/terminal.ts +15 -3
- 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-
|
|
3
|
-
import { i as getCapabilities, t as colorState } from "./color-policy-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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();
|
package/dist/terminal.d.ts
CHANGED
|
@@ -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-
|
|
4
|
-
import { t as ColorProfile } from "./color-profile-
|
|
5
|
-
import { t as ColorPolicy } from "./color-policy-
|
|
6
|
-
import { n as Screen } from "./screen-
|
|
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
|
-
|
|
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-
|
|
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.
|
|
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
|
@@ -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: {
|
|
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
|
-
|
|
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,
|
package/src/components/App.tsx
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
1
|
+
import { useMemo } from "react";
|
|
2
2
|
|
|
3
|
-
import {
|
|
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 {
|
|
26
|
-
|
|
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
|
-
|
|
385
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
455
|
-
|
|
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
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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 =
|
|
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 =
|
|
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 ?
|
|
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
|
-
|
|
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.#
|
|
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++) {
|
package/src/terminal/session.ts
CHANGED
|
@@ -140,8 +140,9 @@ export class TerminalSession {
|
|
|
140
140
|
);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
|
|
144
|
-
|
|
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 {
|
package/src/testing/terminal.ts
CHANGED
|
@@ -100,7 +100,13 @@ export type TerminalApp = {
|
|
|
100
100
|
*/
|
|
101
101
|
type: (text: string) => void;
|
|
102
102
|
|
|
103
|
-
|
|
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
|
-
|
|
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) {
|