@sayknow-cli/tui 0.3.12 → 0.3.15

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/src/tui.ts CHANGED
@@ -9,7 +9,14 @@ import { getKeybindings } from "./keybindings";
9
9
  import { isKeyRelease } from "./keys";
10
10
  import { renderMetrics } from "./metrics";
11
11
  import type { Terminal } from "./terminal";
12
- import { ImageProtocol, setCellDimensions, setTerminalImageProtocol, TERMINAL } from "./terminal-capabilities";
12
+ import {
13
+ ImageProtocol,
14
+ isImageProtocolForced,
15
+ isUnderTerminalMultiplexer,
16
+ setCellDimensions,
17
+ setTerminalImageProtocol,
18
+ TERMINAL,
19
+ } from "./terminal-capabilities";
13
20
  import {
14
21
  Ellipsis,
15
22
  extractSegments,
@@ -98,6 +105,82 @@ export const CURSOR_MARKER = "\x1b_pi:c\x07";
98
105
 
99
106
  export { visibleWidth };
100
107
 
108
+ /** Durable source identifier for a semantically anchored viewport row. */
109
+ export type ViewportAnchorId = string;
110
+
111
+ export interface ViewportAnchorRow {
112
+ id: ViewportAnchorId;
113
+ graphemeStart: number;
114
+ graphemeEnd: number;
115
+ cellStart: number;
116
+ cellEnd: number;
117
+ }
118
+
119
+ export interface ViewportAnchorRender {
120
+ lines: string[];
121
+ anchors: Array<ViewportAnchorRow | null>;
122
+ }
123
+
124
+ export interface ViewportAnchorProvider extends Component {
125
+ renderWithViewportAnchors(width: number): ViewportAnchorRender;
126
+ }
127
+
128
+ export interface ViewportAnchorSource {
129
+ id: ViewportAnchorId;
130
+ }
131
+
132
+ export interface ViewportAnchorSourceRenderer extends Component {
133
+ renderWithViewportAnchorSource(width: number, source: ViewportAnchorSource): ViewportAnchorRender;
134
+ }
135
+
136
+ export function isViewportAnchorProvider(component: Component): component is ViewportAnchorProvider {
137
+ if (!("renderWithViewportAnchors" in component) || typeof component.renderWithViewportAnchors !== "function") {
138
+ return false;
139
+ }
140
+ return !(
141
+ component instanceof Container &&
142
+ component.renderWithViewportAnchors === Container.prototype.renderWithViewportAnchors &&
143
+ component.render !== Container.prototype.render
144
+ );
145
+ }
146
+
147
+ export function isViewportAnchorSourceRenderer(component: Component): component is ViewportAnchorSourceRenderer {
148
+ return (
149
+ "renderWithViewportAnchorSource" in component && typeof component.renderWithViewportAnchorSource === "function"
150
+ );
151
+ }
152
+
153
+ export function renderComponentWithViewportAnchors(component: Component, width: number): ViewportAnchorRender {
154
+ if (isViewportAnchorProvider(component)) {
155
+ const rendered = component.renderWithViewportAnchors(width);
156
+ if (rendered.anchors.length !== rendered.lines.length) {
157
+ throw new Error(
158
+ `Viewport anchor provider returned ${rendered.anchors.length} anchors for ${rendered.lines.length} lines`,
159
+ );
160
+ }
161
+ return rendered;
162
+ }
163
+ const lines = component.render(width);
164
+ return { lines, anchors: lines.map(() => null) };
165
+ }
166
+
167
+ export function renderComponentWithViewportAnchorSource(
168
+ component: Component,
169
+ width: number,
170
+ source: ViewportAnchorSource,
171
+ ): ViewportAnchorRender {
172
+ if (!isViewportAnchorSourceRenderer(component)) {
173
+ throw new TypeError("Viewport anchor sources require renderer-owned row metadata");
174
+ }
175
+ const rendered = component.renderWithViewportAnchorSource(width, source);
176
+ if (rendered.anchors.length !== rendered.lines.length) {
177
+ throw new Error(
178
+ `Viewport anchor source renderer returned ${rendered.anchors.length} anchors for ${rendered.lines.length} lines`,
179
+ );
180
+ }
181
+ return rendered;
182
+ }
183
+
101
184
  /**
102
185
  * Anchor position for overlays
103
186
  */
@@ -141,7 +224,6 @@ function isTermuxSession(env: Record<string, string | undefined> = Bun.env): boo
141
224
  return Boolean(env.TERMUX_VERSION);
142
225
  }
143
226
 
144
- const SKC_TMUX_LAUNCHED_ENV = "SKC_TMUX_LAUNCHED";
145
227
  const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
146
228
  const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on", "y"]);
147
229
 
@@ -155,25 +237,38 @@ function envFlagEnabled(value: string | undefined): boolean {
155
237
  return normalized !== undefined && TRUTHY_ENV_VALUES.has(normalized);
156
238
  }
157
239
 
158
- function termLooksMultiplexed(value: string | undefined): boolean {
159
- const term = value?.trim().toLowerCase() ?? "";
160
- return term.startsWith("tmux") || term.startsWith("screen");
161
- }
162
-
163
240
  function isWindowsTerminalSession(env: Record<string, string | undefined> = Bun.env): boolean {
164
241
  return envIsEnabled(env.WT_SESSION) || env.TERM_PROGRAM === "Windows_Terminal";
165
242
  }
166
243
 
167
- /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
244
+ /**
245
+ * Detect terminal multiplexers where scrollback clearing and height-change
246
+ * redraws are hostile. Delegates to the shared capability predicate so the
247
+ * renderer and graphics-protocol selection agree on what counts as a
248
+ * multiplexed host.
249
+ */
168
250
  function isMultiplexerSession(env: Record<string, string | undefined> = Bun.env): boolean {
169
- return Boolean(
170
- envIsEnabled(env.TMUX) ||
171
- envIsEnabled(env.TMUX_PANE) ||
172
- envIsEnabled(env.STY) ||
173
- envIsEnabled(env.ZELLIJ) ||
174
- envIsEnabled(env[SKC_TMUX_LAUNCHED_ENV]) ||
175
- termLooksMultiplexed(env.TERM),
176
- );
251
+ return isUnderTerminalMultiplexer(env as NodeJS.ProcessEnv);
252
+ }
253
+
254
+ /**
255
+ * Startup sixel capability probe policy (pure; exported for tests):
256
+ * - Never probe when PI_FORCE_IMAGE_PROTOCOL is set — an explicit
257
+ * configuration (including "off") is authoritative.
258
+ * - Never probe inside a terminal multiplexer: tmux advertises DA1 ";4"
259
+ * whenever it was compiled with sixel support, regardless of whether the
260
+ * attached client terminal can render sixel, so a positive reply is not
261
+ * end-to-end evidence. Graphics under a multiplexer are strictly opt-in
262
+ * via PI_FORCE_IMAGE_PROTOCOL=sixel.
263
+ * - Probe Windows Terminal (>=1.22 renders sixel but exposes no env marker).
264
+ */
265
+ export function shouldProbeSixelCapability(
266
+ env: NodeJS.ProcessEnv = Bun.env,
267
+ platform: NodeJS.Platform = process.platform,
268
+ ): boolean {
269
+ if (isImageProtocolForced()) return false;
270
+ if (isUnderTerminalMultiplexer(env)) return false;
271
+ return platform === "win32" && Boolean(env.WT_SESSION?.trim());
177
272
  }
178
273
 
179
274
  function useLegacyMultiplexerFullRender(env: Record<string, string | undefined> = Bun.env): boolean {
@@ -212,6 +307,17 @@ function useViewportRepaintPath(terminal: Terminal): boolean {
212
307
  });
213
308
  }
214
309
 
310
+ function allowsHostNeutralOverflowRepaint(
311
+ terminal: Terminal,
312
+ env: Record<string, string | undefined> = Bun.env,
313
+ ): boolean {
314
+ return (
315
+ terminal.isProcessTerminal === true &&
316
+ !isTermuxSession(env) &&
317
+ !(isMultiplexerSession(env) && useLegacyMultiplexerFullRender(env))
318
+ );
319
+ }
320
+
215
321
  function shouldPreserveScrollbackOnFullClear(terminal: Terminal): boolean {
216
322
  return isViewportSensitiveHost(Bun.env, process.platform, terminal.isProcessTerminal === true);
217
323
  }
@@ -271,9 +377,10 @@ export interface OverlayHandle {
271
377
  /**
272
378
  * Container - a component that contains other components
273
379
  */
274
- export class Container implements Component {
380
+ export class Container implements ViewportAnchorProvider {
275
381
  children: Component[] = [];
276
382
  #disposed = false;
383
+ #viewportAnchorSources = new Map<Component, ViewportAnchorSource>();
277
384
 
278
385
  addChild(component: Component): void {
279
386
  this.children.push(component);
@@ -283,6 +390,7 @@ export class Container implements Component {
283
390
  const index = this.children.indexOf(component);
284
391
  if (index !== -1) {
285
392
  this.children.splice(index, 1);
393
+ this.#viewportAnchorSources.delete(component);
286
394
  component.dispose?.();
287
395
  }
288
396
  }
@@ -292,45 +400,62 @@ export class Container implements Component {
292
400
  const index = this.children.indexOf(component);
293
401
  if (index !== -1) {
294
402
  this.children.splice(index, 1);
403
+ this.#viewportAnchorSources.delete(component);
295
404
  }
296
405
  }
297
406
 
298
407
  clear(): void {
299
- for (const child of this.children) {
300
- child.dispose?.();
301
- }
408
+ for (const child of this.children) child.dispose?.();
302
409
  this.children = [];
410
+ this.#viewportAnchorSources.clear();
303
411
  }
304
412
 
305
413
  /** Remove all children without disposing them (for detach-then-readd reuse). */
306
414
  detachAll(): void {
307
415
  this.children = [];
416
+ this.#viewportAnchorSources.clear();
417
+ }
418
+
419
+ /** Registers a direct child as eligible for semantic viewport anchoring. */
420
+ setViewportAnchorSource(component: Component, source: ViewportAnchorSource | null): void {
421
+ if (source !== null && !isViewportAnchorSourceRenderer(component)) {
422
+ throw new TypeError("Viewport anchor sources require renderer-owned row metadata");
423
+ }
424
+ if (source === null) this.#viewportAnchorSources.delete(component);
425
+ else this.#viewportAnchorSources.set(component, source);
308
426
  }
309
427
 
310
428
  dispose(): void {
311
429
  if (this.#disposed) return;
312
430
  this.#disposed = true;
313
- for (const child of this.children) {
314
- child.dispose?.();
315
- }
431
+ for (const child of this.children) child.dispose?.();
432
+ this.#viewportAnchorSources.clear();
316
433
  }
317
434
 
318
435
  invalidate(): void {
319
- for (const child of this.children) {
320
- child.invalidate?.();
321
- }
436
+ for (const child of this.children) child.invalidate?.();
322
437
  }
323
438
 
324
439
  render(width: number): string[] {
440
+ return this.renderWithViewportAnchors(width).lines;
441
+ }
442
+
443
+ renderWithViewportAnchors(width: number): ViewportAnchorRender {
325
444
  width = Math.max(1, width);
326
445
  const lines: string[] = [];
446
+ const anchors: Array<ViewportAnchorRow | null> = [];
327
447
  for (const child of this.children) {
328
- const childLines = safeRenderComponent(child, width, "container-child");
329
- for (let i = 0; i < childLines.length; i++) {
330
- lines.push(childLines[i]);
448
+ const source = this.#viewportAnchorSources.get(child);
449
+ const rendered =
450
+ source === undefined
451
+ ? safeRenderComponentWithViewportAnchors(child, width, "container-child")
452
+ : safeRenderComponentWithViewportAnchorSource(child, width, source, "container-anchor-child");
453
+ for (let index = 0; index < rendered.lines.length; index++) {
454
+ lines.push(rendered.lines[index]);
455
+ anchors.push(rendered.anchors[index] ?? null);
331
456
  }
332
457
  }
333
- return lines;
458
+ return { lines, anchors };
334
459
  }
335
460
  }
336
461
 
@@ -348,23 +473,58 @@ const reportedRenderErrors = new Set<string>();
348
473
  * command such as `/background`). Isolate the failure: log it once, emit a
349
474
  * visible fallback line, and keep rendering the rest of the tree.
350
475
  */
476
+ function renderFailure(component: Component, where: string, err: unknown): string[] {
477
+ const name = component?.constructor?.name ?? "Component";
478
+ const key = `${where}:${name}:${err instanceof Error ? err.message : String(err)}`;
479
+ if (!reportedRenderErrors.has(key)) {
480
+ if (reportedRenderErrors.size >= MAX_REPORTED_RENDER_ERRORS) reportedRenderErrors.clear();
481
+ reportedRenderErrors.add(key);
482
+ logger.error("Component render failed; emitting fallback line", {
483
+ where,
484
+ component: name,
485
+ error: err instanceof Error ? err.message : String(err),
486
+ stack: err instanceof Error ? err.stack : undefined,
487
+ });
488
+ }
489
+ return [`[render error: ${name}]`];
490
+ }
491
+
492
+ let viewportAnchorRenderFailureCount = 0;
493
+
351
494
  function safeRenderComponent(component: Component, width: number, where: string): string[] {
352
495
  try {
353
496
  return component.render(width);
354
497
  } catch (err) {
355
- const name = component?.constructor?.name ?? "Component";
356
- const key = `${where}:${name}:${err instanceof Error ? err.message : String(err)}`;
357
- if (!reportedRenderErrors.has(key)) {
358
- if (reportedRenderErrors.size >= MAX_REPORTED_RENDER_ERRORS) reportedRenderErrors.clear();
359
- reportedRenderErrors.add(key);
360
- logger.error("Component render failed; emitting fallback line", {
361
- where,
362
- component: name,
363
- error: err instanceof Error ? err.message : String(err),
364
- stack: err instanceof Error ? err.stack : undefined,
365
- });
366
- }
367
- return [`[render error: ${name}]`];
498
+ return renderFailure(component, where, err);
499
+ }
500
+ }
501
+
502
+ function safeRenderComponentWithViewportAnchors(
503
+ component: Component,
504
+ width: number,
505
+ where: string,
506
+ ): ViewportAnchorRender {
507
+ try {
508
+ return renderComponentWithViewportAnchors(component, width);
509
+ } catch (err) {
510
+ viewportAnchorRenderFailureCount += 1;
511
+ const lines = renderFailure(component, where, err);
512
+ return { lines, anchors: lines.map(() => null) };
513
+ }
514
+ }
515
+
516
+ function safeRenderComponentWithViewportAnchorSource(
517
+ component: Component,
518
+ width: number,
519
+ source: ViewportAnchorSource,
520
+ where: string,
521
+ ): ViewportAnchorRender {
522
+ try {
523
+ return renderComponentWithViewportAnchorSource(component, width, source);
524
+ } catch (err) {
525
+ viewportAnchorRenderFailureCount += 1;
526
+ const lines = renderFailure(component, where, err);
527
+ return { lines, anchors: lines.map(() => null) };
368
528
  }
369
529
  }
370
530
 
@@ -374,6 +534,18 @@ type LineNormalizationCacheEntry = {
374
534
  width: number | undefined;
375
535
  };
376
536
 
537
+ type ViewportAnchorFrame = {
538
+ startRow: number;
539
+ anchors: Array<ViewportAnchorRow | null>;
540
+ };
541
+
542
+ type ManualViewportAnchor = {
543
+ id: ViewportAnchorId;
544
+ graphemeIndex: number;
545
+ cellOffset: number;
546
+ desiredScreenRow: number;
547
+ };
548
+
377
549
  type TuiRenderCounterSnapshot = {
378
550
  debugRedrawEnvReads: number;
379
551
  debugRedrawAppendWrites: number;
@@ -386,6 +558,7 @@ type TuiRenderCounterSnapshot = {
386
558
  export class TUI extends Container {
387
559
  terminal: Terminal;
388
560
  #previousLines: string[] = [];
561
+ #latestRenderedLines: string[] = [];
389
562
  /**
390
563
  * Raw (pre-normalization) lines from the previous frame, kept only when the
391
564
  * virtual-viewport flag is on. Used to detect whether the off-screen prefix is
@@ -418,6 +591,11 @@ export class TUI extends Container {
418
591
  #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
419
592
  #viewportTopRow = 0; // Content row currently mapped to screen row 0
420
593
  #manualViewportTop: number | undefined;
594
+ #viewportAnchorComponent: Component | null = null;
595
+ #viewportAnchorFrame: ViewportAnchorFrame | null = null;
596
+ #manualViewportAnchor: ManualViewportAnchor | null = null;
597
+ #manualViewportFallbackAnchors: ManualViewportAnchor[] = [];
598
+ #reconcileMissingViewportAnchor = false;
421
599
  #lastCursorPosition: { row: number; col: number } | null = null;
422
600
  #sixelProbePendingDa = false;
423
601
  #sixelProbePendingGraphics = false;
@@ -555,15 +733,100 @@ export class TUI extends Container {
555
733
  this.#bottomPinnedComponent = component;
556
734
  this.requestRender();
557
735
  }
736
+
737
+ /** Register the direct child whose rows are eligible for semantic viewport anchoring. */
738
+ setViewportAnchorComponent(component: Component | null): void {
739
+ if (component !== null && !isViewportAnchorProvider(component)) {
740
+ throw new TypeError("Viewport anchor components must provide renderer-owned row metadata");
741
+ }
742
+ if (this.#viewportAnchorComponent === component) return;
743
+ this.#viewportAnchorComponent = component;
744
+ this.#viewportAnchorFrame = null;
745
+ }
746
+
747
+ /** Clear manual viewport ownership before replacing the transcript identity namespace. */
748
+ resetViewportAnchorIntent(): void {
749
+ this.#manualViewportTop = undefined;
750
+ this.#manualViewportAnchor = null;
751
+ this.#manualViewportFallbackAnchors = [];
752
+ this.#reconcileMissingViewportAnchor = false;
753
+ this.#viewportAnchorFrame = null;
754
+ }
755
+
756
+ /** Allow one semantic-neighbor reconciliation after a definitive same-transcript rebuild. */
757
+ prepareViewportAnchorForTranscriptRebuild(): void {
758
+ if (this.#manualViewportAnchor !== null) this.#reconcileMissingViewportAnchor = true;
759
+ }
760
+
558
761
  scrollViewportPages(direction: -1 | 1): boolean {
559
762
  const height = this.terminal.rows;
560
763
  const width = this.terminal.columns;
561
764
  if (height <= 0 || width <= 0 || this.#previousLines.length === 0) return false;
562
765
  const maxViewportTop = Math.max(0, this.#previousLines.length - height);
563
- const currentViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop ?? this.#viewportTopRow));
564
- const pageStep = Math.max(1, height - 1);
565
- const targetViewportTop = Math.max(0, Math.min(maxViewportTop, currentViewportTop + direction * pageStep));
566
-
766
+ let currentViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop ?? this.#viewportTopRow));
767
+ const frame = this.#viewportAnchorFrame;
768
+ if (this.#manualViewportAnchor !== null) {
769
+ if (frame === null) return false;
770
+ const resolvedViewportTop = this.#resolveManualAnchor(frame);
771
+ if (resolvedViewportTop === null) return false;
772
+ currentViewportTop = Math.max(0, Math.min(maxViewportTop, resolvedViewportTop));
773
+ }
774
+ const targetViewportTop = Math.max(
775
+ 0,
776
+ Math.min(maxViewportTop, currentViewportTop + direction * Math.max(1, height - 1)),
777
+ );
778
+ if (frame !== null) {
779
+ const desiredScreenRow = this.#manualViewportAnchor?.desiredScreenRow ?? (direction < 0 ? 0 : height - 1);
780
+ const targetRow = targetViewportTop + desiredScreenRow - frame.startRow;
781
+ let selected: { row: number; anchor: ViewportAnchorRow } | undefined;
782
+ const firstCandidateRow = Math.max(0, targetViewportTop - frame.startRow);
783
+ const lastCandidateRow = Math.min(frame.anchors.length, targetViewportTop + height - frame.startRow);
784
+ for (let row = firstCandidateRow; row < lastCandidateRow; row++) {
785
+ const anchor = frame.anchors[row];
786
+ if (anchor === null) continue;
787
+ if (
788
+ selected === undefined ||
789
+ Math.abs(row - targetRow) < Math.abs(selected.row - targetRow) ||
790
+ (Math.abs(row - targetRow) === Math.abs(selected.row - targetRow) &&
791
+ (direction < 0 ? row < selected.row : row > selected.row))
792
+ )
793
+ selected = { row, anchor };
794
+ }
795
+ if (selected === undefined) {
796
+ if (this.#manualViewportAnchor !== null) return false;
797
+ } else {
798
+ this.#manualViewportAnchor = {
799
+ id: selected.anchor.id,
800
+ graphemeIndex:
801
+ direction < 0
802
+ ? selected.anchor.graphemeStart
803
+ : Math.max(selected.anchor.graphemeStart, selected.anchor.graphemeEnd - 1),
804
+ cellOffset:
805
+ direction < 0
806
+ ? selected.anchor.cellStart
807
+ : Math.max(selected.anchor.cellStart, selected.anchor.cellEnd - 1),
808
+ desiredScreenRow: selected.row + frame.startRow - targetViewportTop,
809
+ };
810
+ const fallbacks: ManualViewportAnchor[] = [];
811
+ for (let row = firstCandidateRow; row < lastCandidateRow; row++) {
812
+ const anchor = frame.anchors[row];
813
+ if (anchor === null || row === selected.row) continue;
814
+ fallbacks.push({
815
+ id: anchor.id,
816
+ graphemeIndex:
817
+ direction < 0 ? anchor.graphemeStart : Math.max(anchor.graphemeStart, anchor.graphemeEnd - 1),
818
+ cellOffset: direction < 0 ? anchor.cellStart : Math.max(anchor.cellStart, anchor.cellEnd - 1),
819
+ desiredScreenRow: row + frame.startRow - targetViewportTop,
820
+ });
821
+ }
822
+ fallbacks.sort(
823
+ (a, b) =>
824
+ Math.abs(a.desiredScreenRow - this.#manualViewportAnchor!.desiredScreenRow) -
825
+ Math.abs(b.desiredScreenRow - this.#manualViewportAnchor!.desiredScreenRow),
826
+ );
827
+ this.#manualViewportFallbackAnchors = fallbacks;
828
+ }
829
+ }
567
830
  this.#manualViewportTop = targetViewportTop;
568
831
  return this.#repaintViewportFromLines(
569
832
  this.#previousLines,
@@ -572,6 +835,7 @@ export class TUI extends Container {
572
835
  targetViewportTop,
573
836
  null,
574
837
  "manual viewport scroll",
838
+ this.#manualViewportAnchor !== null,
575
839
  );
576
840
  }
577
841
 
@@ -579,16 +843,22 @@ export class TUI extends Container {
579
843
  if (this.#manualViewportTop === undefined) return false;
580
844
  const height = this.terminal.rows;
581
845
  const width = this.terminal.columns;
582
- const liveViewportTop = Math.max(0, this.#previousLines.length - height);
846
+ const liveLines = this.#latestRenderedLines;
847
+ const liveViewportTop = Math.max(0, liveLines.length - height);
583
848
  this.#manualViewportTop = undefined;
584
- return this.#repaintViewportFromLines(
585
- this.#previousLines,
849
+ this.#manualViewportAnchor = null;
850
+ this.#manualViewportFallbackAnchors = [];
851
+ this.#reconcileMissingViewportAnchor = false;
852
+ const repainted = this.#repaintViewportFromLines(
853
+ liveLines,
586
854
  width,
587
855
  height,
588
856
  liveViewportTop,
589
857
  this.#lastCursorPosition,
590
858
  "manual viewport follow live",
591
859
  );
860
+ if (repainted) this.#previousLines = liveLines;
861
+ return repainted;
592
862
  }
593
863
 
594
864
  /**
@@ -757,8 +1027,7 @@ export class TUI extends Container {
757
1027
 
758
1028
  #querySixelSupport(): void {
759
1029
  if (TERMINAL.imageProtocol) return;
760
- if (process.platform !== "win32") return;
761
- if (!Bun.env.WT_SESSION) return;
1030
+ if (!this.#isSixelProbeCandidate()) return;
762
1031
  if (!process.stdin.isTTY || !process.stdout.isTTY) return;
763
1032
 
764
1033
  this.#clearSixelProbeState();
@@ -772,6 +1041,10 @@ export class TUI extends Container {
772
1041
  }, 250);
773
1042
  }
774
1043
 
1044
+ #isSixelProbeCandidate(): boolean {
1045
+ return shouldProbeSixelCapability();
1046
+ }
1047
+
775
1048
  #handleSixelProbeInput(data: string): InputListenerResult {
776
1049
  if (!this.#sixelProbePendingDa && !this.#sixelProbePendingGraphics) {
777
1050
  return undefined;
@@ -798,11 +1071,15 @@ export class TUI extends Container {
798
1071
 
799
1072
  if (useDa && this.#sixelProbePendingDa) {
800
1073
  this.#sixelProbePendingDa = false;
801
- const attributes = (match[1] ?? "")
1074
+ const params = (match[1] ?? "")
802
1075
  .split(";")
803
1076
  .map(value => Number.parseInt(value, 10))
804
1077
  .filter(value => Number.isFinite(value));
805
- const hasSixelAttribute = attributes.includes(4);
1078
+ // The first DA1 parameter is the device/operating class (e.g. 1,
1079
+ // 62, 64), not an extension attribute: `CSI ?4;6c` identifies a
1080
+ // VT132, it does not advertise sixel. Only the parameters after
1081
+ // the class carry attributes like 4 (sixel graphics).
1082
+ const hasSixelAttribute = params.slice(1).includes(4);
806
1083
  if (hasSixelAttribute) {
807
1084
  this.#sixelProbePendingGraphics = false;
808
1085
  probeOutcome = true;
@@ -811,8 +1088,11 @@ export class TUI extends Container {
811
1088
  }
812
1089
  } else if (!useDa && this.#sixelProbePendingGraphics) {
813
1090
  this.#sixelProbePendingGraphics = false;
1091
+ // XTSMGRAPHICS reply is `CSI ? 2 ; Ps ; ... S` where Ps=0 means
1092
+ // success and 1/2/3 are errors (tmux answers our unsupported
1093
+ // read with `CSI ?2;3;0S`). Only a success reply proves sixel.
814
1094
  const status = Number.parseInt(match[1] ?? "", 10);
815
- const supportsSixel = !Number.isNaN(status) && status !== 0;
1095
+ const supportsSixel = status === 0;
816
1096
  if (supportsSixel) {
817
1097
  this.#sixelProbePendingDa = false;
818
1098
  probeOutcome = true;
@@ -926,6 +1206,7 @@ export class TUI extends Container {
926
1206
  // focus/listener state is intentionally preserved so input routing survives
927
1207
  // a resume.
928
1208
  this.#previousLines = [];
1209
+ this.#latestRenderedLines = [];
929
1210
  this.#previousRaw = [];
930
1211
  this.#lineNormalizationCache.clear();
931
1212
  this.#lineTruncationCache.clear();
@@ -962,6 +1243,7 @@ export class TUI extends Container {
962
1243
  // A forced full redraw supersedes any queued input-priority render.
963
1244
  this.#inputRenderPending = false;
964
1245
  this.#previousLines = [];
1246
+ this.#latestRenderedLines = [];
965
1247
  this.#previousRaw = [];
966
1248
  this.#lineNormalizationCache.clear();
967
1249
  this.#lineTruncationCache.clear();
@@ -1547,7 +1829,11 @@ export class TUI extends Container {
1547
1829
  return lines;
1548
1830
  }
1549
1831
 
1550
- #padBeforeBottomPinnedComponent(lines: string[], height: number): string[] {
1832
+ #padBeforeBottomPinnedComponent(
1833
+ lines: string[],
1834
+ height: number,
1835
+ renderedChildren: Map<Component, string[]>,
1836
+ ): string[] {
1551
1837
  const component = this.#bottomPinnedComponent;
1552
1838
  if (component === null || lines.length >= height) return lines;
1553
1839
 
@@ -1562,7 +1848,7 @@ export class TUI extends Container {
1562
1848
 
1563
1849
  let pinnedLineCount = 0;
1564
1850
  for (let i = pinnedStart; i < this.children.length; i++) {
1565
- pinnedLineCount += safeRenderComponent(this.children[i], this.terminal.columns, "pinned").length;
1851
+ pinnedLineCount += (renderedChildren.get(this.children[i]) ?? []).length;
1566
1852
  }
1567
1853
 
1568
1854
  const blankRows = height - lines.length;
@@ -1571,6 +1857,55 @@ export class TUI extends Container {
1571
1857
  padded.splice(insertAt, 0, ...Array.from({ length: blankRows }, () => ""));
1572
1858
  return padded;
1573
1859
  }
1860
+ #resolveManualAnchor(frame: ViewportAnchorFrame): number | null {
1861
+ const anchor = this.#manualViewportAnchor;
1862
+ if (anchor === null) return null;
1863
+ const row = frame.anchors.findIndex(
1864
+ candidate =>
1865
+ candidate !== null &&
1866
+ candidate.id === anchor.id &&
1867
+ candidate.graphemeStart <= anchor.graphemeIndex &&
1868
+ anchor.graphemeIndex < candidate.graphemeEnd &&
1869
+ candidate.cellStart <= anchor.cellOffset &&
1870
+ anchor.cellOffset < candidate.cellEnd,
1871
+ );
1872
+ return row < 0 ? null : Math.max(0, frame.startRow + row - anchor.desiredScreenRow);
1873
+ }
1874
+
1875
+ #resolvePreparedManualAnchor(frame: ViewportAnchorFrame): number | null {
1876
+ const previous = this.#manualViewportAnchor;
1877
+ for (const fallback of this.#manualViewportFallbackAnchors) {
1878
+ this.#manualViewportAnchor = fallback;
1879
+ const resolved = this.#resolveManualAnchor(frame);
1880
+ if (resolved !== null) return resolved;
1881
+ }
1882
+ const targetRow = Math.max(
1883
+ 0,
1884
+ Math.min(
1885
+ frame.anchors.length - 1,
1886
+ (this.#manualViewportTop ?? 0) + (previous?.desiredScreenRow ?? 0) - frame.startRow,
1887
+ ),
1888
+ );
1889
+ let selectedRow = -1;
1890
+ for (let distance = 0; distance < frame.anchors.length; distance++) {
1891
+ for (const row of [targetRow - distance, targetRow + distance]) {
1892
+ if (row < 0 || row >= frame.anchors.length || frame.anchors[row] === null) continue;
1893
+ selectedRow = row;
1894
+ break;
1895
+ }
1896
+ if (selectedRow >= 0) break;
1897
+ }
1898
+ const selected = selectedRow >= 0 ? frame.anchors[selectedRow] : null;
1899
+ if (selected === null) return null;
1900
+ this.#manualViewportAnchor = {
1901
+ id: selected.id,
1902
+ graphemeIndex: selected.graphemeStart,
1903
+ cellOffset: selected.cellStart,
1904
+ desiredScreenRow: previous?.desiredScreenRow ?? 0,
1905
+ };
1906
+ return this.#resolveManualAnchor(frame);
1907
+ }
1908
+
1574
1909
  #repaintViewportFromLines(
1575
1910
  lines: string[],
1576
1911
  width: number,
@@ -1578,9 +1913,10 @@ export class TUI extends Container {
1578
1913
  viewportTop: number,
1579
1914
  cursorPos: { row: number; col: number } | null,
1580
1915
  reason: string,
1916
+ allowPastLiveBottom = false,
1581
1917
  ): boolean {
1582
1918
  if (height <= 0 || width <= 0) return false;
1583
- const maxViewportTop = Math.max(0, lines.length - height);
1919
+ const maxViewportTop = Math.max(0, lines.length - (allowPastLiveBottom ? 1 : height));
1584
1920
  const nextViewportTop = Math.max(0, Math.min(maxViewportTop, viewportTop));
1585
1921
  const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - this.#viewportTopRow));
1586
1922
  let buffer = "\x1b[?2026h";
@@ -1642,13 +1978,27 @@ export class TUI extends Container {
1642
1978
  return targetScreenRow - currentScreenRow;
1643
1979
  };
1644
1980
 
1645
- // Render all components to get new lines
1981
+ // Render direct children once so the registered transcript component retains row ownership.
1646
1982
  const renderTreeStart = renderMetrics.now();
1647
- let newLines = this.render(width);
1983
+ const renderedLines: string[] = [];
1984
+ const renderedChildren = new Map<Component, string[]>();
1985
+ let anchorFrame: ViewportAnchorFrame | null = null;
1986
+ const anchorRenderFailureCountBefore = viewportAnchorRenderFailureCount;
1987
+ for (const child of this.children) {
1988
+ const rendered = safeRenderComponentWithViewportAnchors(child, width, "tui-child");
1989
+ renderedChildren.set(child, rendered.lines);
1990
+ if (child === this.#viewportAnchorComponent && rendered.anchors.some(anchor => anchor !== null)) {
1991
+ anchorFrame = { startRow: renderedLines.length, anchors: rendered.anchors };
1992
+ }
1993
+ for (const line of rendered.lines) renderedLines.push(line);
1994
+ }
1995
+ const anchorRenderFailed = viewportAnchorRenderFailureCount !== anchorRenderFailureCountBefore;
1996
+ let newLines = renderedLines;
1997
+ this.#viewportAnchorFrame = anchorFrame;
1648
1998
  if (renderMetrics.enabled) renderMetrics.recordHelper("renderTree", renderMetrics.now() - renderTreeStart);
1649
1999
 
1650
2000
  if (this.#bottomPinnedComponent !== null && height > 0) {
1651
- newLines = this.#padBeforeBottomPinnedComponent(newLines, height);
2001
+ newLines = this.#padBeforeBottomPinnedComponent(newLines, height, renderedChildren);
1652
2002
  }
1653
2003
 
1654
2004
  // Composite overlays into the rendered lines (before differential compare)
@@ -1719,20 +2069,78 @@ export class TUI extends Container {
1719
2069
  renderMetrics.recordLineCount("measured", total - diffStart);
1720
2070
  if (usedWindowNormalize) renderMetrics.recordLineCount("offscreenScan", diffStart);
1721
2071
  }
2072
+ this.#latestRenderedLines = newLines;
1722
2073
 
1723
2074
  if (this.#manualViewportTop !== undefined) {
1724
- const maxViewportTop = Math.max(0, newLines.length - height);
1725
- const nextViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop));
2075
+ let resolvedAnchorTop = anchorFrame === null ? null : this.#resolveManualAnchor(anchorFrame);
2076
+ if (
2077
+ this.#manualViewportAnchor !== null &&
2078
+ resolvedAnchorTop === null &&
2079
+ this.#reconcileMissingViewportAnchor
2080
+ ) {
2081
+ resolvedAnchorTop = anchorFrame === null ? null : this.#resolvePreparedManualAnchor(anchorFrame);
2082
+ this.#reconcileMissingViewportAnchor = false;
2083
+ if (resolvedAnchorTop === null) {
2084
+ this.#manualViewportAnchor = null;
2085
+ this.#manualViewportFallbackAnchors = [];
2086
+ }
2087
+ }
2088
+ if (this.#manualViewportAnchor !== null && resolvedAnchorTop === null) {
2089
+ if (anchorRenderFailed) {
2090
+ // Keep semantic intent armed for recovery, but render the diagnostic frame
2091
+ // instead of masking a provider failure behind stale transcript content.
2092
+ this.#repaintViewportFromLines(
2093
+ newLines,
2094
+ width,
2095
+ height,
2096
+ this.#manualViewportTop,
2097
+ null,
2098
+ "failed semantic viewport render",
2099
+ true,
2100
+ );
2101
+ this.#previousLines = newLines;
2102
+ this.#previousWidth = width;
2103
+ this.#previousHeight = height;
2104
+ return;
2105
+ }
2106
+ // A formerly valid semantic target is temporarily absent (provider removal,
2107
+ // replacement, eviction, or object deletion). Keep the last resolved frame
2108
+ // instead of silently reinterpreting manual intent as a numeric viewport.
2109
+ const retainedLines = this.#previousLines.length > 0 ? this.#previousLines : newLines;
2110
+ this.#repaintViewportFromLines(
2111
+ retainedLines,
2112
+ width,
2113
+ height,
2114
+ this.#manualViewportTop,
2115
+ null,
2116
+ "unresolved semantic viewport render",
2117
+ true,
2118
+ );
2119
+ this.#previousWidth = width;
2120
+ this.#previousHeight = height;
2121
+ return;
2122
+ }
2123
+ const nextViewportTop = resolvedAnchorTop ?? this.#manualViewportTop;
2124
+ if (
2125
+ this.#previousWidth === width &&
2126
+ this.#previousHeight === height &&
2127
+ nextViewportTop === this.#manualViewportTop &&
2128
+ newLines.length === this.#previousLines.length &&
2129
+ newLines.every((line, index) => line === this.#previousLines[index])
2130
+ ) {
2131
+ return;
2132
+ }
1726
2133
  this.#manualViewportTop = nextViewportTop;
1727
- const repaintCursorPos = null;
2134
+ this.#reconcileMissingViewportAnchor = false;
1728
2135
  if (
1729
2136
  this.#repaintViewportFromLines(
1730
2137
  newLines,
1731
2138
  width,
1732
2139
  height,
1733
2140
  nextViewportTop,
1734
- repaintCursorPos,
2141
+ null,
1735
2142
  "manual viewport render",
2143
+ this.#manualViewportAnchor !== null,
1736
2144
  )
1737
2145
  ) {
1738
2146
  this.#previousLines = newLines;
@@ -1878,7 +2286,11 @@ export class TUI extends Container {
1878
2286
  // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
1879
2287
  if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
1880
2288
  logRedraw(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
1881
- if (useViewportRepaintPath(this.terminal)) {
2289
+ if (
2290
+ useViewportRepaintPath(this.terminal) ||
2291
+ ((this.#previousLines.length > height || newLines.length > height) &&
2292
+ allowsHostNeutralOverflowRepaint(this.terminal))
2293
+ ) {
1882
2294
  viewportRepaint(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
1883
2295
  } else {
1884
2296
  fullRender(true, "clearOnShrink");
@@ -1981,7 +2393,12 @@ export class TUI extends Container {
1981
2393
  // back to live.
1982
2394
  if (firstChanged < prevViewportTop) {
1983
2395
  logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1984
- if (useViewportRepaintPath(this.terminal)) {
2396
+ if (
2397
+ useViewportRepaintPath(this.terminal) ||
2398
+ (newLines.length <= this.#previousLines.length &&
2399
+ (this.#previousLines.length > height || newLines.length > height) &&
2400
+ allowsHostNeutralOverflowRepaint(this.terminal))
2401
+ ) {
1985
2402
  viewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1986
2403
  return;
1987
2404
  }
@@ -2163,11 +2580,30 @@ export class TUI extends Container {
2163
2580
  return { seq, toRow: targetRow };
2164
2581
  }
2165
2582
 
2583
+ /**
2584
+ * Register an emitter whose escape payload is appended to every render
2585
+ * write (inside its own synchronized-output block, cursor saved/restored).
2586
+ * Used for absolute-positioned overlays such as pixel-image pets that live
2587
+ * outside the line-based component model. Return null to emit nothing.
2588
+ */
2589
+ setPostRenderEmitter(emitter: (() => string | null) | undefined): void {
2590
+ this.#postRenderEmitter = emitter;
2591
+ }
2592
+
2593
+ #postRenderEmitter: (() => string | null) | undefined;
2594
+
2166
2595
  #writeRenderBufferAndReanchorImeCursor(
2167
2596
  buffer: string,
2168
2597
  cursorPos: { row: number; col: number } | null,
2169
2598
  totalLines: number,
2170
2599
  ): boolean {
2600
+ const overlay = this.#postRenderEmitter?.();
2601
+ if (overlay) {
2602
+ // DECSC/DECRC keep the hardware cursor stable; the dedicated
2603
+ // synchronized block prevents visible tearing while the overlay
2604
+ // area is cleared and redrawn.
2605
+ buffer += `\x1b[?2026h\x1b7${overlay}\x1b8\x1b[?2026l`;
2606
+ }
2171
2607
  if (!this.#writeTerminal(buffer)) return false;
2172
2608
  if (!this.#imeCursorActive) return true;
2173
2609
  return this.#writeCursorPosition(cursorPos, totalLines);