@gajae-code/tui 0.11.11 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [0.11.11] - 2026-07-26
5
+ ## [0.12.1] - 2026-07-29
6
+
7
+ ### Fixed
8
+
9
+ - Kitty/Ghostty inline images no longer remain visually pinned when sticky or semantic viewport repaints move their anchors into application scrollback. The renderer now soft-deletes only the named placement from the old viewport, retains uploaded pixels for history replay, and keeps placement tracking aligned across unresolved-anchor and follow-live transitions.
10
+
11
+ ## [0.12.0] - 2026-07-28
12
+
13
+ ### Changed
14
+
15
+ - Mouse wheel scrolling now moves the session viewport by exactly three lines (`DEFAULT_WHEEL_LINES = 3`) instead of a full page. PageUp/PageDown keep page-sized steps with edge pinning.
16
+ - Manual transcript scrolling now keeps a valid registered status/composer boundary fixed at the bottom independently of output-source registration. Output sources control only the exact semantic new-output notice; transcript selection excludes pinned chrome, short transcript lanes emit blanks instead of duplicating suffix rows, and constrained heights retain the focused suffix component before decorative rows.
17
+ - Manual viewport revisions now advance for semantic changes in a visible capped sidebar even without an inline component; duplicate, elided, hidden, geometry-only, and theme-only changes do not raise a false new-output notice. Equal output-source updates do not render, and constrained pinned suffixes avoid copying transcript-length prefixes.
18
+ - A downward manual scroll (wheel or `PageDown`) that reaches the true transcript bottom now transitions through the existing live-follow transaction instead of repainting another manual frame, so wheel and PageDown automatically return to live output at the bottom. A partial downward movement retains manual ownership and the notice; upward movement never follows. The transition preserves editor focus, pinned chrome, notice clearing, and fatal terminal transaction semantics, clears manual anchor state through the existing transaction, and does not replay manual-era output into native/host scrollback.
19
+
20
+ ### Fixed
21
+
22
+ - Slash-command autocomplete no longer treats the final segment of a nested filesystem path or URL as a command token, preventing accepted skill suggestions from rewriting literal paths.
23
+
24
+ - Terminal capability-probe replies no longer leak into the prompt as text (`^[]11;rgb:0000/0000/0000^G^[[?62;22;52c` appearing in the editor after a long-running foreground command). Three separate paths fed them to the input handler: replies whose pending-query counters had already been reset (`stop()`/`start()` around an editor handoff, Ctrl+Z, or a session resume) failed the `#pendingDa1Sentinels`/`#osc11Pending` gates and were forwarded; a reply split across stdin reads with a gap larger than `StdinBuffer`'s 10ms completion timeout was flushed as individual characters; and an unterminated sequence kept absorbing the following ESC. Probe replies are now consumed by shape, incomplete probe-reply prefixes are held at the stdin decoding boundary (bounded by 500ms/256 bytes, and only 150ms for a bare ESC inside a probe window), an ESC cuts the sequence in progress unless it is an OSC/DCS/APC string terminator, and an unsolicited reply is dropped by an explicit backstop. A dropped or mangled reply can no longer latch `#osc11Pending` either: a 1s watchdog and a 64-byte reassembly cap resolve the query cycle instead of swallowing keystrokes. DA1 and XTSMGRAPHICS replies stay owned by the sixel probe and are dropped in `Tui` once that probe has finished, so an orphaned device report is no longer typed into the focused component.
25
+
26
+ - `waitForRenderCommit` / generation-scoped render tokens resolve only after a successful buffer write (or fail open on stopped/unavailable terminals), enabling awaitable progress frames for interactive resume without hanging (#2914).
27
+ - Streaming layout contraction followed by regrowth no longer re-admits an already committed logical row into native terminal scrollback, preventing occasional duplicated assistant lines after Markdown reflow.
28
+ - Repeated clearing of an already-clear viewport output source is now a render-request no-op, matching identical non-null source updates.
29
+ - A terminal width change now ends in one forced full redraw 1000ms after the last observed resize event, repairing stale bands left by lines wrapped at the old column count — across the full transcript, including scrollback history, on every host. Interim resize frames keep their cheap per-host path; the debounce is what makes the one full replay safe, so drag-resizing still does not replay the transcript per `SIGWINCH`. While the user is reading scrollback (manual viewport), the repair is deferred and runs when they return to live output. Height-only changes are unaffected (#3360, #3361).
6
30
 
7
31
  ## [0.11.7] - 2026-07-22
8
32
  ### Fixed
package/README.md CHANGED
@@ -56,6 +56,14 @@ tui.requestRender(); // Request a re-render
56
56
  tui.onDebug = () => console.log("Debug triggered");
57
57
  ```
58
58
 
59
+ ### Manual viewport and pinned suffix
60
+
61
+ `setBottomPinnedComponent(component)` marks a direct-child boundary. During manual viewport ownership, that child and all later direct children remain fixed at the bottom while rows before it form the scrollable lane; this does not require an output source. `scrollViewportPages()` moves by the lane height minus one; `scrollViewportBy()` supports smaller row steps and rejects non-finite deltas.
62
+
63
+ `setViewportOutputSource({ identity, revision })` reports semantic output changes without coupling the TUI to message types. A same-identity revision advance while manually scrolled displays the exact notice `New output — type to follow`; following live or changing/removing the identity clears it, while a stale same-identity revision rollback does not.
64
+
65
+ Pinned rows and the notice are excluded from transcript mouse-selection coordinates. When the terminal is too short, the focused direct-child suffix component is retained before decorative or lower-priority suffix rows.
66
+
59
67
  ### Component Interface
60
68
 
61
69
  All components implement:
@@ -48,6 +48,7 @@ export interface RenderMetricsSnapshot {
48
48
  timerGauges: Record<string, number>;
49
49
  helperStats: Record<string, HelperStat>;
50
50
  lineCounts: Record<string, LineCountGauge>;
51
+ structuralCounters: Record<string, number>;
51
52
  }
52
53
  export declare class RenderMetrics {
53
54
  #private;
@@ -73,6 +74,8 @@ export declare class RenderMetrics {
73
74
  recordHelper(name: string, durationMs: number): void;
74
75
  /** Record a per-render line-count gauge (e.g. "rendered", "normalized", "diffed"). */
75
76
  recordLineCount(name: string, value: number): void;
77
+ /** Accumulate deterministic structural render work without retaining frame data. */
78
+ recordStructuralCounter(name: string, value?: number): void;
76
79
  /**
77
80
  * Force a GC when the runtime exposes one and sample RSS as the post-run
78
81
  * "return" value used by the memory-leak gate. Callers should drop large
@@ -45,6 +45,12 @@ export declare class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
45
45
  #private;
46
46
  constructor(options?: StdinBufferOptions);
47
47
  process(data: string | Buffer): void;
48
+ /**
49
+ * ProcessTerminal calls this right after writing a capability probe so a lone
50
+ * ESC arriving inside the reply window is treated as a possible reply fragment
51
+ * rather than a keypress.
52
+ */
53
+ noteProbeIssued(windowMs?: number): void;
48
54
  flush(): string[];
49
55
  clear(): void;
50
56
  getBuffer(): string;
@@ -158,6 +158,15 @@ export declare function encodeKittyPlacement(options: {
158
158
  columns: number;
159
159
  rows: number;
160
160
  }): string;
161
+ export interface KittyPlacementReference {
162
+ imageId: number;
163
+ placementId: number;
164
+ rows: number;
165
+ }
166
+ /** Extract bounded, named kitty placements from a rendered line. */
167
+ export declare function extractKittyPlacementReferences(line: string): KittyPlacementReference[];
168
+ /** Soft-delete one named kitty placement while retaining its transmitted pixels. */
169
+ export declare function encodeKittyPlacementDelete(reference: KittyPlacementReference): string;
161
170
  export declare function encodeITerm2(base64Data: string, options?: {
162
171
  width?: number | string;
163
172
  height?: number | string;
@@ -1,3 +1,19 @@
1
+ /**
2
+ * Capability-probe reply shapes that only this layer solicits (OSC 11 background
3
+ * color, the Mode 2031 appearance DSR, and the Kitty keyboard-flags report).
4
+ * These are terminal-to-host replies and are NEVER legitimate user input, so a
5
+ * reply that arrives outside its pending-query window is dropped defensively.
6
+ *
7
+ * DA1 is deliberately absent: `Tui` issues its own DA1 request for the sixel
8
+ * probe and consumes that reply downstream.
9
+ */
10
+ export declare const PROBE_REPLY_PATTERNS: ReadonlyArray<{
11
+ name: string;
12
+ issuedProbe: string;
13
+ pattern: RegExp;
14
+ }>;
15
+ /** True when `sequence` is one of the probe replies above. */
16
+ export declare function isUnsolicitedProbeReply(sequence: string): boolean;
1
17
  /**
2
18
  * Whether GJC may reprogram the keyboard with enhanced input protocols
3
19
  * (the Kitty keyboard protocol and the xterm modifyOtherKeys fallback).
@@ -1,5 +1,7 @@
1
1
  import type { Terminal } from "./terminal";
2
2
  import { visibleWidth } from "./utils";
3
+ /** Discrete mouse-wheel notch size in terminal rows (xterm/less-style). */
4
+ export declare const DEFAULT_WHEEL_LINES = 3;
3
5
  type InputListenerResult = {
4
6
  consume?: boolean;
5
7
  data?: string;
@@ -99,6 +101,11 @@ export interface ViewportAnchorProvider extends Component {
99
101
  export interface ViewportAnchorSource {
100
102
  id: ViewportAnchorId;
101
103
  }
104
+ /** Identity and monotonic revision of the logical output producer. */
105
+ export type ViewportOutputSource = {
106
+ identity: string;
107
+ revision: bigint;
108
+ };
102
109
  export interface ViewportAnchorSourceRenderer extends Component {
103
110
  renderWithViewportAnchorSource(width: number, source: ViewportAnchorSource): ViewportAnchorRender;
104
111
  }
@@ -231,6 +238,13 @@ export declare class TUI extends Container {
231
238
  constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: {
232
239
  enableMouse?: boolean;
233
240
  copySelection?: (text: string) => void | Promise<void>;
241
+ /**
242
+ * Trailing debounce for the settled width repair, in ms. `0` disables the
243
+ * settled repair (deterministic harnesses need this — a wall-clock-timed
244
+ * full replay lands at nondeterministic logical positions). Defaults to
245
+ * `GJC_TUI_WIDTH_SETTLE_MS` / `PI_TUI_WIDTH_SETTLE_MS`, then 1000.
246
+ */
247
+ widthSettleMs?: number;
234
248
  });
235
249
  dispose(): void;
236
250
  get fullRedraws(): number;
@@ -244,7 +258,11 @@ export declare class TUI extends Container {
244
258
  */
245
259
  setClearOnShrink(enabled: boolean): void;
246
260
  setFocus(component: Component | null): void;
261
+ removeChild(component: Component): void;
262
+ clear(): void;
247
263
  setBottomPinnedComponent(component: Component | null): void;
264
+ /** Report the logical output producer revision without coupling TUI to message types. */
265
+ setViewportOutputSource(source: ViewportOutputSource | null): void;
248
266
  /** Register the direct child whose rows are eligible for semantic viewport anchoring. */
249
267
  setViewportAnchorComponent(component: Component | null): void;
250
268
  /** Clear manual viewport ownership before replacing the transcript identity namespace. */
@@ -253,6 +271,10 @@ export declare class TUI extends Container {
253
271
  prepareViewportAnchorForTranscriptRebuild(): void;
254
272
  /** Reveal a semantic viewport anchor without changing the rendered content width. */
255
273
  revealViewportAnchor(id: ViewportAnchorId, alignment: "top" | "center" | "bottom"): boolean;
274
+ scrollViewportBy(deltaRows: number, options?: {
275
+ /** edge: PageUp/PageDown pin; stable: preserve/center pin for fine wheel motion */
276
+ pin?: "edge" | "stable";
277
+ }): boolean;
256
278
  scrollViewportPages(direction: -1 | 1): boolean;
257
279
  followLiveViewport(): boolean;
258
280
  /**
@@ -266,6 +288,15 @@ export declare class TUI extends Container {
266
288
  hasOverlay(): boolean;
267
289
  invalidate(): void;
268
290
  start(): void;
291
+ /**
292
+ * Wait for a specific render request generation to be written successfully.
293
+ *
294
+ * Render requests are coalesced, so committing a newer generation also commits
295
+ * every older generation represented by that frame. A stopped or unavailable
296
+ * terminal resolves waiters false so UI callers can fail open instead of
297
+ * holding a session operation behind a dead renderer.
298
+ */
299
+ waitForRenderCommit(generation: number, timeoutMs?: number): Promise<boolean>;
269
300
  get terminalAvailable(): boolean;
270
301
  addInputListener(listener: InputListener): () => void;
271
302
  removeInputListener(listener: InputListener): void;
@@ -294,6 +325,7 @@ export declare class TUI extends Container {
294
325
  */
295
326
  requestResizeRender(): void;
296
327
  requestRender(force?: boolean, source?: string): void;
328
+ requestRenderWithGeneration(force?: boolean, source?: string): number;
297
329
  getLineRenderCacheStats(): {
298
330
  normalizationSize: number;
299
331
  truncationSize: number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.11.11",
4
+ "version": "0.12.1",
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.11.11",
40
- "@gajae-code/utils": "0.11.11",
39
+ "@gajae-code/natives": "0.12.1",
40
+ "@gajae-code/utils": "0.12.1",
41
41
  "lru-cache": "11.3.6",
42
42
  "marked": "18.0.6"
43
43
  },
@@ -229,6 +229,10 @@ export function extractSlashCommandTokenPrefix(text: string): string | null {
229
229
  const charBeforeSlash = text[slashIndex - 1];
230
230
  if (charBeforeSlash && NON_COMMAND_SLASH_PREFIX_PRECEDERS.has(charBeforeSlash)) return null;
231
231
 
232
+ let tokenStart = slashIndex;
233
+ while (tokenStart > 0 && !/\s/.test(text[tokenStart - 1] ?? "")) tokenStart -= 1;
234
+ if (text.slice(tokenStart, slashIndex).includes("/")) return null;
235
+
232
236
  return token;
233
237
  }
234
238
  export interface AutocompleteItem {
package/src/metrics.ts CHANGED
@@ -124,6 +124,7 @@ export interface RenderMetricsSnapshot {
124
124
  timerGauges: Record<string, number>;
125
125
  helperStats: Record<string, HelperStat>;
126
126
  lineCounts: Record<string, LineCountGauge>;
127
+ structuralCounters: Record<string, number>;
127
128
  }
128
129
 
129
130
  function emptyDurationStats(): DurationStats {
@@ -160,6 +161,7 @@ export class RenderMetrics {
160
161
  #timerGauges = new Map<string, number>();
161
162
  #helpers = new Map<string, { count: number; totalMs: number }>();
162
163
  #lineGauges = new Map<string, LineCountGauge>();
164
+ #structuralCounters = new Map<string, number>();
163
165
  #rssReturn: number | null = null;
164
166
  #heapBaseline: number | null = null;
165
167
  #heapReturn: number | null = null;
@@ -200,6 +202,7 @@ export class RenderMetrics {
200
202
  this.#timerGauges.clear();
201
203
  this.#helpers.clear();
202
204
  this.#lineGauges.clear();
205
+ this.#structuralCounters.clear();
203
206
  this.#rssReturn = null;
204
207
  this.#heapBaseline = null;
205
208
  this.#heapReturn = null;
@@ -296,6 +299,13 @@ export class RenderMetrics {
296
299
  this.#lineGauges.set(retained, cur);
297
300
  }
298
301
 
302
+ /** Accumulate deterministic structural render work without retaining frame data. */
303
+ recordStructuralCounter(name: string, value = 1): void {
304
+ if (!this.#enabled) return;
305
+ const retained = retainedLabel(this.#structuralCounters, name);
306
+ this.#structuralCounters.set(retained, (this.#structuralCounters.get(retained) ?? 0) + value);
307
+ }
308
+
299
309
  /**
300
310
  * Force a GC when the runtime exposes one and sample RSS as the post-run
301
311
  * "return" value used by the memory-leak gate. Callers should drop large
@@ -345,6 +355,10 @@ export class RenderMetrics {
345
355
  return out;
346
356
  }
347
357
 
358
+ #structuralCounterStats(): Record<string, number> {
359
+ return Object.fromEntries(this.#structuralCounters);
360
+ }
361
+
348
362
  snapshot(): RenderMetricsSnapshot {
349
363
  return {
350
364
  enabled: this.#enabled,
@@ -374,6 +388,7 @@ export class RenderMetrics {
374
388
  timerGauges: Object.fromEntries(this.#timerGauges),
375
389
  helperStats: this.#helperStats(),
376
390
  lineCounts: this.#lineCountStats(),
391
+ structuralCounters: this.#structuralCounterStats(),
377
392
  };
378
393
  }
379
394
  }
@@ -26,6 +26,25 @@ const BRACKETED_PASTE_END = "\x1b[201~";
26
26
  const SGR_QUARANTINE_MAX_BYTES = 256;
27
27
  const SGR_QUARANTINE_TIMEOUT_MS = 100;
28
28
 
29
+ // Bounds for holding an incomplete terminal capability-probe reply instead of
30
+ // flushing its fragments into the input stream.
31
+ const PROBE_FRAGMENT_HOLD_MAX_MS = 500;
32
+ const PROBE_FRAGMENT_MAX_BYTES = 256;
33
+ const PROBE_ESCAPE_HOLD_MAX_MS = 150;
34
+ const PROBE_REPLY_WINDOW_MS = 2500;
35
+
36
+ /**
37
+ * True when the buffer is an escape sequence that only a terminal reply can
38
+ * complete: an OSC without its BEL/ST terminator (OSC 11 background color) or a
39
+ * private CSI without its final byte (DA1, kitty flags, Mode 2031 DSR).
40
+ * SGR mouse prefixes are excluded; they have their own quarantine.
41
+ */
42
+ function isIncompleteProbeReplyPrefix(buffer: string): boolean {
43
+ if (buffer.length > PROBE_FRAGMENT_MAX_BYTES) return false;
44
+ if (/^\x1b\][^\x07\x1b]*$/.test(buffer)) return true;
45
+ return /^\x1b\[\??[\d;]*$/.test(buffer);
46
+ }
47
+
29
48
  /** True for complete SGR mouse CSI reports. These remain control input, never text. */
30
49
  export function isSgrMouseSequence(sequence: string): boolean {
31
50
  return /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(sequence);
@@ -238,6 +257,19 @@ function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | unde
238
257
  return codepoint >= 32 ? codepoint : undefined;
239
258
  }
240
259
 
260
+ /**
261
+ * True when the ESC at `index` can still continue the escape sequence that
262
+ * started at offset 0, i.e. it is (or may become) the ST terminator `ESC \` of
263
+ * an OSC/DCS/APC string. Anywhere else an ESC cancels the sequence in progress.
264
+ */
265
+ function continuesAsStringTerminator(remaining: string, index: number): boolean {
266
+ const introducer = remaining[1];
267
+ if (introducer !== "]" && introducer !== "P" && introducer !== "_") return false;
268
+ const afterEsc = remaining[index + 1];
269
+ // Terminator not fully delivered yet: keep buffering rather than guessing.
270
+ return afterEsc === undefined || afterEsc === "\\";
271
+ }
272
+
241
273
  function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } {
242
274
  const sequences: string[] = [];
243
275
  let pos = 0;
@@ -270,6 +302,15 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
270
302
  pos += seqEnd;
271
303
  break;
272
304
  } else if (status === "incomplete") {
305
+ // An ESC cancels an escape sequence already in progress; it can only
306
+ // continue one as the ST terminator of an OSC/DCS/APC string. Cutting
307
+ // here keeps an unterminated sequence from swallowing the next key.
308
+ // seqEnd === 1 is excluded so Meta sequences (ESC ESC) still parse.
309
+ if (remaining[seqEnd] === ESC && seqEnd >= 2 && !continuesAsStringTerminator(remaining, seqEnd)) {
310
+ sequences.push(candidate);
311
+ pos += seqEnd;
312
+ break;
313
+ }
273
314
  seqEnd++;
274
315
  } else {
275
316
  // Should not happen when starting with ESC
@@ -346,6 +387,10 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
346
387
  #sgrQuarantineBytes = 0;
347
388
  #sgrQuarantineSemicolons = 0;
348
389
  #sgrQuarantineHasDigit = false;
390
+ // Probe-reply fragment hold.
391
+ #probeHoldStartedAt: number | undefined;
392
+ #probeHoldBuffer = "";
393
+ #probeReplyWindowUntil = 0;
349
394
 
350
395
  constructor(options: StdinBufferOptions = {}) {
351
396
  super();
@@ -497,17 +542,10 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
497
542
  this.#emitDataSequence(sequence);
498
543
  }
499
544
 
500
- if (this.#buffer.length > 0) {
501
- this.#timeout = setTimeout(() => {
502
- if (isSgrMousePrefix(this.#buffer)) {
503
- this.#beginSgrQuarantine();
504
- return;
505
- }
506
- const flushed = this.flush();
507
- for (const sequence of flushed) {
508
- this.#emitDataSequence(sequence);
509
- }
510
- }, this.#timeoutMs);
545
+ if (this.#buffer.length === 0) {
546
+ this.#probeHoldStartedAt = undefined;
547
+ } else {
548
+ this.#timeout = setTimeout(() => this.#onFlushTimeout(), this.#timeoutMs);
511
549
  }
512
550
  }
513
551
 
@@ -599,12 +637,61 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
599
637
  this.emit("data", sequence);
600
638
  }
601
639
 
640
+ /**
641
+ * ProcessTerminal calls this right after writing a capability probe so a lone
642
+ * ESC arriving inside the reply window is treated as a possible reply fragment
643
+ * rather than a keypress.
644
+ */
645
+ noteProbeIssued(windowMs: number = PROBE_REPLY_WINDOW_MS): void {
646
+ this.#probeReplyWindowUntil = Date.now() + windowMs;
647
+ }
648
+
649
+ #onFlushTimeout(): void {
650
+ if (isSgrMousePrefix(this.#buffer)) {
651
+ this.#beginSgrQuarantine();
652
+ return;
653
+ }
654
+ if (this.#shouldHoldProbeFragment()) {
655
+ this.#timeout = setTimeout(() => this.#onFlushTimeout(), this.#timeoutMs);
656
+ return;
657
+ }
658
+ this.#probeHoldStartedAt = undefined;
659
+ const flushed = this.flush();
660
+ for (const sequence of flushed) {
661
+ this.#emitDataSequence(sequence);
662
+ }
663
+ }
664
+
665
+ #shouldHoldProbeFragment(): boolean {
666
+ const buffer = this.#buffer;
667
+ if (buffer.length === 0) return false;
668
+ const now = Date.now();
669
+ const bareEscape = buffer === "\x1b";
670
+ if (bareEscape) {
671
+ // A lone ESC is a real key press: only hold it while a probe reply is
672
+ // still expected, and only briefly.
673
+ if (now >= this.#probeReplyWindowUntil) return false;
674
+ } else if (!isIncompleteProbeReplyPrefix(buffer)) {
675
+ return false;
676
+ }
677
+ if (this.#probeHoldStartedAt === undefined || this.#probeHoldBuffer !== buffer) {
678
+ // Restart the clock whenever the fragment makes progress. A start stamp left
679
+ // over from an earlier fragment expired every later hold instantly, so a
680
+ // reply split across many reads still leaked character by character.
681
+ this.#probeHoldStartedAt = now;
682
+ this.#probeHoldBuffer = buffer;
683
+ }
684
+ const limit = bareEscape ? PROBE_ESCAPE_HOLD_MAX_MS : PROBE_FRAGMENT_HOLD_MAX_MS;
685
+ return now - this.#probeHoldStartedAt < limit;
686
+ }
687
+
602
688
  flush(): string[] {
603
689
  if (this.#timeout) {
604
690
  clearTimeout(this.#timeout);
605
691
  this.#timeout = undefined;
606
692
  }
607
693
  if (this.#sgrQuarantine) this.#endSgrQuarantine();
694
+ this.#probeHoldStartedAt = undefined;
608
695
 
609
696
  const pendingMeta = this.#consumePendingSingleUtf8LeadAsMeta();
610
697
 
@@ -637,6 +724,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
637
724
  this.#sgrQuarantineBytes = 0;
638
725
  this.#sgrQuarantineSemicolons = 0;
639
726
  this.#sgrQuarantineHasDigit = false;
727
+ this.#probeHoldStartedAt = undefined;
640
728
  // Drop any incomplete multi-byte sequence the decoder is holding so a
641
729
  // stale partial prefix cannot combine with future input. destroy()
642
730
  // resets the decoder by calling clear().
@@ -514,6 +514,68 @@ export function encodeKittyPlacement(options: {
514
514
  return `\x1b_Ga=p,i=${options.imageId},p=${options.placementId},c=${options.columns},r=${options.rows},C=1,q=2\x1b\\`;
515
515
  }
516
516
 
517
+ export interface KittyPlacementReference {
518
+ imageId: number;
519
+ placementId: number;
520
+ rows: number;
521
+ }
522
+
523
+ const MAX_KITTY_CONTROL_CHARS = 4096;
524
+ const MAX_KITTY_PLACEMENTS_PER_LINE = 1024;
525
+ const MAX_KITTY_PLACEMENT_SCAN_CHARS = 256 * 1024;
526
+ const MAX_KITTY_PLACEMENT_SCAN_BYTES = 256 * 1024;
527
+ const MAX_KITTY_UINT32 = 0xffff_ffff;
528
+ const MAX_KITTY_CONTROL_FIELDS = 64;
529
+
530
+ function parseKittyUint32(raw: string | undefined): number | null {
531
+ if (raw === undefined || raw.length === 0 || raw.length > 10 || !/^\d+$/u.test(raw)) return null;
532
+ const value = Number(raw);
533
+ return Number.isInteger(value) && value > 0 && value <= MAX_KITTY_UINT32 ? value : null;
534
+ }
535
+
536
+ /** Extract bounded, named kitty placements from a rendered line. */
537
+ export function extractKittyPlacementReferences(line: string): KittyPlacementReference[] {
538
+ if (line.length > MAX_KITTY_PLACEMENT_SCAN_CHARS) return [];
539
+ if (!line.includes(ImageProtocol.Kitty) || Buffer.byteLength(line) > MAX_KITTY_PLACEMENT_SCAN_BYTES) return [];
540
+ const placements: KittyPlacementReference[] = [];
541
+ for (const match of line.matchAll(/\x1b_G([^;\x1b]*)(?:;([^\x1b]*))?\x1b\\/gu)) {
542
+ const control = match[1] ?? "";
543
+ if (control.length === 0 || control.length > MAX_KITTY_CONTROL_CHARS || match[2] !== undefined) continue;
544
+ const parts = control.split(",");
545
+ if (parts.length > MAX_KITTY_CONTROL_FIELDS) continue;
546
+
547
+ const params = new Map<string, string>();
548
+ let valid = true;
549
+ for (const part of parts) {
550
+ const separator = part.indexOf("=");
551
+ if (separator !== 1 || part.length === 2) {
552
+ valid = false;
553
+ break;
554
+ }
555
+ const key = part[0];
556
+ if (!/[A-Za-z]/u.test(key) || params.has(key)) {
557
+ valid = false;
558
+ break;
559
+ }
560
+ params.set(key, part.slice(2));
561
+ }
562
+ if (!valid || params.get("a") !== "p" || params.get("C") !== "1" || params.has("m")) continue;
563
+
564
+ const imageId = parseKittyUint32(params.get("i"));
565
+ const placementId = parseKittyUint32(params.get("p"));
566
+ const rows = parseKittyUint32(params.get("r"));
567
+ if (imageId === null || placementId === null || rows === null) continue;
568
+ placements.push({ imageId, placementId, rows });
569
+ if (placements.length > MAX_KITTY_PLACEMENTS_PER_LINE) return [];
570
+ }
571
+ return placements;
572
+ }
573
+
574
+ /** Soft-delete one named kitty placement while retaining its transmitted pixels. */
575
+ export function encodeKittyPlacementDelete(reference: KittyPlacementReference): string {
576
+ return `\x1b_Ga=d,d=i,i=${reference.imageId},p=${reference.placementId},q=2\x1b\\`;
577
+ }
578
+
517
579
  export function encodeITerm2(
518
580
  base64Data: string,
519
581
  options: {
@@ -755,8 +817,8 @@ export function renderImage(
755
817
  // and ALL of its placements (breaking sibling components showing the
756
818
  // same content) and would re-send multi-MB payloads on every repaint.
757
819
  if (!transmittedKittyImageIds.has(imageId)) {
758
- transmittedKittyImageIds.add(imageId);
759
820
  (options.onTransmit ?? kittyTransmitWriter)(encodeKittyTransmit(base64Data, imageId));
821
+ transmittedKittyImageIds.add(imageId);
760
822
  }
761
823
  const sequence = encodeKittyPlacement({ imageId, placementId, columns: fit.columns, rows: fit.rows });
762
824
  return { sequence, rows: fit.rows, cursorNeutral: true };