@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.
@@ -168,7 +168,8 @@ async function walkDirectoryWithFd(baseDir, fdPath, query, maxResults, signal, m
168
168
  resolve(results);
169
169
  };
170
170
  const onAbort = () => {
171
- if (child.exitCode === null) {
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
@@ -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 = 10;
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 { images: "kitty", trueColor: true, hyperlinks: true };
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 };
@@ -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;
@@ -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 { images: "kitty", trueColor: true, hyperlinks: true };
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
- ...detectCapabilities(hyperlinks === undefined ? undefined : () => hyperlinks),
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
- return `[Image: ${parts.join(" ")}]`;
693
+ const hint = getCapabilities().imageHint;
694
+ return hint ? `[Image: ${parts.join(" ")}] (${hint})` : `[Image: ${parts.join(" ")}]`;
661
695
  }
662
696
  //# sourceMappingURL=terminal-image.js.map
@@ -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 need a longer reassembly window.
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 = 10;
165
- const DEFAULT_SSH_ESCAPE_TIMEOUT_MS = 100;
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 need a longer reassembly window.
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 DEFAULT_SSH_ESCAPE_TIMEOUT_MS;
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
  }
@@ -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. Rows the
555
- * document grew by scroll naturally into scrollback in their current form, so nothing is lost there and
556
- * nothing is cleared, and a reader who scrolled up keeps their place. Returns false when an image row is
557
- * involved, so the caller replays as before.
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. Rows the
2203
- * document grew by scroll naturally into scrollback in their current form, so nothing is lost there and
2204
- * nothing is cleared, and a reader who scrolled up keeps their place. Returns false when an image row is
2205
- * involved, so the caller replays as before.
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 start = Math.min(prevViewportTop, Math.max(0, newLines.length - height));
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.createViewportInsertScrollPlan(newLines, prevViewportTop, height, lineCountDelta);
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,3 @@
1
+ /** Windows Subsystem for Linux session detection that survives sanitized environments (#3628). */
2
+ export declare function isWslSession(env: NodeJS.ProcessEnv, platform?: NodeJS.Platform, procVersionPath?: string): boolean;
3
+ //# sourceMappingURL=wsl-session.d.ts.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-tui",
3
- "version": "2026.10.18-1",
3
+ "version": "2026.10.19-1",
4
4
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",