@code-yeongyu/senpi-tui 2026.10.18-1 → 2026.10.19

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.
@@ -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
@@ -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,8 @@ 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;
9
11
  }
10
12
  export interface DetectedTerminalCapabilities extends TerminalCapabilities {
11
13
  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 };
@@ -3,6 +3,7 @@ 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 = {};
@@ -53,7 +54,9 @@ function detectCapabilitiesFromEnvironment(tmuxForwardsHyperlink) {
53
54
  return { images: "kitty", trueColor: true, hyperlinks: true };
54
55
  }
55
56
  if (process.env.WEZTERM_PANE || termProgram === "wezterm") {
56
- return { images: "kitty", trueColor: true, hyperlinks: true };
57
+ return isOldWindowsWezTerm(process.env.TERM_PROGRAM_VERSION, process.platform)
58
+ ? { images: null, trueColor: true, hyperlinks: true, imageHint: WEZTERM_WINDOWS_IMAGES_HINT }
59
+ : { images: "kitty", trueColor: true, hyperlinks: true };
57
60
  }
58
61
  // Warp supports the Kitty graphics protocol and OSC 8 hyperlinks.
59
62
  if (termProgram === "warpterminal" || process.env.WARP_SESSION_ID || process.env.WARP_TERMINAL_SESSION_UUID) {
@@ -86,6 +89,11 @@ function detectCapabilitiesFromEnvironment(tmuxForwardsHyperlink) {
86
89
  function parseBooleanCapabilityOverride(value) {
87
90
  return value === "1" ? true : value === "0" ? false : undefined;
88
91
  }
92
+ // The hint explains why detection turned images off; an explicit image choice makes it wrong.
93
+ function withoutImageHint(caps) {
94
+ const { imageHint: _imageHint, ...rest } = caps;
95
+ return rest;
96
+ }
89
97
  export function detectCapabilities(tmuxForwardsHyperlink, tmuxPassthroughState) {
90
98
  if (!tmuxForwardsHyperlink || !tmuxPassthroughState) {
91
99
  const hyperlinks = parseBooleanCapabilityOverride(process.env.PI_HYPERLINKS);
@@ -98,7 +106,7 @@ export function detectCapabilities(tmuxForwardsHyperlink, tmuxPassthroughState)
98
106
  : undefined;
99
107
  const trueColor = parseBooleanCapabilityOverride(process.env.PI_TRUE_COLOR);
100
108
  return {
101
- ...detected,
109
+ ...(images !== undefined ? withoutImageHint(detected) : detected),
102
110
  ...(images !== undefined ? { images } : {}),
103
111
  ...(trueColor !== undefined ? { trueColor } : {}),
104
112
  ...(hyperlinks !== undefined ? { hyperlinks } : {}),
@@ -125,8 +133,9 @@ export function detectCapabilities(tmuxForwardsHyperlink, tmuxPassthroughState)
125
133
  export function getCapabilities() {
126
134
  if (!cachedCapabilities) {
127
135
  const hyperlinks = capabilityOverrides.hyperlinks;
136
+ const detected = detectCapabilities(hyperlinks === undefined ? undefined : () => hyperlinks);
128
137
  cachedCapabilities = {
129
- ...detectCapabilities(hyperlinks === undefined ? undefined : () => hyperlinks),
138
+ ...(capabilityOverrides.images !== undefined ? withoutImageHint(detected) : detected),
130
139
  ...capabilityOverrides,
131
140
  };
132
141
  }
@@ -142,7 +151,8 @@ export function resetCapabilitiesCache() {
142
151
  export function setCapabilityOverrides(overrides) {
143
152
  if (capabilityOverrides.images === overrides.images &&
144
153
  capabilityOverrides.trueColor === overrides.trueColor &&
145
- capabilityOverrides.hyperlinks === overrides.hyperlinks) {
154
+ capabilityOverrides.hyperlinks === overrides.hyperlinks &&
155
+ capabilityOverrides.imageHint === overrides.imageHint) {
146
156
  return;
147
157
  }
148
158
  capabilityOverrides = { ...overrides };
@@ -657,6 +667,7 @@ export function imageFallback(mimeType, dimensions, filename) {
657
667
  parts.push(`[${sanitizeTerminalLabel(mimeType)}]`);
658
668
  if (dimensions)
659
669
  parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`);
660
- return `[Image: ${parts.join(" ")}]`;
670
+ const hint = getCapabilities().imageHint;
671
+ return hint ? `[Image: ${parts.join(" ")}] (${hint})` : `[Image: ${parts.join(" ")}]`;
661
672
  }
662
673
  //# 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
@@ -391,6 +391,11 @@ export declare abstract class TuiBase extends Container {
391
391
  acquireMouseCapture(reason: string): () => void;
392
392
  private resetMouseCaptureState;
393
393
  protected get mouseCaptureEnabled(): boolean;
394
+ /**
395
+ * Whether mouse reports reach the app right now: `"off"` without an enabled lease, `"armed"` while
396
+ * they do, `"yielded"` while a renderer has handed the mouse back to the terminal (main-screen wheel).
397
+ */
398
+ mouseCaptureState(): "off" | "armed" | "yielded";
394
399
  protected applyMouseTracking(_enabled: boolean): void;
395
400
  protected getMouseLayoutRoots(): readonly Component[];
396
401
  protected setMouseBlocker(name: "suspended" | "external-editor" | "shutting-down", on: boolean): void;
@@ -546,15 +551,15 @@ export declare abstract class TuiBase extends Container {
546
551
  private deleteChangedKittyImages;
547
552
  private getViewportRows;
548
553
  private shouldPreserveMuxScrollback;
549
- private createViewportInsertScrollPlan;
550
554
  private renderViewportInsertScroll;
551
555
  private renderScrollbackReplay;
552
556
  /**
553
557
  * 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.
558
+ * shows them (possibly stale), and the frame is rewritten from the old viewport top down, or from where the
559
+ * old viewport's rows moved to when rows were inserted above it (senpi#3649), so rows already in the
560
+ * scrollback are not written into it again. Rows the document grew by scroll naturally into scrollback in
561
+ * their current form, so nothing is lost there and nothing is cleared, and a reader who scrolled up keeps
562
+ * their place. Returns false when an image row is involved, so the caller replays as before.
558
563
  */
559
564
  private renderHeldRepaint;
560
565
  private renderMuxViewportRepaint;
@@ -594,6 +599,12 @@ export declare abstract class TuiBase extends Container {
594
599
  onLateReply?: (colors: TerminalColors) => void;
595
600
  }): Promise<TerminalColors>;
596
601
  }
602
+ /**
603
+ * Input that the terminal sends on its own rather than the user typing: mouse reports, OSC/DCS/APC
604
+ * replies, DEC private reports (`ESC[?...`) and window/cell-size reports (`ESC[...t`). OSC/DCS/APC need
605
+ * a body after the introducer, so a legacy Alt+] / Alt+Shift+P / Alt+_ key press is still a key.
606
+ */
607
+ export declare function isTerminalReport(data: string): boolean;
597
608
  /** Legacy main-screen renderer export. */
598
609
  export declare class TUI extends TuiBase {
599
610
  readonly mode: TuiMode;
package/dist/tui.js CHANGED
@@ -12,6 +12,8 @@ import { parseOscColorResponse, parseTerminalColorSchemeReport, } from "./termin
12
12
  import { deleteKittyImage, getCapabilities, isImageLine, resetCapabilitiesCache, setCellDimensions, } from "./terminal-image.js";
13
13
  import { consumeTmuxFocusEvent, DISABLE_FOCUS_REPORTING, ENABLE_FOCUS_REPORTING } from "./tmux-focus.js";
14
14
  import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
15
+ import { planViewportInsertScroll } from "./viewport-insert-scroll.js";
16
+ import { insertedAboveViewport } from "./viewport-shift.js";
15
17
  const KITTY_SEQUENCE_PREFIX = "\x1b_G";
16
18
  const MAX_RENDER_WRITE_CHARS = 1024 * 1024;
17
19
  function writeBounded(terminal, data) {
@@ -148,8 +150,6 @@ function defaultDiagnosticLogDirectory() {
148
150
  */
149
151
  let renderErrorLogDirectory;
150
152
  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
153
  const viewportRenderStats = {
154
154
  lastNormalizedLines: 0,
155
155
  lastKittyImageScannedLines: 0,
@@ -728,6 +728,13 @@ export class TuiBase extends Container {
728
728
  get mouseCaptureEnabled() {
729
729
  return this.mouseLeases.size > 0 && this.mouseBlockers.size === 0;
730
730
  }
731
+ /**
732
+ * Whether mouse reports reach the app right now: `"off"` without an enabled lease, `"armed"` while
733
+ * they do, `"yielded"` while a renderer has handed the mouse back to the terminal (main-screen wheel).
734
+ */
735
+ mouseCaptureState() {
736
+ return this.mouseCaptureEnabled ? "armed" : "off";
737
+ }
731
738
  applyMouseTracking(_enabled) { }
732
739
  getMouseLayoutRoots() {
733
740
  return [...this.getMountedRoots(), ...this.renderedOverlayLayouts.map((layout) => layout.entry.component)];
@@ -2097,45 +2104,6 @@ export class TuiBase extends Container {
2097
2104
  shouldPreserveMuxScrollback() {
2098
2105
  return this.#muxDetector() && !useLegacyMuxRender();
2099
2106
  }
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
2107
  renderViewportInsertScroll(plan, newLines, rawLines, cursorPos, width, height) {
2140
2108
  let buffer = TUI.FRAME_BEGIN;
2141
2109
  const regionTop = 0;
@@ -2199,13 +2167,15 @@ export class TuiBase extends Container {
2199
2167
  }
2200
2168
  /**
2201
2169
  * 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.
2170
+ * shows them (possibly stale), and the frame is rewritten from the old viewport top down, or from where the
2171
+ * old viewport's rows moved to when rows were inserted above it (senpi#3649), so rows already in the
2172
+ * scrollback are not written into it again. Rows the document grew by scroll naturally into scrollback in
2173
+ * their current form, so nothing is lost there and nothing is cleared, and a reader who scrolled up keeps
2174
+ * their place. Returns false when an image row is involved, so the caller replays as before.
2206
2175
  */
2207
2176
  renderHeldRepaint(newLines, rawLines, cursorPos, width, height, prevViewportTop, hardwareCursorRow) {
2208
- const start = Math.min(prevViewportTop, Math.max(0, newLines.length - height));
2177
+ const shift = insertedAboveViewport(this.previousLines, newLines, prevViewportTop, height, newLines.length - this.previousLines.length);
2178
+ const start = Math.min(prevViewportTop + shift, Math.max(0, newLines.length - height));
2209
2179
  const rowCount = Math.max(height, newLines.length - start);
2210
2180
  const rows = Array.from({ length: rowCount }, (_, index) => newLines[start + index] ?? "");
2211
2181
  const previousVisible = this.getViewportRows(this.previousLines, prevViewportTop, height);
@@ -2563,7 +2533,9 @@ export class TuiBase extends Container {
2563
2533
  lastChanged = expandedRange.lastChanged;
2564
2534
  }
2565
2535
  const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
2566
- const insertScrollPlan = this.createViewportInsertScrollPlan(newLines, prevViewportTop, height, lineCountDelta);
2536
+ const insertScrollPlan = this.overlayStack.length > 0
2537
+ ? undefined
2538
+ : planViewportInsertScroll(this.previousLines, newLines, prevViewportTop, height, lineCountDelta);
2567
2539
  // No changes - but still need to update hardware cursor position if it moved
2568
2540
  if (firstChanged === -1) {
2569
2541
  this.positionHardwareCursor(cursorPos, newLines.length);
@@ -2948,7 +2920,7 @@ export class TuiBase extends Container {
2948
2920
  * replies, DEC private reports (`ESC[?...`) and window/cell-size reports (`ESC[...t`). OSC/DCS/APC need
2949
2921
  * a body after the introducer, so a legacy Alt+] / Alt+Shift+P / Alt+_ key press is still a key.
2950
2922
  */
2951
- function isTerminalReport(data) {
2923
+ export function isTerminalReport(data) {
2952
2924
  return (data.startsWith("\x1b[<") ||
2953
2925
  data.startsWith("\x1b[M") ||
2954
2926
  (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",
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",