@gajae-code/tui 0.12.11 → 0.12.12

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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.12.12] - 2026-08-05
6
+ ### Fixed
7
+
8
+ - Restored 60 fps time-dependent loader gradients on direct local terminals while retaining the 80 ms cadence and congestion dropping on SSH and multiplexed terminals.
9
+ - Bounded loader animation scheduling to 80 ms, stopped OSC 11 polling after DA1 proves the query unsupported while preserving Mode 2031 recovery, and added the default-on `GJC_TUI_SYNCHRONIZED_OUTPUT=0` compatibility opt-out for terminal parsers that mishandle synchronized-output framing. Real iOS-client validation remains pending for #3798.
10
+ - Decorative animation ticks now pause while stdout has more than 64 KiB buffered, preventing slow SSH terminals and multiplexers from accumulating stale spinner frames.
11
+
5
12
  ## [0.12.11] - 2026-08-03
6
13
 
7
14
  ## [0.12.10] - 2026-08-03
@@ -22,6 +29,7 @@
22
29
  - Restored the `isProcessTerminal`/`shouldUseViewportRepaintForHost` gate on the width-change viewport-repaint intercept so plain terminals (non-multiplexer, non-process-terminal) use `fullRender` for width changes instead of an unconditional viewport repaint. The #3684 chain removed this gate, causing lossless Korean/CJK prose wrapping to break at narrow widths because the viewport repaint only painted the visible rows without committing the full transcript to scrollback (#1979).
23
30
  - Restored the `fullRender` fallback for the `firstChanged < viewportTop` branch on non-viewport-repaint hosts, so above-viewport mutations replay the full frame instead of silently viewport-repainting.
24
31
  - Propagated IME cursor write failure from `#writeRenderBufferAndReanchorImeCursor` so callers detect terminal detach when the deferred cursor write fails after the shared frame commits.
32
+ - The win32 viewport-repaint fallback no longer outranks a terminal that reports `isProcessTerminal: false`. Platform identity exists to recognize Windows console hosts that cannot report the capability, so a terminal that has answered now decides; previously every non-process terminal on Windows (embedders, pipes, the render regression suite) inherited viewport-repaint semantics, suppressed durable history replay, and left contracted rows behind as duplicates.
25
33
 
26
34
  ## [0.12.7] - 2026-07-31
27
35
 
@@ -8,6 +8,9 @@ export declare const __animationSchedulerTestHooks: {
8
8
  getActiveTimerCount(cadence?: AnimationCadence): number;
9
9
  getRegistrantCount(cadence?: AnimationCadence): number;
10
10
  getStartedTimerCount(cadence?: AnimationCadence): number;
11
+ getSkippedTickCount(): number;
12
+ getCongestionThresholdBytes(): number;
13
+ setBufferedOutputBytesProbe(probe: (() => number | undefined) | undefined): void;
11
14
  reset(): void;
12
15
  };
13
16
  export {};
@@ -1,5 +1,14 @@
1
1
  import type { TUI } from "../tui";
2
2
  import { Text } from "./text";
3
+ /**
4
+ * Compatibility options for existing loader call sites.
5
+ *
6
+ * @deprecated `timeDependentColor` preserves the historical smooth-animation
7
+ * contract: direct local terminals reevaluate colorizers at 60 fps, while
8
+ * remote or multiplexed terminals stay on the shared 80 ms cadence to avoid
9
+ * output churn. The field remains accepted for downstream callers while they
10
+ * migrate to an explicit animation policy.
11
+ */
3
12
  export interface LoaderOptions {
4
13
  timeDependentColor?: boolean;
5
14
  }
@@ -7,6 +16,8 @@ export interface LoaderOptions {
7
16
  export declare const __loaderPerfCounters: {
8
17
  liveIntervals: number;
9
18
  startedIntervals: number;
19
+ callbackInvocations: number;
20
+ renderRequests: number;
10
21
  reset(): void;
11
22
  };
12
23
  export declare class Loader extends Text {
@@ -14,6 +25,7 @@ export declare class Loader extends Text {
14
25
  private spinnerColorFn;
15
26
  private messageColorFn;
16
27
  private message;
28
+ private options;
17
29
  constructor(ui: TUI, spinnerColorFn: (str: string) => string, messageColorFn: (str: string) => string, message?: string, spinnerFrames?: string[], options?: LoaderOptions);
18
30
  render(width: number): string[];
19
31
  start(): void;
@@ -22,6 +22,8 @@ export declare class TerminalInfo {
22
22
  sendNotification(message: string): void;
23
23
  }
24
24
  export declare function isNotificationSuppressed(): boolean;
25
+ /** Returns whether stdout crosses an SSH transport, where animation bytes can outpace the link. */
26
+ export declare function isRemoteTerminalSession(env?: NodeJS.ProcessEnv): boolean;
25
27
  /**
26
28
  * Returns whether the process runs under a terminal multiplexer (tmux, GNU
27
29
  * screen, or zellij). Recognizes the same host markers as the renderer's
@@ -179,6 +179,18 @@ export declare function shouldUseViewportRepaintForHost(env?: Record<string, str
179
179
  includeNativeWindows?: boolean;
180
180
  includeProcessTerminal?: boolean;
181
181
  }): boolean;
182
+ /**
183
+ * Viewport-repaint host gate resolved against a terminal's reported capability.
184
+ *
185
+ * `includeNativeWindows` exists so a Windows console host that cannot report
186
+ * `isProcessTerminal` is still recognized from platform identity. It is a
187
+ * fallback, so it must not outrank a terminal that has answered: a terminal
188
+ * reporting `false` is not a native console host, and letting win32 override it
189
+ * gives every non-process terminal on Windows — embedders, pipes, and the
190
+ * render regression suite — viewport-repaint semantics. Those hosts then never
191
+ * replay durable history, so contracted rows survive as duplicates.
192
+ */
193
+ export declare function shouldUseViewportRepaintForTerminal(isProcessTerminal: boolean | undefined, env?: Record<string, string | undefined>, platform?: NodeJS.Platform): boolean;
182
194
  /**
183
195
  * Options for overlay positioning and sizing.
184
196
  * Values can be absolute numbers or percentage strings (e.g., "50%").
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.12.11",
4
+ "version": "0.12.12",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -36,8 +36,8 @@
36
36
  "fmt": "biome format --write ."
37
37
  },
38
38
  "dependencies": {
39
- "@gajae-code/natives": "0.12.11",
40
- "@gajae-code/utils": "0.12.11",
39
+ "@gajae-code/natives": "0.12.12",
40
+ "@gajae-code/utils": "0.12.12",
41
41
  "lru-cache": "11.3.6",
42
42
  "marked": "18.0.6"
43
43
  },
@@ -9,6 +9,28 @@ interface CadenceBucket {
9
9
  startedTimers: number;
10
10
  }
11
11
 
12
+ // Animation ticks are decorative. When the output sink cannot keep up — a
13
+ // remote terminal over SSH, a multiplexer, a slow pipe — those frames are not
14
+ // merely wasted, they queue. Bun buffers whatever `write()` could not hand to
15
+ // the OS, so the renderer runs ahead of the wire and the user watches a backlog
16
+ // drain instead of the current frame.
17
+ //
18
+ // Dropping a decorative tick loses nothing: the next one redraws from live state.
19
+ // Skipping is therefore always safe here, and must never be extended to renders
20
+ // that carry content, which the diff renderer must still emit in order.
21
+ const DEFAULT_CONGESTION_THRESHOLD_BYTES = 64 * 1024;
22
+
23
+ let bufferedOutputBytesProbe: (() => number | undefined) | undefined;
24
+ let skippedTicks = 0;
25
+
26
+ function outputIsCongested(): boolean {
27
+ const stdout = globalThis.process?.stdout as { writableLength?: number } | undefined;
28
+ const buffered = bufferedOutputBytesProbe ? bufferedOutputBytesProbe() : stdout?.writableLength;
29
+ // A healthy TTY drains synchronously and reports 0 here, so this is a no-op
30
+ // locally and only engages once bytes are genuinely stuck.
31
+ return typeof buffered === "number" && buffered > DEFAULT_CONGESTION_THRESHOLD_BYTES;
32
+ }
33
+
12
34
  const buckets = new Map<AnimationCadence, CadenceBucket>();
13
35
 
14
36
  function getBucket(cadence: AnimationCadence): CadenceBucket {
@@ -23,6 +45,12 @@ function getBucket(cadence: AnimationCadence): CadenceBucket {
23
45
  function startBucket(cadence: AnimationCadence, bucket: CadenceBucket): void {
24
46
  if (bucket.timer) return;
25
47
  bucket.timer = setInterval(() => {
48
+ // Skip the whole tick, not each callback: the decision is about the shared
49
+ // output sink, so sampling it once keeps every registrant on the same frame.
50
+ if (outputIsCongested()) {
51
+ skippedTicks += 1;
52
+ return;
53
+ }
26
54
  const now = performance.now();
27
55
  // Snapshot so re-entrant register/unregister during a tick is safe, and
28
56
  // isolate each callback so one throwing registrant cannot starve siblings
@@ -89,11 +117,22 @@ export const __animationSchedulerTestHooks = {
89
117
  for (const bucket of buckets.values()) count += bucket.startedTimers;
90
118
  return count;
91
119
  },
120
+ getSkippedTickCount(): number {
121
+ return skippedTicks;
122
+ },
123
+ getCongestionThresholdBytes(): number {
124
+ return DEFAULT_CONGESTION_THRESHOLD_BYTES;
125
+ },
126
+ setBufferedOutputBytesProbe(probe: (() => number | undefined) | undefined): void {
127
+ bufferedOutputBytesProbe = probe;
128
+ },
92
129
  reset(): void {
93
130
  for (const bucket of buckets.values()) {
94
131
  stopBucket(bucket);
95
132
  bucket.callbacks.clear();
96
133
  bucket.startedTimers = 0;
97
134
  }
135
+ skippedTicks = 0;
136
+ bufferedOutputBytesProbe = undefined;
98
137
  },
99
138
  };
@@ -1,21 +1,42 @@
1
1
  import { type AnimationRegistration, registerAnimationCallback } from "../animation-scheduler";
2
+ import { isRemoteTerminalSession, isUnderTerminalMultiplexer } from "../terminal-capabilities";
2
3
  import type { TUI } from "../tui";
3
4
  import { sliceByColumn, visibleWidth } from "../utils";
4
5
  import { Text } from "./text";
5
6
 
6
7
  const SPINNER_ADVANCE_MS = 80;
7
8
 
9
+ /**
10
+ * Compatibility options for existing loader call sites.
11
+ *
12
+ * @deprecated `timeDependentColor` preserves the historical smooth-animation
13
+ * contract: direct local terminals reevaluate colorizers at 60 fps, while
14
+ * remote or multiplexed terminals stay on the shared 80 ms cadence to avoid
15
+ * output churn. The field remains accepted for downstream callers while they
16
+ * migrate to an explicit animation policy.
17
+ */
8
18
  export interface LoaderOptions {
9
19
  timeDependentColor?: boolean;
10
20
  }
11
21
 
22
+ const SMOOTH_ANIMATION_MS = 16;
23
+
24
+ function resolveAnimationCadence(options: LoaderOptions): 16 | 80 {
25
+ if (options.timeDependentColor !== true) return SPINNER_ADVANCE_MS;
26
+ return isRemoteTerminalSession() || isUnderTerminalMultiplexer() ? SPINNER_ADVANCE_MS : SMOOTH_ANIMATION_MS;
27
+ }
28
+
12
29
  /** Test-only performance counters for advisory baseline tests. */
13
30
  export const __loaderPerfCounters = {
14
31
  liveIntervals: 0,
15
32
  startedIntervals: 0,
33
+ callbackInvocations: 0,
34
+ renderRequests: 0,
16
35
  reset(): void {
17
36
  this.liveIntervals = 0;
18
37
  this.startedIntervals = 0;
38
+ this.callbackInvocations = 0;
39
+ this.renderRequests = 0;
19
40
  },
20
41
  };
21
42
 
@@ -26,7 +47,6 @@ export class Loader extends Text {
26
47
  #ui: TUI | null = null;
27
48
  #lastSpinnerTick = 0;
28
49
  #lastDisplayed?: string;
29
- #timeDependentColor: boolean;
30
50
 
31
51
  constructor(
32
52
  ui: TUI,
@@ -34,11 +54,10 @@ export class Loader extends Text {
34
54
  private messageColorFn: (str: string) => string,
35
55
  private message: string = "Loading...",
36
56
  spinnerFrames?: string[],
37
- options: LoaderOptions = {},
57
+ private options: LoaderOptions = {},
38
58
  ) {
39
59
  super("", 1, 0);
40
60
  this.#ui = ui;
41
- this.#timeDependentColor = options.timeDependentColor ?? false;
42
61
  if (spinnerFrames && spinnerFrames.length > 0) {
43
62
  this.#frames = spinnerFrames;
44
63
  }
@@ -62,16 +81,14 @@ export class Loader extends Text {
62
81
  this.#updateDisplay();
63
82
  __loaderPerfCounters.liveIntervals += 1;
64
83
  __loaderPerfCounters.startedIntervals += 1;
65
- this.#animation = registerAnimationCallback(
66
- now => {
67
- if (now - this.#lastSpinnerTick >= SPINNER_ADVANCE_MS) {
68
- this.#currentFrame = (this.#currentFrame + 1) % this.#frames.length;
69
- this.#lastSpinnerTick = now;
70
- }
71
- this.#updateDisplay();
72
- },
73
- this.#timeDependentColor ? 16 : 80,
74
- );
84
+ this.#animation = registerAnimationCallback(now => {
85
+ __loaderPerfCounters.callbackInvocations += 1;
86
+ if (now - this.#lastSpinnerTick >= SPINNER_ADVANCE_MS) {
87
+ this.#currentFrame = (this.#currentFrame + 1) % this.#frames.length;
88
+ this.#lastSpinnerTick = now;
89
+ }
90
+ this.#updateDisplay();
91
+ }, resolveAnimationCadence(this.options));
75
92
  }
76
93
 
77
94
  stop() {
@@ -97,6 +114,7 @@ export class Loader extends Text {
97
114
  if (next === this.#lastDisplayed) return;
98
115
  this.#lastDisplayed = next;
99
116
  this.setText(next);
117
+ __loaderPerfCounters.renderRequests += 1;
100
118
  this.#ui?.requestRender(false, "loader");
101
119
  }
102
120
  }
@@ -1,6 +1,7 @@
1
1
  import { getKeybindings } from "../keybindings";
2
+ import { extractPrintableText, matchesKey } from "../keys";
2
3
  import type { Component } from "../tui";
3
- import { Ellipsis, padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
4
+ import { Ellipsis, getSegmenter, padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
4
5
 
5
6
  export interface SettingItem {
6
7
  /** Unique identifier for this setting */
@@ -26,7 +27,9 @@ export interface SettingsListTheme {
26
27
  }
27
28
 
28
29
  export class SettingsList implements Component {
30
+ #allItems: SettingItem[];
29
31
  #items: SettingItem[];
32
+ #searchQuery = "";
30
33
  #theme: SettingsListTheme;
31
34
  #selectedIndex = 0;
32
35
  #maxVisible: number;
@@ -48,6 +51,7 @@ export class SettingsList implements Component {
48
51
  onSelectionChange?: (item: SettingItem | undefined) => void,
49
52
  descriptionRows = 0,
50
53
  ) {
54
+ this.#allItems = items;
51
55
  this.#items = items;
52
56
  this.#maxVisible = maxVisible;
53
57
  this.#theme = theme;
@@ -68,7 +72,7 @@ export class SettingsList implements Component {
68
72
 
69
73
  /** Update an item's currentValue */
70
74
  updateValue(id: string, newValue: string): void {
71
- const item = this.#items.find(i => i.id === id);
75
+ const item = this.#allItems.find(i => i.id === id);
72
76
  if (item) {
73
77
  item.currentValue = newValue;
74
78
  }
@@ -82,11 +86,24 @@ export class SettingsList implements Component {
82
86
  * restored index against the new list on the way out.
83
87
  */
84
88
  setItems(items: SettingItem[]): void {
85
- this.#items = items;
89
+ this.#allItems = items;
90
+ this.#items = this.#filterItems();
86
91
  this.#clampSelectedIndex();
87
92
  this.#notifySelectionChange();
88
93
  }
89
94
 
95
+ #filterItems(): SettingItem[] {
96
+ const query = this.#searchQuery.toLocaleLowerCase();
97
+ return query ? this.#allItems.filter(item => item.label.toLocaleLowerCase().includes(query)) : this.#allItems;
98
+ }
99
+
100
+ #setSearchQuery(query: string): void {
101
+ this.#searchQuery = query.normalize("NFC");
102
+ this.#items = this.#filterItems();
103
+ this.#selectedIndex = 0;
104
+ this.#notifySelectionChange();
105
+ }
106
+
90
107
  invalidate(): void {
91
108
  this.#submenuComponent?.invalidate?.();
92
109
  }
@@ -106,9 +123,17 @@ export class SettingsList implements Component {
106
123
 
107
124
  #renderMainList(width: number): string[] {
108
125
  const lines: string[] = [];
126
+ if (this.#searchQuery) {
127
+ lines.push(this.#theme.hint(truncateToWidth(` Search: ${this.#searchQuery}`, width)));
128
+ lines.push("");
129
+ }
109
130
 
110
131
  if (this.#items.length === 0) {
111
- lines.push(this.#theme.hint(" No settings available"));
132
+ lines.push(this.#theme.hint(this.#searchQuery ? " No matching settings" : " No settings available"));
133
+ if (this.#searchQuery) {
134
+ lines.push("");
135
+ lines.push(this.#theme.hint(truncateToWidth(" Type to search · Backspace to edit · Esc to clear", width)));
136
+ }
112
137
  return lines;
113
138
  }
114
139
 
@@ -181,7 +206,10 @@ export class SettingsList implements Component {
181
206
 
182
207
  // Add hint
183
208
  lines.push("");
184
- lines.push(truncateToWidth(this.#theme.hint(" Enter/Space to change · Esc to cancel"), width));
209
+ const hint = this.#searchQuery
210
+ ? " Type to search · Enter to change · Backspace to edit · Esc to clear"
211
+ : " Type to search · Enter/Space to change · Esc to cancel";
212
+ lines.push(truncateToWidth(this.#theme.hint(hint), width));
185
213
 
186
214
  return lines;
187
215
  }
@@ -196,24 +224,44 @@ export class SettingsList implements Component {
196
224
 
197
225
  // Main list input handling
198
226
  const kb = getKeybindings();
199
- if (this.#items.length === 0) {
200
- if (kb.matches(data, "tui.select.cancel")) {
201
- this.#onCancel();
202
- }
203
- return;
204
- }
205
-
206
- if (kb.matches(data, "tui.select.up")) {
227
+ if (this.#items.length > 0 && kb.matches(data, "tui.select.up")) {
207
228
  this.#selectedIndex = this.#selectedIndex === 0 ? this.#items.length - 1 : this.#selectedIndex - 1;
208
229
  this.#notifySelectionChange();
209
- } else if (kb.matches(data, "tui.select.down")) {
230
+ return;
231
+ }
232
+ if (this.#items.length > 0 && kb.matches(data, "tui.select.down")) {
210
233
  this.#selectedIndex = this.#selectedIndex === this.#items.length - 1 ? 0 : this.#selectedIndex + 1;
211
234
  this.#notifySelectionChange();
212
- } else if (kb.matches(data, "tui.select.confirm") || data === " " || data === "\n") {
235
+ return;
236
+ }
237
+ if (
238
+ this.#items.length > 0 &&
239
+ (kb.matches(data, "tui.select.confirm") || data === "\n" || (data === " " && !this.#searchQuery))
240
+ ) {
213
241
  this.#activateItem();
214
- } else if (kb.matches(data, "tui.select.cancel")) {
215
- this.#onCancel();
242
+ return;
243
+ }
244
+ if (kb.matches(data, "tui.select.cancel")) {
245
+ if (this.#searchQuery) {
246
+ this.#setSearchQuery("");
247
+ } else {
248
+ this.#onCancel();
249
+ }
250
+ return;
216
251
  }
252
+ if (this.#searchQuery && matchesKey(data, "backspace")) {
253
+ const graphemes = [...getSegmenter().segment(this.#searchQuery)];
254
+ this.#setSearchQuery(
255
+ graphemes
256
+ .slice(0, -1)
257
+ .map(part => part.segment)
258
+ .join(""),
259
+ );
260
+ return;
261
+ }
262
+
263
+ const printableText = extractPrintableText(data);
264
+ if (printableText) this.#setSearchQuery(this.#searchQuery + printableText);
217
265
  }
218
266
 
219
267
  #activateItem(): void {
@@ -60,6 +60,11 @@ function multiplexerEnvEnabled(value: string | undefined): boolean {
60
60
  return normalized !== undefined && normalized.length > 0 && !MULTIPLEXER_DISABLED_ENV_VALUES.has(normalized);
61
61
  }
62
62
 
63
+ /** Returns whether stdout crosses an SSH transport, where animation bytes can outpace the link. */
64
+ export function isRemoteTerminalSession(env: NodeJS.ProcessEnv = Bun.env): boolean {
65
+ return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
66
+ }
67
+
63
68
  /**
64
69
  * Returns whether the process runs under a terminal multiplexer (tmux, GNU
65
70
  * screen, or zellij). Recognizes the same host markers as the renderer's
package/src/terminal.ts CHANGED
@@ -263,6 +263,8 @@ function unsubscribeFromStdinErrors(subscriber: (err: Error) => void): void {
263
263
  stdinErrorSubscribers.delete(subscriber);
264
264
  if (stdinErrorSubscribers.size === 0) process.stdin.removeListener("error", dispatchStdinError);
265
265
  }
266
+ type Osc11QuerySource = "startup" | "poll" | "mode2031";
267
+ type Osc11QueuedSource = Exclude<Osc11QuerySource, "startup">;
266
268
 
267
269
  /**
268
270
  * Real terminal using process.stdin/stdout
@@ -287,7 +289,7 @@ export class ProcessTerminal implements Terminal {
287
289
  #appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
288
290
  #appearance: TerminalAppearance | undefined;
289
291
  #osc11Pending = false;
290
- #osc11QueryQueued = false;
292
+ #osc11QueuedSource?: Osc11QueuedSource;
291
293
  #osc11ResponseBuffer = "";
292
294
  #privateCsiResponseBuffer = "";
293
295
  #pendingDa1Sentinels = 0;
@@ -409,7 +411,7 @@ export class ProcessTerminal implements Terminal {
409
411
  // sequences in order, so if DA1 arrives before OSC 11 response,
410
412
  // the terminal does not support OSC 11. This avoids indefinite hangs.
411
413
  // Technique used by Neovim, bat, fish, and terminal-colorsaurus.
412
- this.#queryBackgroundColor();
414
+ this.#queryBackgroundColor("startup");
413
415
 
414
416
  // Subscribe to Mode 2031 appearance change notifications.
415
417
  // When the terminal reports a change, we re-query OSC 11 to get the
@@ -571,16 +573,22 @@ export class ProcessTerminal implements Terminal {
571
573
  // already succeeded. Other terminal probes should never see these replies.
572
574
  if (da1ResponsePattern.test(sequence) && this.#pendingDa1Sentinels > 0) {
573
575
  this.#pendingDa1Sentinels--;
574
- if (this.#osc11Pending) {
575
- // DA1 arrived before OSC 11 response: terminal does not support
576
- // OSC 11. Clear the pending state without starting a queued query
577
- // (queued query is started below, after sentinel is consumed).
576
+ const negativeEvidence = this.#osc11Pending;
577
+ const queuedSource = this.#osc11QueuedSource;
578
+ this.#osc11QueuedSource = undefined;
579
+ if (negativeEvidence) {
580
+ // DA1 arrived before OSC 11: this cycle proved OSC 11 unsupported.
581
+ // Stop futile polling, but retain one stronger Mode 2031 push request.
578
582
  this.#osc11Pending = false;
579
583
  this.#osc11ResponseBuffer = "";
580
- }
581
- // Now that this DA1 cycle is complete, start any queued query.
582
- if (this.#osc11QueryQueued && !this.#dead) {
583
- this.#osc11QueryQueued = false;
584
+ this.#clearOsc11QueryWatchdog();
585
+ this.#stopOsc11Poll();
586
+ if (queuedSource === "mode2031" && !this.#dead) {
587
+ this.#startOsc11Query();
588
+ }
589
+ } else if (queuedSource && !this.#dead) {
590
+ // A positive OSC reply arrived first. The delayed sentinel only
591
+ // closes that successful cycle, so preserve its single follow-up.
584
592
  this.#startOsc11Query();
585
593
  }
586
594
  return;
@@ -600,8 +608,9 @@ export class ProcessTerminal implements Terminal {
600
608
  const osc11Match = this.#osc11ResponseBuffer.match(osc11ResponsePattern);
601
609
  if (osc11Match) {
602
610
  const [, rHex, gHex, bHex] = osc11Match;
603
- this.#osc11Pending = false;
604
611
  this.#osc11ResponseBuffer = "";
612
+ if (!this.#osc11Pending) return;
613
+ this.#osc11Pending = false;
605
614
  this.#clearOsc11QueryWatchdog();
606
615
  this.#handleOsc11Response(rHex!, gHex!, bHex!);
607
616
  return;
@@ -628,7 +637,7 @@ export class ProcessTerminal implements Terminal {
628
637
  if (this.#mode2031DebounceTimer) clearTimeout(this.#mode2031DebounceTimer);
629
638
  this.#mode2031DebounceTimer = setTimeout(() => {
630
639
  this.#mode2031DebounceTimer = undefined;
631
- this.#queryBackgroundColor();
640
+ this.#queryBackgroundColor("mode2031");
632
641
  }, 100);
633
642
  return;
634
643
  }
@@ -662,14 +671,14 @@ export class ProcessTerminal implements Terminal {
662
671
  * DA1 avoids indefinite hangs: if DA1 response arrives before OSC 11,
663
672
  * the terminal does not support OSC 11.
664
673
  */
665
- #queryBackgroundColor(): void {
674
+ #queryBackgroundColor(source: Osc11QuerySource): void {
666
675
  if (this.#dead) return;
667
- // Queue if an OSC 11 query is in flight or its DA1 sentinel hasn't been
668
- // consumed yet. Starting a new query while a DA1 is outstanding would
669
- // increment the sentinel counter, and the old DA1 arrival would then
670
- // prematurely clear the new query's pending state.
676
+ // Queue if an OSC 11 query is in flight or its DA1 sentinel has not yet
677
+ // been consumed. Mode 2031 push evidence outranks a periodic poll.
671
678
  if (this.#osc11Pending || this.#pendingDa1Sentinels > 0) {
672
- this.#osc11QueryQueued = true;
679
+ if (source === "mode2031" || (source === "poll" && this.#osc11QueuedSource === undefined)) {
680
+ this.#osc11QueuedSource = source;
681
+ }
673
682
  return;
674
683
  }
675
684
  this.#startOsc11Query();
@@ -698,11 +707,12 @@ export class ProcessTerminal implements Terminal {
698
707
  this.#osc11QueryWatchdog = undefined;
699
708
  if (this.#dead) return;
700
709
  if (!this.#osc11Pending && this.#pendingDa1Sentinels === 0) return;
710
+ const queuedSource = this.#osc11QueuedSource;
711
+ this.#osc11QueuedSource = undefined;
701
712
  this.#osc11Pending = false;
702
713
  this.#osc11ResponseBuffer = "";
703
714
  this.#pendingDa1Sentinels = 0;
704
- if (this.#osc11QueryQueued && !this.#dead) {
705
- this.#osc11QueryQueued = false;
715
+ if (queuedSource && !this.#dead) {
706
716
  this.#startOsc11Query();
707
717
  }
708
718
  }, 1000);
@@ -750,7 +760,7 @@ export class ProcessTerminal implements Terminal {
750
760
  this.#stopOsc11Poll();
751
761
  return;
752
762
  }
753
- this.#queryBackgroundColor();
763
+ this.#queryBackgroundColor("poll");
754
764
  }, 2_000);
755
765
  this.#osc11PollTimer.unref();
756
766
  }
@@ -878,7 +888,7 @@ export class ProcessTerminal implements Terminal {
878
888
  }
879
889
  this.#appearanceCallbacks = [];
880
890
  this.#osc11Pending = false;
881
- this.#osc11QueryQueued = false;
891
+ this.#osc11QueuedSource = undefined;
882
892
  this.#osc11ResponseBuffer = "";
883
893
  this.#privateCsiResponseBuffer = "";
884
894
  this.#pendingDa1Sentinels = 0;
package/src/tui.ts CHANGED
@@ -430,6 +430,28 @@ export function shouldUseViewportRepaintForHost(
430
430
  return isViewportSensitiveHost(env, platform, includeNativeWindows, includeProcessTerminal);
431
431
  }
432
432
 
433
+ /**
434
+ * Viewport-repaint host gate resolved against a terminal's reported capability.
435
+ *
436
+ * `includeNativeWindows` exists so a Windows console host that cannot report
437
+ * `isProcessTerminal` is still recognized from platform identity. It is a
438
+ * fallback, so it must not outrank a terminal that has answered: a terminal
439
+ * reporting `false` is not a native console host, and letting win32 override it
440
+ * gives every non-process terminal on Windows — embedders, pipes, and the
441
+ * render regression suite — viewport-repaint semantics. Those hosts then never
442
+ * replay durable history, so contracted rows survive as duplicates.
443
+ */
444
+ export function shouldUseViewportRepaintForTerminal(
445
+ isProcessTerminal: boolean | undefined,
446
+ env: Record<string, string | undefined> = Bun.env,
447
+ platform: NodeJS.Platform = process.platform,
448
+ ): boolean {
449
+ return shouldUseViewportRepaintForHost(env, platform, {
450
+ includeNativeWindows: isProcessTerminal !== false,
451
+ includeProcessTerminal: isProcessTerminal === true,
452
+ });
453
+ }
454
+
433
455
  /**
434
456
  * Options for overlay positioning and sizing.
435
457
  * Values can be absolute numbers or percentage strings (e.g., "50%").
@@ -903,6 +925,8 @@ export class TUI extends Container {
903
925
  // showHardwareCursor=false but cursor is shown for IME anchoring (macOS).
904
926
  #imeCursorActive = false;
905
927
  #clearOnShrink = $pickflag("GJC_CLEAR_ON_SHRINK", "PI_CLEAR_ON_SHRINK");
928
+ #synchronizedOutputBegin = "";
929
+ #synchronizedOutputEnd = "";
906
930
 
907
931
  // Default-on: reuse the previous normalized off-screen prefix and only normalize/diff the
908
932
  // visible window, bounding per-frame work on huge transcripts. Output stays byte-identical;
@@ -997,6 +1021,9 @@ export class TUI extends Container {
997
1021
  this.terminal = terminal;
998
1022
  this.#legacyMultiplexerFullRender =
999
1023
  isMultiplexerSession(Bun.env) && envIsEnabled(Bun.env.PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER);
1024
+ const synchronizedOutputEnabled = $flag("GJC_TUI_SYNCHRONIZED_OUTPUT", true);
1025
+ this.#synchronizedOutputBegin = synchronizedOutputEnabled ? "\x1b[?2026h" : "";
1026
+ this.#synchronizedOutputEnd = synchronizedOutputEnabled ? "\x1b[?2026l" : "";
1000
1027
  if (showHardwareCursor !== undefined) {
1001
1028
  this.#showHardwareCursor = showHardwareCursor;
1002
1029
  }
@@ -1671,6 +1698,10 @@ export class TUI extends Container {
1671
1698
  return this.#guardTerminalOperation(() => this.terminal.write(data), !deferRenderFailure);
1672
1699
  }
1673
1700
 
1701
+ #frameSynchronizedOutput(payload: string): string {
1702
+ return `${this.#synchronizedOutputBegin}${payload}${this.#synchronizedOutputEnd}`;
1703
+ }
1704
+
1674
1705
  #hideCursor(): boolean {
1675
1706
  return this.#guardTerminalOperation(() => this.terminal.hideCursor());
1676
1707
  }
@@ -1939,6 +1970,11 @@ export class TUI extends Container {
1939
1970
  this.#forcedRenderQueued = false;
1940
1971
  }
1941
1972
 
1973
+ /** Host gate for viewport-repaint decisions, resolved against this terminal. */
1974
+ #viewportRepaintHost(): boolean {
1975
+ return shouldUseViewportRepaintForTerminal(this.terminal.isProcessTerminal);
1976
+ }
1977
+
1942
1978
  /**
1943
1979
  * Viewport-repaint-aware resize render request.
1944
1980
  *
@@ -1962,13 +1998,7 @@ export class TUI extends Container {
1962
1998
  this.#lastObservedWidth = observedWidth;
1963
1999
  const heightChanged = this.#previousHeight !== this.terminal.rows;
1964
2000
  if (widthChanged) this.#scheduleWidthSettleRedraw();
1965
- this.requestRender(
1966
- heightChanged &&
1967
- !shouldUseViewportRepaintForHost(Bun.env, process.platform, {
1968
- includeProcessTerminal: this.terminal.isProcessTerminal === true,
1969
- }),
1970
- "resize",
1971
- );
2001
+ this.requestRender(heightChanged && !this.#viewportRepaintHost(), "resize");
1972
2002
  }
1973
2003
 
1974
2004
  /**
@@ -3135,7 +3165,7 @@ export class TUI extends Container {
3135
3165
  { top: transcriptLineCount, bottom: transcriptLineCount + suffixLineCount },
3136
3166
  ]
3137
3167
  : [{ top: nextViewportTop, bottom: nextViewportTop + height }];
3138
- let buffer = `\x1b[?2026h${deletePlan.output}`;
3168
+ let buffer = deletePlan.output;
3139
3169
  buffer += "\x1b[H";
3140
3170
  const committedTranscriptRows: Array<number | null> = [];
3141
3171
  for (let screenRow = 0; screenRow < height; screenRow++) {
@@ -3175,7 +3205,7 @@ export class TUI extends Container {
3175
3205
  cursorToRow = cursor.toRow;
3176
3206
  }
3177
3207
  buffer += cursorSeq;
3178
- buffer += "\x1b[?2026l";
3208
+ buffer = this.#frameSynchronizedOutput(buffer);
3179
3209
  let contentWritten = false;
3180
3210
  const writeSucceeded = this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, lines.length, () => {
3181
3211
  contentWritten = true;
@@ -3564,10 +3594,7 @@ export class TUI extends Container {
3564
3594
  return;
3565
3595
  }
3566
3596
  // Helper to clear scrollback and viewport and render all new lines
3567
- const shouldPreserveScrollbackOnFullClear =
3568
- shouldUseViewportRepaintForHost(Bun.env, process.platform, {
3569
- includeProcessTerminal: this.terminal.isProcessTerminal === true,
3570
- }) || this.#legacyMultiplexerFullRender;
3597
+ const shouldPreserveScrollbackOnFullClear = this.#viewportRepaintHost() || this.#legacyMultiplexerFullRender;
3571
3598
  let viewportRepaint: (
3572
3599
  reason: string,
3573
3600
  targetViewportTopOrAllowPastLiveBottom?: number | boolean,
@@ -3591,7 +3618,7 @@ export class TUI extends Container {
3591
3618
  [],
3592
3619
  clear,
3593
3620
  );
3594
- let buffer = `\x1b[?2026h${deletePlan.output}`; // Begin synchronized output
3621
+ let buffer = deletePlan.output;
3595
3622
  // Skip clearing scrollback (3J) in hosts where clear/replay can snap the
3596
3623
  // native viewport away from the live prompt (tmux/screen, Windows ConPTY) —
3597
3624
  // unless the caller explicitly needs history erased (the settled width
@@ -3609,7 +3636,7 @@ export class TUI extends Container {
3609
3636
  const cursorRow = Math.max(0, newLines.length - 1);
3610
3637
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, cursorRow);
3611
3638
  buffer += seq;
3612
- buffer += "\x1b[?2026l"; // End synchronized output
3639
+ buffer = this.#frameSynchronizedOutput(buffer);
3613
3640
  if (
3614
3641
  !this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
3615
3642
  this.#cursorRow = cursorRow;
@@ -3763,9 +3790,7 @@ export class TUI extends Container {
3763
3790
  fullRender(true, "width settled", true);
3764
3791
  return;
3765
3792
  }
3766
- const useViewportRepaintPath = shouldUseViewportRepaintForHost(Bun.env, process.platform, {
3767
- includeProcessTerminal: this.terminal.isProcessTerminal === true,
3768
- });
3793
+ const useViewportRepaintPath = this.#viewportRepaintHost();
3769
3794
  const widthReflowRequired =
3770
3795
  this.#previousWidth > 0 &&
3771
3796
  rawLines.some(
@@ -3811,7 +3836,9 @@ export class TUI extends Container {
3811
3836
  .slice(0, this.#restartDurableLineCount)
3812
3837
  .every((line, index) => line === this.#restartDurableRawLines[index]);
3813
3838
  if (restartAppendProven && rawLines.length > this.#restartDurableLineCount) {
3814
- const appendBuffer = `\x1b[?2026h${newLines.slice(this.#restartDurableLineCount).join("\r\n")}\x1b[?2026l`;
3839
+ const appendBuffer = this.#frameSynchronizedOutput(
3840
+ newLines.slice(this.#restartDurableLineCount).join("\r\n"),
3841
+ );
3815
3842
  if (!this.#writeTerminal(appendBuffer)) return;
3816
3843
  // The append already reached native scrollback. Advance both the live
3817
3844
  // frontier and the retained restart baseline before the viewport write:
@@ -4057,7 +4084,7 @@ export class TUI extends Container {
4057
4084
  const deletePlan = this.#kittyPlacementDeletePlan(previousKittyPlacementSpans, nextKittyPlacementSpans, [
4058
4085
  { top: firstChanged, bottom: lastChanged + 1 },
4059
4086
  ]);
4060
- let buffer = `\x1b[?2026h${deletePlan.output}`;
4087
+ let buffer = deletePlan.output;
4061
4088
  // Move to end of new content (clamp to 0 for empty content)
4062
4089
  const targetRow = Math.max(0, newLines.length - 1);
4063
4090
  const lineDiff = computeLineDiff(targetRow);
@@ -4085,7 +4112,7 @@ export class TUI extends Container {
4085
4112
  }
4086
4113
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, targetRow);
4087
4114
  buffer += seq;
4088
- buffer += "\x1b[?2026l";
4115
+ buffer = this.#frameSynchronizedOutput(buffer);
4089
4116
  if (
4090
4117
  !this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length, () => {
4091
4118
  this.#cursorRow = targetRow;
@@ -4226,7 +4253,7 @@ export class TUI extends Container {
4226
4253
  const deletePlan = this.#kittyPlacementDeletePlan(previousKittyPlacementSpans, nextKittyPlacementSpans, [
4227
4254
  { top: firstChanged, bottom: lastChanged + 1 },
4228
4255
  ]);
4229
- let buffer = `\x1b[?2026h${deletePlan.output}`; // Begin synchronized output
4256
+ let buffer = deletePlan.output;
4230
4257
  const prevViewportBottom = prevViewportTop + height - 1;
4231
4258
  const nativeScrollbackAdmission =
4232
4259
  appendedLines &&
@@ -4341,7 +4368,7 @@ export class TUI extends Container {
4341
4368
 
4342
4369
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, finalCursorRow);
4343
4370
  buffer += seq;
4344
- buffer += "\x1b[?2026l"; // End synchronized output
4371
+ buffer = this.#frameSynchronizedOutput(buffer);
4345
4372
 
4346
4373
  if ($pickflag("GJC_TUI_DEBUG", "PI_TUI_DEBUG")) {
4347
4374
  const debugDir = "/tmp/tui";
@@ -4490,7 +4517,7 @@ export class TUI extends Container {
4490
4517
  // DECSC/DECRC keep the hardware cursor stable; the dedicated
4491
4518
  // synchronized block prevents visible tearing while the overlay
4492
4519
  // area is cleared and redrawn.
4493
- const overlayBuffer = `\x1b[?2026h\x1b7${overlay}\x1b8\x1b[?2026l`;
4520
+ const overlayBuffer = this.#frameSynchronizedOutput(`\x1b7${overlay}\x1b8`);
4494
4521
  // Overlay delivery is outside shared transcript ownership. The
4495
4522
  // shared write has already committed even when this exempt write
4496
4523
  // fails, so do not make callers retry the shared bytes.