@code-yeongyu/senpi-tui 2026.10.18-1 → 2026.10.19-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/autocomplete.js +2 -1
- package/dist/escape-window.d.ts +10 -0
- package/dist/escape-window.js +12 -0
- package/dist/kitty-graphics-probe.d.ts +21 -0
- package/dist/kitty-graphics-probe.js +37 -0
- package/dist/stdin-buffer.js +7 -2
- package/dist/terminal-capabilities.d.ts +4 -0
- package/dist/terminal-capabilities.js +4 -1
- package/dist/terminal-image.d.ts +3 -1
- package/dist/terminal-image.js +39 -5
- package/dist/terminal.d.ts +3 -2
- package/dist/terminal.js +8 -6
- package/dist/tui-main-screen.d.ts +8 -0
- package/dist/tui-main-screen.js +37 -2
- package/dist/tui.d.ts +20 -5
- package/dist/tui.js +64 -49
- package/dist/viewport-insert-scroll.d.ts +22 -0
- package/dist/viewport-insert-scroll.js +60 -0
- package/dist/viewport-shift.d.ts +24 -0
- package/dist/viewport-shift.js +65 -0
- package/dist/wezterm-windows-images.d.ts +17 -0
- package/dist/wezterm-windows-images.js +26 -0
- package/dist/wsl-session.d.ts +3 -0
- package/dist/wsl-session.js +18 -0
- package/package.json +1 -1
package/dist/autocomplete.js
CHANGED
|
@@ -168,7 +168,8 @@ async function walkDirectoryWithFd(baseDir, fdPath, query, maxResults, signal, m
|
|
|
168
168
|
resolve(results);
|
|
169
169
|
};
|
|
170
170
|
const onAbort = () => {
|
|
171
|
-
|
|
171
|
+
// A child that never started has no pid; on Node < 24.22 / 26.10 its kill() signals our own process group.
|
|
172
|
+
if (child.pid !== undefined && child.exitCode === null) {
|
|
172
173
|
child.kill("SIGKILL");
|
|
173
174
|
}
|
|
174
175
|
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pending lone ESC whose next input is delivered after the escape window is
|
|
3
|
+
* released as a standalone Escape press before the new input is processed. The
|
|
4
|
+
* decision uses the arrival-clock gap observed at delivery time, so an
|
|
5
|
+
* over-window gap is always treated as a lone Escape regardless of whether the
|
|
6
|
+
* flush timer callback ran first (#3628). Within the window the pair still
|
|
7
|
+
* merges into one Alt-key sequence.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isStaleEscape(pending: string, gapMs: number | undefined, escapeWindowMs: number): boolean;
|
|
10
|
+
//# sourceMappingURL=escape-window.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pending lone ESC whose next input is delivered after the escape window is
|
|
3
|
+
* released as a standalone Escape press before the new input is processed. The
|
|
4
|
+
* decision uses the arrival-clock gap observed at delivery time, so an
|
|
5
|
+
* over-window gap is always treated as a lone Escape regardless of whether the
|
|
6
|
+
* flush timer callback ran first (#3628). Within the window the pair still
|
|
7
|
+
* merges into one Alt-key sequence.
|
|
8
|
+
*/
|
|
9
|
+
export function isStaleEscape(pending, gapMs, escapeWindowMs) {
|
|
10
|
+
return pending === "\x1b" && gapMs !== undefined && gapMs >= escapeWindowMs;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=escape-window.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kitty graphics support probe for multiplexer panes whose environment says
|
|
3
|
+
* nothing about image support (herdr; senpi #3698).
|
|
4
|
+
*
|
|
5
|
+
* A 1x1 RGBA direct-transmission query (`a=q`) is written to the terminal; a
|
|
6
|
+
* terminal that implements the Kitty graphics protocol replies with an APC
|
|
7
|
+
* response naming the query's image id. Silence, an error payload, or a reply
|
|
8
|
+
* for another id all mean "no graphics" — the caller must then keep the text
|
|
9
|
+
* fallback instead of emitting graphics sequences blindly.
|
|
10
|
+
*/
|
|
11
|
+
export declare const KITTY_GRAPHICS_PROBE_TIMEOUT_MS = 500;
|
|
12
|
+
/** `AAAA` is base64 for four zero bytes: one 1x1 RGBA pixel. */
|
|
13
|
+
export declare function buildKittyGraphicsQuery(imageId: number): string;
|
|
14
|
+
export type KittyGraphicsProbeOutcome = "ok" | "err";
|
|
15
|
+
/**
|
|
16
|
+
* Classify one APC chunk as the answer to our query. Returns null when the
|
|
17
|
+
* chunk is not a Kitty graphics response for `imageId`, so unrelated APC
|
|
18
|
+
* traffic (graphics commands from other writers) is left untouched.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseKittyGraphicsProbeResponse(data: string, imageId: number): KittyGraphicsProbeOutcome | null;
|
|
21
|
+
//# sourceMappingURL=kitty-graphics-probe.d.ts.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kitty graphics support probe for multiplexer panes whose environment says
|
|
3
|
+
* nothing about image support (herdr; senpi #3698).
|
|
4
|
+
*
|
|
5
|
+
* A 1x1 RGBA direct-transmission query (`a=q`) is written to the terminal; a
|
|
6
|
+
* terminal that implements the Kitty graphics protocol replies with an APC
|
|
7
|
+
* response naming the query's image id. Silence, an error payload, or a reply
|
|
8
|
+
* for another id all mean "no graphics" — the caller must then keep the text
|
|
9
|
+
* fallback instead of emitting graphics sequences blindly.
|
|
10
|
+
*/
|
|
11
|
+
export const KITTY_GRAPHICS_PROBE_TIMEOUT_MS = 500;
|
|
12
|
+
/** `AAAA` is base64 for four zero bytes: one 1x1 RGBA pixel. */
|
|
13
|
+
export function buildKittyGraphicsQuery(imageId) {
|
|
14
|
+
return `\x1b_Gi=${imageId},s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Classify one APC chunk as the answer to our query. Returns null when the
|
|
18
|
+
* chunk is not a Kitty graphics response for `imageId`, so unrelated APC
|
|
19
|
+
* traffic (graphics commands from other writers) is left untouched.
|
|
20
|
+
*/
|
|
21
|
+
export function parseKittyGraphicsProbeResponse(data, imageId) {
|
|
22
|
+
const start = "\x1b_G";
|
|
23
|
+
const end = "\x1b\\";
|
|
24
|
+
if (!data.startsWith(start) || !data.endsWith(end))
|
|
25
|
+
return null;
|
|
26
|
+
const body = data.slice(start.length, -end.length);
|
|
27
|
+
const separator = body.indexOf(";");
|
|
28
|
+
if (separator === -1)
|
|
29
|
+
return null;
|
|
30
|
+
const keys = body.slice(0, separator);
|
|
31
|
+
const message = body.slice(separator + 1);
|
|
32
|
+
const idMatch = /(?:^|,)i=(\d+)(?:,|$)/.exec(keys);
|
|
33
|
+
if (!idMatch || Number.parseInt(idMatch[1] ?? "", 10) !== imageId)
|
|
34
|
+
return null;
|
|
35
|
+
return message.startsWith("OK") ? "ok" : "err";
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=kitty-graphics-probe.js.map
|
package/dist/stdin-buffer.js
CHANGED
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { StringDecoder } from "node:string_decoder";
|
|
20
20
|
import { EventEmitter } from "events";
|
|
21
|
+
import { isStaleEscape } from "./escape-window.js";
|
|
21
22
|
const ESC = "\x1b";
|
|
22
23
|
const DEFAULT_SEQUENCE_TIMEOUT_MS = 50;
|
|
23
|
-
const DEFAULT_ESCAPE_TIMEOUT_MS =
|
|
24
|
+
const DEFAULT_ESCAPE_TIMEOUT_MS = 50;
|
|
24
25
|
const DEFAULT_BURST_WINDOW_MS = 20;
|
|
25
26
|
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
26
27
|
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
@@ -299,12 +300,16 @@ export class StdinBuffer extends EventEmitter {
|
|
|
299
300
|
clearTimeout(this.burstTimer);
|
|
300
301
|
this.burstTimer = null;
|
|
301
302
|
}
|
|
302
|
-
this.buffer += str;
|
|
303
303
|
const chunkAt = str.length > 0 ? this.clock() : undefined;
|
|
304
304
|
const burstGap = chunkAt !== undefined && this.lastInputAt !== undefined ? chunkAt - this.lastInputAt : undefined;
|
|
305
305
|
if (chunkAt !== undefined) {
|
|
306
306
|
this.lastInputAt = chunkAt;
|
|
307
307
|
}
|
|
308
|
+
if (isStaleEscape(this.buffer, burstGap, this.escapeTimeoutMs)) {
|
|
309
|
+
this.buffer = "";
|
|
310
|
+
this.emitDataSequence(ESC);
|
|
311
|
+
}
|
|
312
|
+
this.buffer += str;
|
|
308
313
|
if (this.heldNewline.length > 0) {
|
|
309
314
|
// The clock decides, not timer delivery: a read outside the window means the held line
|
|
310
315
|
// break was an Enter, even when its release timer has not fired yet (a stalled loop).
|
|
@@ -6,6 +6,10 @@ export interface TerminalCapabilities {
|
|
|
6
6
|
hyperlinks: boolean;
|
|
7
7
|
tmuxPassthrough?: boolean;
|
|
8
8
|
kittyUnicodePlaceholders?: boolean;
|
|
9
|
+
/** Shown next to the image placeholder when the terminal could show images after a user action. */
|
|
10
|
+
imageHint?: string;
|
|
11
|
+
/** Set when image support is still unknown from the environment: the TUI must query the terminal and record the answer via `setProbedImageProtocol`. */
|
|
12
|
+
imageProbe?: "kitty";
|
|
9
13
|
}
|
|
10
14
|
export interface DetectedTerminalCapabilities extends TerminalCapabilities {
|
|
11
15
|
cellDimensions?: {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { decideTmuxImageCapability, parseTmuxKittyTerminalOverride, } from "./tmux-image-capability.js";
|
|
2
2
|
import { probeTmuxImageState } from "./tmux-image-probe.js";
|
|
3
|
+
import { isOldWindowsWezTerm, WEZTERM_WINDOWS_IMAGES_HINT } from "./wezterm-windows-images.js";
|
|
3
4
|
export function outerKittyGraphicsMode(clientTermname) {
|
|
4
5
|
const term = clientTermname.trim().toLowerCase();
|
|
5
6
|
return term.includes("kitty") || term.includes("ghostty") || term.includes("warp") ? "placeholder" : null;
|
|
@@ -33,7 +34,9 @@ export function detectTerminalCapabilities(env = process.env, platform = process
|
|
|
33
34
|
return { images: "kitty", trueColor: true, hyperlinks: true };
|
|
34
35
|
}
|
|
35
36
|
if (env.WEZTERM_PANE || termProgram === "wezterm") {
|
|
36
|
-
return
|
|
37
|
+
return isOldWindowsWezTerm(env.TERM_PROGRAM_VERSION, platform)
|
|
38
|
+
? { images: null, trueColor: true, hyperlinks: true, imageHint: WEZTERM_WINDOWS_IMAGES_HINT }
|
|
39
|
+
: { images: "kitty", trueColor: true, hyperlinks: true };
|
|
37
40
|
}
|
|
38
41
|
if (termProgram === "iterm.app" || env.ITERM_SESSION_ID) {
|
|
39
42
|
return { images: "iterm2", trueColor: true, hyperlinks: true };
|
package/dist/terminal-image.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TerminalColorMode } from "./colors.ts";
|
|
2
|
-
import { type DetectedTerminalCapabilities, type TerminalCapabilities } from "./terminal-capabilities.ts";
|
|
2
|
+
import { type DetectedTerminalCapabilities, type ImageProtocol, type TerminalCapabilities } from "./terminal-capabilities.ts";
|
|
3
3
|
export type { ImageProtocol, TerminalCapabilities } from "./terminal-capabilities.ts";
|
|
4
4
|
export { outerKittyGraphicsMode } from "./terminal-capabilities.ts";
|
|
5
5
|
export interface TmuxPassthroughState {
|
|
@@ -27,6 +27,8 @@ export interface ImageRenderOptions {
|
|
|
27
27
|
}
|
|
28
28
|
export declare function getCellDimensions(): CellDimensions;
|
|
29
29
|
export declare function setCellDimensions(dims: CellDimensions): void;
|
|
30
|
+
/** Record the TUI probe's answer for probe-requesting terminals. `undefined` re-arms the probe. */
|
|
31
|
+
export declare function setProbedImageProtocol(protocol: ImageProtocol | undefined): void;
|
|
30
32
|
export declare function detectCapabilities(tmuxForwardsHyperlink?: () => boolean, tmuxPassthroughState?: () => TmuxPassthroughState): DetectedTerminalCapabilities;
|
|
31
33
|
export declare function getCapabilities(): TerminalCapabilities;
|
|
32
34
|
export declare function getTerminalColorMode(capabilities?: TerminalCapabilities): TerminalColorMode;
|
package/dist/terminal-image.js
CHANGED
|
@@ -3,9 +3,14 @@ import { isAbsolute } from "node:path";
|
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { detectTerminalCapabilities, } from "./terminal-capabilities.js";
|
|
5
5
|
import { sanitizeTerminalLabel, shortenImagePath } from "./terminal-text.js";
|
|
6
|
+
import { isOldWindowsWezTerm, WEZTERM_WINDOWS_IMAGES_HINT } from "./wezterm-windows-images.js";
|
|
6
7
|
export { outerKittyGraphicsMode } from "./terminal-capabilities.js";
|
|
7
8
|
let cachedCapabilities = null;
|
|
8
9
|
let capabilityOverrides = {};
|
|
10
|
+
// Answer of the TUI's Kitty graphics probe, recorded once per process. It
|
|
11
|
+
// outlives cachedCapabilities because focus events reset that cache and
|
|
12
|
+
// re-detect from the environment (senpi #3698).
|
|
13
|
+
let probedImageProtocol;
|
|
9
14
|
// Default cell dimensions - updated by TUI when terminal responds to query
|
|
10
15
|
let cellDimensions = { widthPx: 9, heightPx: 18 };
|
|
11
16
|
export function getCellDimensions() {
|
|
@@ -53,7 +58,9 @@ function detectCapabilitiesFromEnvironment(tmuxForwardsHyperlink) {
|
|
|
53
58
|
return { images: "kitty", trueColor: true, hyperlinks: true };
|
|
54
59
|
}
|
|
55
60
|
if (process.env.WEZTERM_PANE || termProgram === "wezterm") {
|
|
56
|
-
return
|
|
61
|
+
return isOldWindowsWezTerm(process.env.TERM_PROGRAM_VERSION, process.platform)
|
|
62
|
+
? { images: null, trueColor: true, hyperlinks: true, imageHint: WEZTERM_WINDOWS_IMAGES_HINT }
|
|
63
|
+
: { images: "kitty", trueColor: true, hyperlinks: true };
|
|
57
64
|
}
|
|
58
65
|
// Warp supports the Kitty graphics protocol and OSC 8 hyperlinks.
|
|
59
66
|
if (termProgram === "warpterminal" || process.env.WARP_SESSION_ID || process.env.WARP_TERMINAL_SESSION_UUID) {
|
|
@@ -71,6 +78,17 @@ function detectCapabilitiesFromEnvironment(tmuxForwardsHyperlink) {
|
|
|
71
78
|
if (terminalEmulator === "jetbrains-jediterm") {
|
|
72
79
|
return { images: null, trueColor: true, hyperlinks: false };
|
|
73
80
|
}
|
|
81
|
+
// herdr panes: the environment says nothing about the outer terminal's graphics support, and herdr
|
|
82
|
+
// itself leaks graphics sequences when the outer terminal cannot render them. Detection defers to
|
|
83
|
+
// the TUI's Kitty graphics probe and latches its answer (senpi #3698).
|
|
84
|
+
if (termProgram === "herdr" || process.env.HERDR_ENV === "1") {
|
|
85
|
+
return {
|
|
86
|
+
images: probedImageProtocol ?? null,
|
|
87
|
+
trueColor: hasTrueColorHint,
|
|
88
|
+
hyperlinks: false,
|
|
89
|
+
...(probedImageProtocol === undefined ? { imageProbe: "kitty" } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
74
92
|
// Windows Terminal does not always set WT_SESSION, for example when it hosts
|
|
75
93
|
// a cmd.exe launched directly from Win+R. Modern Windows consoles support
|
|
76
94
|
// truecolor; keep hyperlinks off unless we positively detected support above.
|
|
@@ -86,6 +104,19 @@ function detectCapabilitiesFromEnvironment(tmuxForwardsHyperlink) {
|
|
|
86
104
|
function parseBooleanCapabilityOverride(value) {
|
|
87
105
|
return value === "1" ? true : value === "0" ? false : undefined;
|
|
88
106
|
}
|
|
107
|
+
/** Record the TUI probe's answer for probe-requesting terminals. `undefined` re-arms the probe. */
|
|
108
|
+
export function setProbedImageProtocol(protocol) {
|
|
109
|
+
if (probedImageProtocol === protocol)
|
|
110
|
+
return;
|
|
111
|
+
probedImageProtocol = protocol;
|
|
112
|
+
cachedCapabilities = null;
|
|
113
|
+
}
|
|
114
|
+
// The hint explains why detection turned images off; an explicit image choice makes it wrong. The
|
|
115
|
+
// probe flag is stripped for the same reason: an explicit choice settles the question, no probe needed.
|
|
116
|
+
function withoutImageHint(caps) {
|
|
117
|
+
const { imageHint: _imageHint, imageProbe: _imageProbe, ...rest } = caps;
|
|
118
|
+
return rest;
|
|
119
|
+
}
|
|
89
120
|
export function detectCapabilities(tmuxForwardsHyperlink, tmuxPassthroughState) {
|
|
90
121
|
if (!tmuxForwardsHyperlink || !tmuxPassthroughState) {
|
|
91
122
|
const hyperlinks = parseBooleanCapabilityOverride(process.env.PI_HYPERLINKS);
|
|
@@ -98,7 +129,7 @@ export function detectCapabilities(tmuxForwardsHyperlink, tmuxPassthroughState)
|
|
|
98
129
|
: undefined;
|
|
99
130
|
const trueColor = parseBooleanCapabilityOverride(process.env.PI_TRUE_COLOR);
|
|
100
131
|
return {
|
|
101
|
-
...detected,
|
|
132
|
+
...(images !== undefined ? withoutImageHint(detected) : detected),
|
|
102
133
|
...(images !== undefined ? { images } : {}),
|
|
103
134
|
...(trueColor !== undefined ? { trueColor } : {}),
|
|
104
135
|
...(hyperlinks !== undefined ? { hyperlinks } : {}),
|
|
@@ -125,8 +156,9 @@ export function detectCapabilities(tmuxForwardsHyperlink, tmuxPassthroughState)
|
|
|
125
156
|
export function getCapabilities() {
|
|
126
157
|
if (!cachedCapabilities) {
|
|
127
158
|
const hyperlinks = capabilityOverrides.hyperlinks;
|
|
159
|
+
const detected = detectCapabilities(hyperlinks === undefined ? undefined : () => hyperlinks);
|
|
128
160
|
cachedCapabilities = {
|
|
129
|
-
...
|
|
161
|
+
...(capabilityOverrides.images !== undefined ? withoutImageHint(detected) : detected),
|
|
130
162
|
...capabilityOverrides,
|
|
131
163
|
};
|
|
132
164
|
}
|
|
@@ -142,7 +174,8 @@ export function resetCapabilitiesCache() {
|
|
|
142
174
|
export function setCapabilityOverrides(overrides) {
|
|
143
175
|
if (capabilityOverrides.images === overrides.images &&
|
|
144
176
|
capabilityOverrides.trueColor === overrides.trueColor &&
|
|
145
|
-
capabilityOverrides.hyperlinks === overrides.hyperlinks
|
|
177
|
+
capabilityOverrides.hyperlinks === overrides.hyperlinks &&
|
|
178
|
+
capabilityOverrides.imageHint === overrides.imageHint) {
|
|
146
179
|
return;
|
|
147
180
|
}
|
|
148
181
|
capabilityOverrides = { ...overrides };
|
|
@@ -657,6 +690,7 @@ export function imageFallback(mimeType, dimensions, filename) {
|
|
|
657
690
|
parts.push(`[${sanitizeTerminalLabel(mimeType)}]`);
|
|
658
691
|
if (dimensions)
|
|
659
692
|
parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`);
|
|
660
|
-
|
|
693
|
+
const hint = getCapabilities().imageHint;
|
|
694
|
+
return hint ? `[Image: ${parts.join(" ")}] (${hint})` : `[Image: ${parts.join(" ")}]`;
|
|
661
695
|
}
|
|
662
696
|
//# sourceMappingURL=terminal-image.js.map
|
package/dist/terminal.d.ts
CHANGED
|
@@ -74,9 +74,10 @@ export interface ProcessTerminalOptions {
|
|
|
74
74
|
/**
|
|
75
75
|
* Resolve how long to wait for the rest of an escape sequence before
|
|
76
76
|
* dispatching a lone ESC as the Escape key. Legacy Alt+key input is ESC plus
|
|
77
|
-
* another byte, so high-latency transports
|
|
77
|
+
* another byte, so high-latency transports (SSH, the WSL interop pipe) need a
|
|
78
|
+
* longer reassembly window.
|
|
78
79
|
*/
|
|
79
|
-
export declare function resolveEscapeTimeoutMs(env?: NodeJS.ProcessEnv): number;
|
|
80
|
+
export declare function resolveEscapeTimeoutMs(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): number;
|
|
80
81
|
/**
|
|
81
82
|
* Resolve how long a line break that ends a read with text is held as a possible paste fragment
|
|
82
83
|
* when the terminal sends no bracketed-paste markers. Paste chunks over SSH arrive further apart,
|
package/dist/terminal.js
CHANGED
|
@@ -7,6 +7,7 @@ import { getNativePlatformHelper } from "./native-platform.js";
|
|
|
7
7
|
import { observeProcessStderrWrites } from "./stderr-observer.js";
|
|
8
8
|
import { StdinBuffer } from "./stdin-buffer.js";
|
|
9
9
|
import { queryTmuxCursorPosition } from "./tmux-cursor-query.js";
|
|
10
|
+
import { isWslSession } from "./wsl-session.js";
|
|
10
11
|
const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
|
|
11
12
|
const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
|
|
12
13
|
const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0\x07";
|
|
@@ -161,22 +162,23 @@ function unsubscribeFromStdinErrors(subscriber) {
|
|
|
161
162
|
if (stdinErrorSubscribers.size === 0)
|
|
162
163
|
process.stdin.removeListener("error", dispatchStdinError);
|
|
163
164
|
}
|
|
164
|
-
const DEFAULT_ESCAPE_TIMEOUT_MS =
|
|
165
|
-
const
|
|
165
|
+
const DEFAULT_ESCAPE_TIMEOUT_MS = 50;
|
|
166
|
+
const DEFAULT_REMOTE_ESCAPE_TIMEOUT_MS = 100;
|
|
166
167
|
const DEFAULT_BURST_WINDOW_MS = 20;
|
|
167
168
|
const DEFAULT_SSH_BURST_WINDOW_MS = 100;
|
|
168
169
|
/**
|
|
169
170
|
* Resolve how long to wait for the rest of an escape sequence before
|
|
170
171
|
* dispatching a lone ESC as the Escape key. Legacy Alt+key input is ESC plus
|
|
171
|
-
* another byte, so high-latency transports
|
|
172
|
+
* another byte, so high-latency transports (SSH, the WSL interop pipe) need a
|
|
173
|
+
* longer reassembly window.
|
|
172
174
|
*/
|
|
173
|
-
export function resolveEscapeTimeoutMs(env = process.env) {
|
|
175
|
+
export function resolveEscapeTimeoutMs(env = process.env, platform = process.platform) {
|
|
174
176
|
const configured = Number(env.PI_TUI_ESC_TIMEOUT);
|
|
175
177
|
if (Number.isFinite(configured) && configured > 0) {
|
|
176
178
|
return configured;
|
|
177
179
|
}
|
|
178
|
-
if (env.SSH_CONNECTION || env.SSH_TTY) {
|
|
179
|
-
return
|
|
180
|
+
if (env.SSH_CONNECTION || env.SSH_TTY || isWslSession(env, platform)) {
|
|
181
|
+
return DEFAULT_REMOTE_ESCAPE_TIMEOUT_MS;
|
|
180
182
|
}
|
|
181
183
|
return DEFAULT_ESCAPE_TIMEOUT_MS;
|
|
182
184
|
}
|
|
@@ -12,6 +12,11 @@ export interface TuiMainScreenRenderState {
|
|
|
12
12
|
export declare class TuiMainScreen extends TuiBase {
|
|
13
13
|
readonly mode: "regular";
|
|
14
14
|
private trackingEnabled;
|
|
15
|
+
/**
|
|
16
|
+
* The app cannot scroll the terminal's scrollback, so the first wheel report while armed hands the
|
|
17
|
+
* mouse back to the terminal; the next key press arms it again (senpi#3604).
|
|
18
|
+
*/
|
|
19
|
+
private yielded;
|
|
15
20
|
private readonly clicks;
|
|
16
21
|
/**
|
|
17
22
|
* A press stays clickable only while the committed frame and component tree it hit are unchanged.
|
|
@@ -19,6 +24,7 @@ export declare class TuiMainScreen extends TuiBase {
|
|
|
19
24
|
*/
|
|
20
25
|
private mousePress?;
|
|
21
26
|
constructor(...args: ConstructorParameters<typeof TuiBase>);
|
|
27
|
+
mouseCaptureState(): "off" | "armed" | "yielded";
|
|
22
28
|
protected applyMouseTracking(enabled: boolean): void;
|
|
23
29
|
protected beforeTerminalStop(): void;
|
|
24
30
|
protected doRender(): void;
|
|
@@ -26,6 +32,8 @@ export declare class TuiMainScreen extends TuiBase {
|
|
|
26
32
|
private pressLayoutUnchanged;
|
|
27
33
|
private applyMouseResult;
|
|
28
34
|
private handleMouseInput;
|
|
35
|
+
private yieldToTerminal;
|
|
36
|
+
private rearmAfterYield;
|
|
29
37
|
captureRenderState(): TuiMainScreenRenderState;
|
|
30
38
|
restoreRenderState(state: TuiMainScreenRenderState): void;
|
|
31
39
|
}
|
package/dist/tui-main-screen.js
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
import { isMouseSequence, MOUSE_TRACKING, MouseClickSynthesizer, parseSgrMouseEvent, toTuiMouseEvent, } from "./mouse-input.js";
|
|
2
2
|
import { isImageLine } from "./terminal-image.js";
|
|
3
|
-
import { Container, dispatchMouseEvent, retargetMouseEvent, TuiBase, } from "./tui.js";
|
|
3
|
+
import { Container, dispatchMouseEvent, isTerminalReport, retargetMouseEvent, TuiBase, } from "./tui.js";
|
|
4
4
|
/** TUI implementation that renders into the terminal's main screen and scrollback. */
|
|
5
5
|
export class TuiMainScreen extends TuiBase {
|
|
6
6
|
constructor(...args) {
|
|
7
7
|
super(...args);
|
|
8
8
|
this.mode = "regular";
|
|
9
9
|
this.trackingEnabled = false;
|
|
10
|
+
/**
|
|
11
|
+
* The app cannot scroll the terminal's scrollback, so the first wheel report while armed hands the
|
|
12
|
+
* mouse back to the terminal; the next key press arms it again (senpi#3604).
|
|
13
|
+
*/
|
|
14
|
+
this.yielded = false;
|
|
10
15
|
this.clicks = new MouseClickSynthesizer();
|
|
11
16
|
this.addInputListener((data) => this.handleMouseInput(data));
|
|
12
17
|
}
|
|
18
|
+
mouseCaptureState() {
|
|
19
|
+
if (this.yielded)
|
|
20
|
+
return "yielded";
|
|
21
|
+
return this.trackingEnabled ? "armed" : "off";
|
|
22
|
+
}
|
|
13
23
|
applyMouseTracking(enabled) {
|
|
24
|
+
this.yielded = false;
|
|
14
25
|
const supported = Boolean(process.stdout.isTTY) &&
|
|
15
26
|
!process.env.TERMUX_VERSION &&
|
|
16
27
|
(process.platform !== "win32" || Boolean(process.env.WT_SESSION));
|
|
@@ -25,6 +36,7 @@ export class TuiMainScreen extends TuiBase {
|
|
|
25
36
|
beforeTerminalStop() {
|
|
26
37
|
this.terminal.write(MOUSE_TRACKING.disable);
|
|
27
38
|
this.trackingEnabled = false;
|
|
39
|
+
this.yielded = false;
|
|
28
40
|
this.clicks.cancel();
|
|
29
41
|
this.mousePress = undefined;
|
|
30
42
|
}
|
|
@@ -83,9 +95,16 @@ export class TuiMainScreen extends TuiBase {
|
|
|
83
95
|
this.setFocus(target);
|
|
84
96
|
}
|
|
85
97
|
handleMouseInput(data) {
|
|
86
|
-
if (!isMouseSequence(data))
|
|
98
|
+
if (!isMouseSequence(data)) {
|
|
99
|
+
if (this.yielded && !isTerminalReport(data))
|
|
100
|
+
this.rearmAfterYield();
|
|
87
101
|
return { consume: false };
|
|
102
|
+
}
|
|
88
103
|
const raw = parseSgrMouseEvent(data);
|
|
104
|
+
if (this.trackingEnabled && raw && (raw.button & 64) !== 0) {
|
|
105
|
+
this.yieldToTerminal();
|
|
106
|
+
return { consume: true };
|
|
107
|
+
}
|
|
89
108
|
if (!this.trackingEnabled || !raw || raw.button !== 0) {
|
|
90
109
|
this.clicks.cancel();
|
|
91
110
|
this.mousePress = undefined;
|
|
@@ -146,6 +165,22 @@ export class TuiMainScreen extends TuiBase {
|
|
|
146
165
|
}
|
|
147
166
|
return { consume: true };
|
|
148
167
|
}
|
|
168
|
+
yieldToTerminal() {
|
|
169
|
+
this.terminal.write(MOUSE_TRACKING.disable);
|
|
170
|
+
this.trackingEnabled = false;
|
|
171
|
+
this.yielded = true;
|
|
172
|
+
this.clicks.cancel();
|
|
173
|
+
this.mousePress = undefined;
|
|
174
|
+
this.requestRender();
|
|
175
|
+
}
|
|
176
|
+
rearmAfterYield() {
|
|
177
|
+
if (!this.mouseCaptureEnabled) {
|
|
178
|
+
this.yielded = false;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
this.applyMouseTracking(true);
|
|
182
|
+
this.requestRender();
|
|
183
|
+
}
|
|
149
184
|
captureRenderState() {
|
|
150
185
|
return {
|
|
151
186
|
previousLines: [...this.previousLines],
|
package/dist/tui.d.ts
CHANGED
|
@@ -363,6 +363,7 @@ export declare abstract class TuiBase extends Container {
|
|
|
363
363
|
* replies belong to the oldest one. Queries stay here after a timeout to collect late replies.
|
|
364
364
|
*/
|
|
365
365
|
private pendingTerminalColorQueries;
|
|
366
|
+
private pendingKittyGraphicsProbe?;
|
|
366
367
|
private terminalColorSchemeListeners;
|
|
367
368
|
private terminalColorSchemeNotificationsEnabled;
|
|
368
369
|
/** Directory for debug/crash logs. The fork keeps a concrete default (`~/.senpi/agent`) so PI_DEBUG_REDRAW and crash dumps stay in the agent directory. */
|
|
@@ -391,6 +392,11 @@ export declare abstract class TuiBase extends Container {
|
|
|
391
392
|
acquireMouseCapture(reason: string): () => void;
|
|
392
393
|
private resetMouseCaptureState;
|
|
393
394
|
protected get mouseCaptureEnabled(): boolean;
|
|
395
|
+
/**
|
|
396
|
+
* Whether mouse reports reach the app right now: `"off"` without an enabled lease, `"armed"` while
|
|
397
|
+
* they do, `"yielded"` while a renderer has handed the mouse back to the terminal (main-screen wheel).
|
|
398
|
+
*/
|
|
399
|
+
mouseCaptureState(): "off" | "armed" | "yielded";
|
|
394
400
|
protected applyMouseTracking(_enabled: boolean): void;
|
|
395
401
|
protected getMouseLayoutRoots(): readonly Component[];
|
|
396
402
|
protected setMouseBlocker(name: "suspended" | "external-editor" | "shutting-down", on: boolean): void;
|
|
@@ -465,6 +471,9 @@ export declare abstract class TuiBase extends Container {
|
|
|
465
471
|
onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void;
|
|
466
472
|
setTerminalColorSchemeNotifications(enabled: boolean): void;
|
|
467
473
|
private queryCellSize;
|
|
474
|
+
private probeKittyGraphics;
|
|
475
|
+
private consumeKittyGraphicsProbeResponse;
|
|
476
|
+
private resolveKittyGraphicsProbe;
|
|
468
477
|
stop(options?: TuiStopOptions): void;
|
|
469
478
|
/**
|
|
470
479
|
* While a reply streams, a frame that changes rows above the viewport would replay the whole scrollback
|
|
@@ -546,15 +555,15 @@ export declare abstract class TuiBase extends Container {
|
|
|
546
555
|
private deleteChangedKittyImages;
|
|
547
556
|
private getViewportRows;
|
|
548
557
|
private shouldPreserveMuxScrollback;
|
|
549
|
-
private createViewportInsertScrollPlan;
|
|
550
558
|
private renderViewportInsertScroll;
|
|
551
559
|
private renderScrollbackReplay;
|
|
552
560
|
/**
|
|
553
561
|
* The held frame of `setScrollbackReplayHold` (#2836): rows above the old viewport stay as the terminal
|
|
554
|
-
* shows them (possibly stale), and the frame is rewritten from the old viewport top down
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
562
|
+
* shows them (possibly stale), and the frame is rewritten from the old viewport top down, or from where the
|
|
563
|
+
* old viewport's rows moved to when rows were inserted above it (senpi#3649), so rows already in the
|
|
564
|
+
* scrollback are not written into it again. Rows the document grew by scroll naturally into scrollback in
|
|
565
|
+
* their current form, so nothing is lost there and nothing is cleared, and a reader who scrolled up keeps
|
|
566
|
+
* their place. Returns false when an image row is involved, so the caller replays as before.
|
|
558
567
|
*/
|
|
559
568
|
private renderHeldRepaint;
|
|
560
569
|
private renderMuxViewportRepaint;
|
|
@@ -594,6 +603,12 @@ export declare abstract class TuiBase extends Container {
|
|
|
594
603
|
onLateReply?: (colors: TerminalColors) => void;
|
|
595
604
|
}): Promise<TerminalColors>;
|
|
596
605
|
}
|
|
606
|
+
/**
|
|
607
|
+
* Input that the terminal sends on its own rather than the user typing: mouse reports, OSC/DCS/APC
|
|
608
|
+
* replies, DEC private reports (`ESC[?...`) and window/cell-size reports (`ESC[...t`). OSC/DCS/APC need
|
|
609
|
+
* a body after the introducer, so a legacy Alt+] / Alt+Shift+P / Alt+_ key press is still a key.
|
|
610
|
+
*/
|
|
611
|
+
export declare function isTerminalReport(data: string): boolean;
|
|
597
612
|
/** Legacy main-screen renderer export. */
|
|
598
613
|
export declare class TUI extends TuiBase {
|
|
599
614
|
readonly mode: TuiMode;
|
package/dist/tui.js
CHANGED
|
@@ -7,11 +7,14 @@ import * as os from "node:os";
|
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { performance } from "node:perf_hooks";
|
|
9
9
|
import { isKeyRelease, matchesKey } from "./keys.js";
|
|
10
|
+
import { buildKittyGraphicsQuery, KITTY_GRAPHICS_PROBE_TIMEOUT_MS, parseKittyGraphicsProbeResponse, } from "./kitty-graphics-probe.js";
|
|
10
11
|
import { isMultiplexerSession, useLegacyMuxRender, viewportRenderEnabled } from "./mux.js";
|
|
11
12
|
import { parseOscColorResponse, parseTerminalColorSchemeReport, } from "./terminal-colors.js";
|
|
12
|
-
import { deleteKittyImage, getCapabilities, isImageLine, resetCapabilitiesCache, setCellDimensions, } from "./terminal-image.js";
|
|
13
|
+
import { allocateImageId, deleteKittyImage, getCapabilities, isImageLine, resetCapabilitiesCache, setCellDimensions, setProbedImageProtocol, } from "./terminal-image.js";
|
|
13
14
|
import { consumeTmuxFocusEvent, DISABLE_FOCUS_REPORTING, ENABLE_FOCUS_REPORTING } from "./tmux-focus.js";
|
|
14
15
|
import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
|
|
16
|
+
import { planViewportInsertScroll } from "./viewport-insert-scroll.js";
|
|
17
|
+
import { insertedAboveViewport } from "./viewport-shift.js";
|
|
15
18
|
const KITTY_SEQUENCE_PREFIX = "\x1b_G";
|
|
16
19
|
const MAX_RENDER_WRITE_CHARS = 1024 * 1024;
|
|
17
20
|
function writeBounded(terminal, data) {
|
|
@@ -148,8 +151,6 @@ function defaultDiagnosticLogDirectory() {
|
|
|
148
151
|
*/
|
|
149
152
|
let renderErrorLogDirectory;
|
|
150
153
|
const VIEWPORT_RENDER_OVERSCAN = 16;
|
|
151
|
-
// Keep scroll-region wins cheap when a few visible rows mutate during append streaming.
|
|
152
|
-
const MAX_SCROLL_DIFF_ROWS = 4;
|
|
153
154
|
const viewportRenderStats = {
|
|
154
155
|
lastNormalizedLines: 0,
|
|
155
156
|
lastKittyImageScannedLines: 0,
|
|
@@ -728,6 +729,13 @@ export class TuiBase extends Container {
|
|
|
728
729
|
get mouseCaptureEnabled() {
|
|
729
730
|
return this.mouseLeases.size > 0 && this.mouseBlockers.size === 0;
|
|
730
731
|
}
|
|
732
|
+
/**
|
|
733
|
+
* Whether mouse reports reach the app right now: `"off"` without an enabled lease, `"armed"` while
|
|
734
|
+
* they do, `"yielded"` while a renderer has handed the mouse back to the terminal (main-screen wheel).
|
|
735
|
+
*/
|
|
736
|
+
mouseCaptureState() {
|
|
737
|
+
return this.mouseCaptureEnabled ? "armed" : "off";
|
|
738
|
+
}
|
|
731
739
|
applyMouseTracking(_enabled) { }
|
|
732
740
|
getMouseLayoutRoots() {
|
|
733
741
|
return [...this.getMountedRoots(), ...this.renderedOverlayLayouts.map((layout) => layout.entry.component)];
|
|
@@ -1235,6 +1243,7 @@ export class TuiBase extends Container {
|
|
|
1235
1243
|
this.terminal.write("\x1b[?2031h");
|
|
1236
1244
|
}
|
|
1237
1245
|
this.queryCellSize();
|
|
1246
|
+
this.probeKittyGraphics();
|
|
1238
1247
|
this.requestRender();
|
|
1239
1248
|
}
|
|
1240
1249
|
addInputListener(listener) {
|
|
@@ -1270,6 +1279,40 @@ export class TuiBase extends Container {
|
|
|
1270
1279
|
// Response format: CSI 6 ; height ; width t
|
|
1271
1280
|
this.terminal.write("\x1b[16t");
|
|
1272
1281
|
}
|
|
1282
|
+
probeKittyGraphics() {
|
|
1283
|
+
if (this.pendingKittyGraphicsProbe)
|
|
1284
|
+
return;
|
|
1285
|
+
if (getCapabilities().imageProbe !== "kitty")
|
|
1286
|
+
return;
|
|
1287
|
+
const imageId = allocateImageId();
|
|
1288
|
+
const timer = setTimeout(() => this.resolveKittyGraphicsProbe(false), KITTY_GRAPHICS_PROBE_TIMEOUT_MS);
|
|
1289
|
+
this.pendingKittyGraphicsProbe = { imageId, timer };
|
|
1290
|
+
this.terminal.write(buildKittyGraphicsQuery(imageId));
|
|
1291
|
+
}
|
|
1292
|
+
consumeKittyGraphicsProbeResponse(data) {
|
|
1293
|
+
const probe = this.pendingKittyGraphicsProbe;
|
|
1294
|
+
if (!probe)
|
|
1295
|
+
return false;
|
|
1296
|
+
const outcome = parseKittyGraphicsProbeResponse(data, probe.imageId);
|
|
1297
|
+
if (outcome === null)
|
|
1298
|
+
return false;
|
|
1299
|
+
this.resolveKittyGraphicsProbe(outcome === "ok");
|
|
1300
|
+
return true;
|
|
1301
|
+
}
|
|
1302
|
+
resolveKittyGraphicsProbe(supported) {
|
|
1303
|
+
const probe = this.pendingKittyGraphicsProbe;
|
|
1304
|
+
if (!probe)
|
|
1305
|
+
return;
|
|
1306
|
+
this.pendingKittyGraphicsProbe = undefined;
|
|
1307
|
+
clearTimeout(probe.timer);
|
|
1308
|
+
setProbedImageProtocol(supported ? "kitty" : null);
|
|
1309
|
+
if (this.stopped)
|
|
1310
|
+
return;
|
|
1311
|
+
if (supported)
|
|
1312
|
+
this.queryCellSize();
|
|
1313
|
+
this.invalidate();
|
|
1314
|
+
this.requestRender();
|
|
1315
|
+
}
|
|
1273
1316
|
stop(options = {}) {
|
|
1274
1317
|
this.stopped = true;
|
|
1275
1318
|
// The hold itself belongs to the turn (interactive mode sets and releases it); a stop()/start() handover
|
|
@@ -1280,6 +1323,10 @@ export class TuiBase extends Container {
|
|
|
1280
1323
|
this.renderRequested = false;
|
|
1281
1324
|
this.inputRenderPending = false;
|
|
1282
1325
|
this.cancelRenderTimer();
|
|
1326
|
+
if (this.pendingKittyGraphicsProbe) {
|
|
1327
|
+
clearTimeout(this.pendingKittyGraphicsProbe.timer);
|
|
1328
|
+
this.pendingKittyGraphicsProbe = undefined;
|
|
1329
|
+
}
|
|
1283
1330
|
if (this.terminalColorSchemeNotificationsEnabled) {
|
|
1284
1331
|
this.terminal.write("\x1b[?2031l");
|
|
1285
1332
|
}
|
|
@@ -1486,6 +1533,9 @@ export class TuiBase extends Container {
|
|
|
1486
1533
|
if (this.consumeTerminalColorSchemeReport(data)) {
|
|
1487
1534
|
return;
|
|
1488
1535
|
}
|
|
1536
|
+
if (this.consumeKittyGraphicsProbeResponse(data)) {
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1489
1539
|
// A key press means the user is at the bottom again. Terminal reports (mouse/wheel, OSC/DCS replies,
|
|
1490
1540
|
// DEC private reports, window and cell-size reports) are not the user and must not trigger it.
|
|
1491
1541
|
if (!isTerminalReport(data)) {
|
|
@@ -2097,45 +2147,6 @@ export class TuiBase extends Container {
|
|
|
2097
2147
|
shouldPreserveMuxScrollback() {
|
|
2098
2148
|
return this.#muxDetector() && !useLegacyMuxRender();
|
|
2099
2149
|
}
|
|
2100
|
-
createViewportInsertScrollPlan(newLines, prevViewportTop, height, lineCountDelta) {
|
|
2101
|
-
if (lineCountDelta <= 0 || lineCountDelta >= height || this.overlayStack.length > 0) {
|
|
2102
|
-
return undefined;
|
|
2103
|
-
}
|
|
2104
|
-
const maxViewportTop = Math.max(0, newLines.length - height);
|
|
2105
|
-
const viewportTop = Math.min(maxViewportTop, prevViewportTop + lineCountDelta);
|
|
2106
|
-
if (viewportTop <= prevViewportTop) {
|
|
2107
|
-
return undefined;
|
|
2108
|
-
}
|
|
2109
|
-
const previousVisible = this.getViewportRows(this.previousLines, prevViewportTop, height);
|
|
2110
|
-
const nextVisible = this.getViewportRows(newLines, viewportTop, height);
|
|
2111
|
-
if (previousVisible.some(isImageLine) || nextVisible.some(isImageLine)) {
|
|
2112
|
-
return undefined;
|
|
2113
|
-
}
|
|
2114
|
-
let stableSuffixRows = 0;
|
|
2115
|
-
while (stableSuffixRows < height &&
|
|
2116
|
-
previousVisible[height - stableSuffixRows - 1] === nextVisible[height - stableSuffixRows - 1]) {
|
|
2117
|
-
stableSuffixRows += 1;
|
|
2118
|
-
}
|
|
2119
|
-
const regionHeight = height - stableSuffixRows;
|
|
2120
|
-
if (regionHeight <= 0 || regionHeight < lineCountDelta) {
|
|
2121
|
-
return undefined;
|
|
2122
|
-
}
|
|
2123
|
-
const mutatedRows = [];
|
|
2124
|
-
for (let row = 0; row < regionHeight - lineCountDelta; row++) {
|
|
2125
|
-
if (previousVisible[row + lineCountDelta] !== nextVisible[row]) {
|
|
2126
|
-
mutatedRows.push({ screenRow: row, content: nextVisible[row] });
|
|
2127
|
-
if (mutatedRows.length > MAX_SCROLL_DIFF_ROWS) {
|
|
2128
|
-
return undefined;
|
|
2129
|
-
}
|
|
2130
|
-
}
|
|
2131
|
-
}
|
|
2132
|
-
return {
|
|
2133
|
-
viewportTop,
|
|
2134
|
-
regionBottom: regionHeight - 1,
|
|
2135
|
-
insertedRows: nextVisible.slice(regionHeight - lineCountDelta, regionHeight),
|
|
2136
|
-
mutatedRows,
|
|
2137
|
-
};
|
|
2138
|
-
}
|
|
2139
2150
|
renderViewportInsertScroll(plan, newLines, rawLines, cursorPos, width, height) {
|
|
2140
2151
|
let buffer = TUI.FRAME_BEGIN;
|
|
2141
2152
|
const regionTop = 0;
|
|
@@ -2199,13 +2210,15 @@ export class TuiBase extends Container {
|
|
|
2199
2210
|
}
|
|
2200
2211
|
/**
|
|
2201
2212
|
* The held frame of `setScrollbackReplayHold` (#2836): rows above the old viewport stay as the terminal
|
|
2202
|
-
* shows them (possibly stale), and the frame is rewritten from the old viewport top down
|
|
2203
|
-
*
|
|
2204
|
-
*
|
|
2205
|
-
*
|
|
2213
|
+
* shows them (possibly stale), and the frame is rewritten from the old viewport top down, or from where the
|
|
2214
|
+
* old viewport's rows moved to when rows were inserted above it (senpi#3649), so rows already in the
|
|
2215
|
+
* scrollback are not written into it again. Rows the document grew by scroll naturally into scrollback in
|
|
2216
|
+
* their current form, so nothing is lost there and nothing is cleared, and a reader who scrolled up keeps
|
|
2217
|
+
* their place. Returns false when an image row is involved, so the caller replays as before.
|
|
2206
2218
|
*/
|
|
2207
2219
|
renderHeldRepaint(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow) {
|
|
2208
|
-
const
|
|
2220
|
+
const shift = insertedAboveViewport(this.previousLines, newLines, prevViewportTop, height, newLines.length - this.previousLines.length);
|
|
2221
|
+
const start = Math.min(prevViewportTop + shift, Math.max(0, newLines.length - height));
|
|
2209
2222
|
const rowCount = Math.max(height, newLines.length - start);
|
|
2210
2223
|
const rows = Array.from({ length: rowCount }, (_, index) => newLines[start + index] ?? "");
|
|
2211
2224
|
const previousVisible = this.getViewportRows(this.previousLines, prevViewportTop, height);
|
|
@@ -2563,7 +2576,9 @@ export class TuiBase extends Container {
|
|
|
2563
2576
|
lastChanged = expandedRange.lastChanged;
|
|
2564
2577
|
}
|
|
2565
2578
|
const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
|
|
2566
|
-
const insertScrollPlan = this.
|
|
2579
|
+
const insertScrollPlan = this.overlayStack.length > 0
|
|
2580
|
+
? undefined
|
|
2581
|
+
: planViewportInsertScroll(this.previousLines, newLines, prevViewportTop, height, lineCountDelta);
|
|
2567
2582
|
// No changes - but still need to update hardware cursor position if it moved
|
|
2568
2583
|
if (firstChanged === -1) {
|
|
2569
2584
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
@@ -2948,7 +2963,7 @@ export class TuiBase extends Container {
|
|
|
2948
2963
|
* replies, DEC private reports (`ESC[?...`) and window/cell-size reports (`ESC[...t`). OSC/DCS/APC need
|
|
2949
2964
|
* a body after the introducer, so a legacy Alt+] / Alt+Shift+P / Alt+_ key press is still a key.
|
|
2950
2965
|
*/
|
|
2951
|
-
function isTerminalReport(data) {
|
|
2966
|
+
export function isTerminalReport(data) {
|
|
2952
2967
|
return (data.startsWith("\x1b[<") ||
|
|
2953
2968
|
data.startsWith("\x1b[M") ||
|
|
2954
2969
|
(data.length > 2 && (data.startsWith("\x1b]") || data.startsWith("\x1bP") || data.startsWith("\x1b_"))) ||
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface ViewportInsertScrollPlan {
|
|
2
|
+
viewportTop: number;
|
|
3
|
+
regionBottom: number;
|
|
4
|
+
insertedRows: string[];
|
|
5
|
+
mutatedRows: Array<{
|
|
6
|
+
screenRow: number;
|
|
7
|
+
content: string;
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Plan a scroll-region update for a frame that grew by `lineCountDelta` rows below a stable bottom suffix.
|
|
12
|
+
*
|
|
13
|
+
* Scrolling the region pushes the old top `lineCountDelta` visible rows into the terminal's scrollback as
|
|
14
|
+
* they are, so the plan only holds when the new frame keeps exactly those rows there. Rows inserted at or
|
|
15
|
+
* above the viewport top (a live tool card growing above a tall live area such as the /btw panel) break that:
|
|
16
|
+
* the scroll would lose the inserted rows and send stale copies of the live area into the scrollback on
|
|
17
|
+
* every frame (senpi#3649), so such a frame takes the general repaint paths instead. Identical rows can make the
|
|
18
|
+
* departing rows equal the new ones by coincidence, so a frame that reads as rows moved down from above
|
|
19
|
+
* (`shiftedBlockMoved`) is refused too.
|
|
20
|
+
*/
|
|
21
|
+
export declare function planViewportInsertScroll(previousLines: readonly string[], newLines: readonly string[], prevViewportTop: number, height: number, lineCountDelta: number): ViewportInsertScrollPlan | undefined;
|
|
22
|
+
//# sourceMappingURL=viewport-insert-scroll.d.ts.map
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { isImageLine } from "./terminal-image.js";
|
|
2
|
+
import { shiftedBlockMoved } from "./viewport-shift.js";
|
|
3
|
+
// Keep scroll-region wins cheap when a few visible rows mutate during append streaming.
|
|
4
|
+
const MAX_SCROLL_DIFF_ROWS = 4;
|
|
5
|
+
function viewportRows(lines, viewportTop, height) {
|
|
6
|
+
return Array.from({ length: height }, (_, row) => lines[viewportTop + row] ?? "");
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Plan a scroll-region update for a frame that grew by `lineCountDelta` rows below a stable bottom suffix.
|
|
10
|
+
*
|
|
11
|
+
* Scrolling the region pushes the old top `lineCountDelta` visible rows into the terminal's scrollback as
|
|
12
|
+
* they are, so the plan only holds when the new frame keeps exactly those rows there. Rows inserted at or
|
|
13
|
+
* above the viewport top (a live tool card growing above a tall live area such as the /btw panel) break that:
|
|
14
|
+
* the scroll would lose the inserted rows and send stale copies of the live area into the scrollback on
|
|
15
|
+
* every frame (senpi#3649), so such a frame takes the general repaint paths instead. Identical rows can make the
|
|
16
|
+
* departing rows equal the new ones by coincidence, so a frame that reads as rows moved down from above
|
|
17
|
+
* (`shiftedBlockMoved`) is refused too.
|
|
18
|
+
*/
|
|
19
|
+
export function planViewportInsertScroll(previousLines, newLines, prevViewportTop, height, lineCountDelta) {
|
|
20
|
+
if (lineCountDelta <= 0 || lineCountDelta >= height)
|
|
21
|
+
return undefined;
|
|
22
|
+
const maxViewportTop = Math.max(0, newLines.length - height);
|
|
23
|
+
const viewportTop = Math.min(maxViewportTop, prevViewportTop + lineCountDelta);
|
|
24
|
+
if (viewportTop <= prevViewportTop)
|
|
25
|
+
return undefined;
|
|
26
|
+
const previousVisible = viewportRows(previousLines, prevViewportTop, height);
|
|
27
|
+
const nextVisible = viewportRows(newLines, viewportTop, height);
|
|
28
|
+
if (previousVisible.some(isImageLine) || nextVisible.some(isImageLine))
|
|
29
|
+
return undefined;
|
|
30
|
+
const scrolledOut = viewportTop - prevViewportTop;
|
|
31
|
+
for (let row = 0; row < scrolledOut; row++) {
|
|
32
|
+
if (previousVisible[row] !== newLines[prevViewportTop + row])
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
if (shiftedBlockMoved(previousLines, newLines, prevViewportTop, scrolledOut, height))
|
|
36
|
+
return undefined;
|
|
37
|
+
let stableSuffixRows = 0;
|
|
38
|
+
while (stableSuffixRows < height &&
|
|
39
|
+
previousVisible[height - stableSuffixRows - 1] === nextVisible[height - stableSuffixRows - 1]) {
|
|
40
|
+
stableSuffixRows += 1;
|
|
41
|
+
}
|
|
42
|
+
const regionHeight = height - stableSuffixRows;
|
|
43
|
+
if (regionHeight <= 0 || regionHeight < lineCountDelta)
|
|
44
|
+
return undefined;
|
|
45
|
+
const mutatedRows = [];
|
|
46
|
+
for (let row = 0; row < regionHeight - lineCountDelta; row++) {
|
|
47
|
+
if (previousVisible[row + lineCountDelta] !== nextVisible[row]) {
|
|
48
|
+
mutatedRows.push({ screenRow: row, content: nextVisible[row] ?? "" });
|
|
49
|
+
if (mutatedRows.length > MAX_SCROLL_DIFF_ROWS)
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
viewportTop,
|
|
55
|
+
regionBottom: regionHeight - 1,
|
|
56
|
+
insertedRows: nextVisible.slice(regionHeight - lineCountDelta, regionHeight),
|
|
57
|
+
mutatedRows,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=viewport-insert-scroll.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the new frame reads as the old one with `shift` rows inserted above `prevViewportTop`: the rows at
|
|
3
|
+
* `prevViewportTop..+shift` are the old rows right above it, and walking up through the block of rows that keeps
|
|
4
|
+
* matching that reading finds at least one row that really moved. Identical rows (blank or repeated lines) match
|
|
5
|
+
* both readings and prove nothing; the walk stops at the first row the shift does not explain.
|
|
6
|
+
*/
|
|
7
|
+
export declare function shiftedBlockMoved(previousLines: readonly string[], newLines: readonly string[], prevViewportTop: number, shift: number, height: number): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* How far the last frame's visible rows moved down in the new frame because rows were inserted above them.
|
|
10
|
+
*
|
|
11
|
+
* A held repaint writes the frame from a start row and lets the rows above the screen scroll into the
|
|
12
|
+
* terminal's scrollback. Rows that sat above the old viewport are already there, so after an insertion
|
|
13
|
+
* above the viewport (a live tool card growing over a tall /btw panel, senpi#3649) the repaint must start
|
|
14
|
+
* where the old viewport's rows went, or every frame pushes another copy of them.
|
|
15
|
+
*
|
|
16
|
+
* A shift counts only when the rows it would skip are exactly the rows already in the scrollback right above
|
|
17
|
+
* the old viewport, the contiguous block of rows above the viewport that matches the shift holds at least one
|
|
18
|
+
* row that really moved (identical rows, such as blank or repeated lines, match both readings and prove
|
|
19
|
+
* nothing), and most of the old visible rows reappear at it. A majority alone is not enough: a tall
|
|
20
|
+
* stable live area matches at any offset after an append, while rows re-laid out in place (a streamed table,
|
|
21
|
+
* #2836) are new rows that must still scroll into the scrollback, so the start stays at the old viewport top.
|
|
22
|
+
*/
|
|
23
|
+
export declare function insertedAboveViewport(previousLines: readonly string[], newLines: readonly string[], prevViewportTop: number, height: number, lineCountDelta: number): number;
|
|
24
|
+
//# sourceMappingURL=viewport-shift.d.ts.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the new frame reads as the old one with `shift` rows inserted above `prevViewportTop`: the rows at
|
|
3
|
+
* `prevViewportTop..+shift` are the old rows right above it, and walking up through the block of rows that keeps
|
|
4
|
+
* matching that reading finds at least one row that really moved. Identical rows (blank or repeated lines) match
|
|
5
|
+
* both readings and prove nothing; the walk stops at the first row the shift does not explain.
|
|
6
|
+
*/
|
|
7
|
+
export function shiftedBlockMoved(previousLines, newLines, prevViewportTop, shift, height) {
|
|
8
|
+
if (shift <= 0 || shift > prevViewportTop)
|
|
9
|
+
return false;
|
|
10
|
+
for (let row = prevViewportTop; row < prevViewportTop + shift; row++) {
|
|
11
|
+
const scrolled = previousLines[row - shift];
|
|
12
|
+
if (scrolled === undefined || scrolled !== newLines[row])
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
const scanFloor = Math.max(shift, prevViewportTop - 2 * height);
|
|
16
|
+
for (let row = prevViewportTop + shift - 1; row >= scanFloor; row--) {
|
|
17
|
+
const line = newLines[row];
|
|
18
|
+
if (line !== previousLines[row - shift])
|
|
19
|
+
return false;
|
|
20
|
+
if (line !== previousLines[row])
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* How far the last frame's visible rows moved down in the new frame because rows were inserted above them.
|
|
27
|
+
*
|
|
28
|
+
* A held repaint writes the frame from a start row and lets the rows above the screen scroll into the
|
|
29
|
+
* terminal's scrollback. Rows that sat above the old viewport are already there, so after an insertion
|
|
30
|
+
* above the viewport (a live tool card growing over a tall /btw panel, senpi#3649) the repaint must start
|
|
31
|
+
* where the old viewport's rows went, or every frame pushes another copy of them.
|
|
32
|
+
*
|
|
33
|
+
* A shift counts only when the rows it would skip are exactly the rows already in the scrollback right above
|
|
34
|
+
* the old viewport, the contiguous block of rows above the viewport that matches the shift holds at least one
|
|
35
|
+
* row that really moved (identical rows, such as blank or repeated lines, match both readings and prove
|
|
36
|
+
* nothing), and most of the old visible rows reappear at it. A majority alone is not enough: a tall
|
|
37
|
+
* stable live area matches at any offset after an append, while rows re-laid out in place (a streamed table,
|
|
38
|
+
* #2836) are new rows that must still scroll into the scrollback, so the start stays at the old viewport top.
|
|
39
|
+
*/
|
|
40
|
+
export function insertedAboveViewport(previousLines, newLines, prevViewportTop, height, lineCountDelta) {
|
|
41
|
+
if (lineCountDelta <= 0)
|
|
42
|
+
return 0;
|
|
43
|
+
const visibleMatchesAt = (shift) => {
|
|
44
|
+
let matches = 0;
|
|
45
|
+
for (let row = 0; row < height; row++) {
|
|
46
|
+
const previous = previousLines[prevViewportTop + row];
|
|
47
|
+
if (previous !== undefined && previous === newLines[prevViewportTop + shift + row])
|
|
48
|
+
matches += 1;
|
|
49
|
+
}
|
|
50
|
+
return matches;
|
|
51
|
+
};
|
|
52
|
+
let best = 0;
|
|
53
|
+
let bestMatches = visibleMatchesAt(0);
|
|
54
|
+
for (let shift = 1; shift <= Math.min(lineCountDelta, prevViewportTop); shift++) {
|
|
55
|
+
if (!shiftedBlockMoved(previousLines, newLines, prevViewportTop, shift, height))
|
|
56
|
+
continue;
|
|
57
|
+
const matches = visibleMatchesAt(shift);
|
|
58
|
+
if (matches > bestMatches) {
|
|
59
|
+
best = shift;
|
|
60
|
+
bestMatches = matches;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return bestMatches * 2 > height ? best : 0;
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=viewport-shift.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WezTerm on Windows runs its shell through the ConPTY it bundles. Bundled
|
|
3
|
+
* ConPTY builds before 1.22 drop the Kitty graphics APC (`ESC _G ... ESC \`)
|
|
4
|
+
* before it reaches WezTerm, so an image placement leaves a blank gap. WezTerm
|
|
5
|
+
* bundled ConPTY 1.22.250204002 in wezterm/wezterm@4accc376f, whose version
|
|
6
|
+
* string is below (committer date in its own zone, see wezterm-version/build.rs);
|
|
7
|
+
* that build and later ones deliver the APC. No stable release has it yet.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MIN_WEZTERM_WINDOWS_IMAGES_VERSION = "20250208-160106";
|
|
10
|
+
export declare const WEZTERM_WINDOWS_IMAGES_HINT = "update WezTerm to a nightly build to show images inline";
|
|
11
|
+
/**
|
|
12
|
+
* True only when WezTerm on Windows positively reports a build older than the
|
|
13
|
+
* first one whose bundled ConPTY passes Kitty graphics through. A missing or
|
|
14
|
+
* unparsable version keeps images on: there is no evidence of an old build.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isOldWindowsWezTerm(version: string | undefined, platform: NodeJS.Platform): boolean;
|
|
17
|
+
//# sourceMappingURL=wezterm-windows-images.d.ts.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WezTerm on Windows runs its shell through the ConPTY it bundles. Bundled
|
|
3
|
+
* ConPTY builds before 1.22 drop the Kitty graphics APC (`ESC _G ... ESC \`)
|
|
4
|
+
* before it reaches WezTerm, so an image placement leaves a blank gap. WezTerm
|
|
5
|
+
* bundled ConPTY 1.22.250204002 in wezterm/wezterm@4accc376f, whose version
|
|
6
|
+
* string is below (committer date in its own zone, see wezterm-version/build.rs);
|
|
7
|
+
* that build and later ones deliver the APC. No stable release has it yet.
|
|
8
|
+
*/
|
|
9
|
+
export const MIN_WEZTERM_WINDOWS_IMAGES_VERSION = "20250208-160106";
|
|
10
|
+
export const WEZTERM_WINDOWS_IMAGES_HINT = "update WezTerm to a nightly build to show images inline";
|
|
11
|
+
// WezTerm sets TERM_PROGRAM_VERSION to `wezterm_version()`: YYYYMMDD-HHMMSS-<commit>.
|
|
12
|
+
const WEZTERM_VERSION = /^(\d{8})-(\d{6})-[0-9a-f]+/i;
|
|
13
|
+
/**
|
|
14
|
+
* True only when WezTerm on Windows positively reports a build older than the
|
|
15
|
+
* first one whose bundled ConPTY passes Kitty graphics through. A missing or
|
|
16
|
+
* unparsable version keeps images on: there is no evidence of an old build.
|
|
17
|
+
*/
|
|
18
|
+
export function isOldWindowsWezTerm(version, platform) {
|
|
19
|
+
if (platform !== "win32" || version === undefined)
|
|
20
|
+
return false;
|
|
21
|
+
const match = WEZTERM_VERSION.exec(version);
|
|
22
|
+
if (!match)
|
|
23
|
+
return false;
|
|
24
|
+
return `${match[1]}-${match[2]}` < MIN_WEZTERM_WINDOWS_IMAGES_VERSION;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=wezterm-windows-images.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
const WSL_ENV_MARKERS = ["WSL_DISTRO_NAME", "WSL_INTEROP", "WSLENV"];
|
|
3
|
+
/** Windows Subsystem for Linux session detection that survives sanitized environments (#3628). */
|
|
4
|
+
export function isWslSession(env, platform = process.platform, procVersionPath = "/proc/version") {
|
|
5
|
+
if (platform !== "linux") {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
if (WSL_ENV_MARKERS.some((marker) => Boolean(env[marker]))) {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
return /microsoft|wsl/i.test(readFileSync(procVersionPath, "utf-8"));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=wsl-session.js.map
|
package/package.json
CHANGED