@sayknow-cli/tui 0.3.11 → 0.3.13

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.
Files changed (39) hide show
  1. package/package.json +8 -9
  2. package/src/components/image.ts +79 -39
  3. package/src/components/markdown.ts +282 -37
  4. package/src/components/sayknow-pet.ts +435 -0
  5. package/src/components/text.ts +60 -36
  6. package/src/index.ts +1 -0
  7. package/src/terminal-capabilities.ts +42 -0
  8. package/src/tui.ts +471 -55
  9. package/src/utils.ts +144 -8
  10. package/dist/types/animation-scheduler.d.ts +0 -13
  11. package/dist/types/autocomplete.d.ts +0 -83
  12. package/dist/types/bracketed-paste.d.ts +0 -26
  13. package/dist/types/components/box.d.ts +0 -20
  14. package/dist/types/components/cancellable-loader.d.ts +0 -21
  15. package/dist/types/components/editor.d.ts +0 -126
  16. package/dist/types/components/image.d.ts +0 -16
  17. package/dist/types/components/input.d.ts +0 -16
  18. package/dist/types/components/loader.d.ts +0 -23
  19. package/dist/types/components/markdown.d.ts +0 -77
  20. package/dist/types/components/select-list.d.ts +0 -46
  21. package/dist/types/components/settings-list.d.ts +0 -39
  22. package/dist/types/components/spacer.d.ts +0 -11
  23. package/dist/types/components/tab-bar.d.ts +0 -56
  24. package/dist/types/components/text.d.ts +0 -13
  25. package/dist/types/components/truncated-text.d.ts +0 -10
  26. package/dist/types/editor-component.d.ts +0 -36
  27. package/dist/types/fuzzy.d.ts +0 -15
  28. package/dist/types/index.d.ts +0 -27
  29. package/dist/types/keybindings.d.ts +0 -201
  30. package/dist/types/keys.d.ts +0 -208
  31. package/dist/types/kill-ring.d.ts +0 -27
  32. package/dist/types/metrics.d.ts +0 -85
  33. package/dist/types/stdin-buffer.d.ts +0 -50
  34. package/dist/types/symbols.d.ts +0 -23
  35. package/dist/types/terminal-capabilities.d.ts +0 -143
  36. package/dist/types/terminal.d.ts +0 -90
  37. package/dist/types/ttyid.d.ts +0 -9
  38. package/dist/types/tui.d.ts +0 -215
  39. package/dist/types/utils.d.ts +0 -87
package/src/tui.ts CHANGED
@@ -98,6 +98,82 @@ export const CURSOR_MARKER = "\x1b_pi:c\x07";
98
98
 
99
99
  export { visibleWidth };
100
100
 
101
+ /** Durable source identifier for a semantically anchored viewport row. */
102
+ export type ViewportAnchorId = string;
103
+
104
+ export interface ViewportAnchorRow {
105
+ id: ViewportAnchorId;
106
+ graphemeStart: number;
107
+ graphemeEnd: number;
108
+ cellStart: number;
109
+ cellEnd: number;
110
+ }
111
+
112
+ export interface ViewportAnchorRender {
113
+ lines: string[];
114
+ anchors: Array<ViewportAnchorRow | null>;
115
+ }
116
+
117
+ export interface ViewportAnchorProvider extends Component {
118
+ renderWithViewportAnchors(width: number): ViewportAnchorRender;
119
+ }
120
+
121
+ export interface ViewportAnchorSource {
122
+ id: ViewportAnchorId;
123
+ }
124
+
125
+ export interface ViewportAnchorSourceRenderer extends Component {
126
+ renderWithViewportAnchorSource(width: number, source: ViewportAnchorSource): ViewportAnchorRender;
127
+ }
128
+
129
+ export function isViewportAnchorProvider(component: Component): component is ViewportAnchorProvider {
130
+ if (!("renderWithViewportAnchors" in component) || typeof component.renderWithViewportAnchors !== "function") {
131
+ return false;
132
+ }
133
+ return !(
134
+ component instanceof Container &&
135
+ component.renderWithViewportAnchors === Container.prototype.renderWithViewportAnchors &&
136
+ component.render !== Container.prototype.render
137
+ );
138
+ }
139
+
140
+ export function isViewportAnchorSourceRenderer(component: Component): component is ViewportAnchorSourceRenderer {
141
+ return (
142
+ "renderWithViewportAnchorSource" in component && typeof component.renderWithViewportAnchorSource === "function"
143
+ );
144
+ }
145
+
146
+ export function renderComponentWithViewportAnchors(component: Component, width: number): ViewportAnchorRender {
147
+ if (isViewportAnchorProvider(component)) {
148
+ const rendered = component.renderWithViewportAnchors(width);
149
+ if (rendered.anchors.length !== rendered.lines.length) {
150
+ throw new Error(
151
+ `Viewport anchor provider returned ${rendered.anchors.length} anchors for ${rendered.lines.length} lines`,
152
+ );
153
+ }
154
+ return rendered;
155
+ }
156
+ const lines = component.render(width);
157
+ return { lines, anchors: lines.map(() => null) };
158
+ }
159
+
160
+ export function renderComponentWithViewportAnchorSource(
161
+ component: Component,
162
+ width: number,
163
+ source: ViewportAnchorSource,
164
+ ): ViewportAnchorRender {
165
+ if (!isViewportAnchorSourceRenderer(component)) {
166
+ throw new TypeError("Viewport anchor sources require renderer-owned row metadata");
167
+ }
168
+ const rendered = component.renderWithViewportAnchorSource(width, source);
169
+ if (rendered.anchors.length !== rendered.lines.length) {
170
+ throw new Error(
171
+ `Viewport anchor source renderer returned ${rendered.anchors.length} anchors for ${rendered.lines.length} lines`,
172
+ );
173
+ }
174
+ return rendered;
175
+ }
176
+
101
177
  /**
102
178
  * Anchor position for overlays
103
179
  */
@@ -212,6 +288,17 @@ function useViewportRepaintPath(terminal: Terminal): boolean {
212
288
  });
213
289
  }
214
290
 
291
+ function allowsHostNeutralOverflowRepaint(
292
+ terminal: Terminal,
293
+ env: Record<string, string | undefined> = Bun.env,
294
+ ): boolean {
295
+ return (
296
+ terminal.isProcessTerminal === true &&
297
+ !isTermuxSession(env) &&
298
+ !(isMultiplexerSession(env) && useLegacyMultiplexerFullRender(env))
299
+ );
300
+ }
301
+
215
302
  function shouldPreserveScrollbackOnFullClear(terminal: Terminal): boolean {
216
303
  return isViewportSensitiveHost(Bun.env, process.platform, terminal.isProcessTerminal === true);
217
304
  }
@@ -271,9 +358,10 @@ export interface OverlayHandle {
271
358
  /**
272
359
  * Container - a component that contains other components
273
360
  */
274
- export class Container implements Component {
361
+ export class Container implements ViewportAnchorProvider {
275
362
  children: Component[] = [];
276
363
  #disposed = false;
364
+ #viewportAnchorSources = new Map<Component, ViewportAnchorSource>();
277
365
 
278
366
  addChild(component: Component): void {
279
367
  this.children.push(component);
@@ -283,6 +371,7 @@ export class Container implements Component {
283
371
  const index = this.children.indexOf(component);
284
372
  if (index !== -1) {
285
373
  this.children.splice(index, 1);
374
+ this.#viewportAnchorSources.delete(component);
286
375
  component.dispose?.();
287
376
  }
288
377
  }
@@ -292,45 +381,62 @@ export class Container implements Component {
292
381
  const index = this.children.indexOf(component);
293
382
  if (index !== -1) {
294
383
  this.children.splice(index, 1);
384
+ this.#viewportAnchorSources.delete(component);
295
385
  }
296
386
  }
297
387
 
298
388
  clear(): void {
299
- for (const child of this.children) {
300
- child.dispose?.();
301
- }
389
+ for (const child of this.children) child.dispose?.();
302
390
  this.children = [];
391
+ this.#viewportAnchorSources.clear();
303
392
  }
304
393
 
305
394
  /** Remove all children without disposing them (for detach-then-readd reuse). */
306
395
  detachAll(): void {
307
396
  this.children = [];
397
+ this.#viewportAnchorSources.clear();
398
+ }
399
+
400
+ /** Registers a direct child as eligible for semantic viewport anchoring. */
401
+ setViewportAnchorSource(component: Component, source: ViewportAnchorSource | null): void {
402
+ if (source !== null && !isViewportAnchorSourceRenderer(component)) {
403
+ throw new TypeError("Viewport anchor sources require renderer-owned row metadata");
404
+ }
405
+ if (source === null) this.#viewportAnchorSources.delete(component);
406
+ else this.#viewportAnchorSources.set(component, source);
308
407
  }
309
408
 
310
409
  dispose(): void {
311
410
  if (this.#disposed) return;
312
411
  this.#disposed = true;
313
- for (const child of this.children) {
314
- child.dispose?.();
315
- }
412
+ for (const child of this.children) child.dispose?.();
413
+ this.#viewportAnchorSources.clear();
316
414
  }
317
415
 
318
416
  invalidate(): void {
319
- for (const child of this.children) {
320
- child.invalidate?.();
321
- }
417
+ for (const child of this.children) child.invalidate?.();
322
418
  }
323
419
 
324
420
  render(width: number): string[] {
421
+ return this.renderWithViewportAnchors(width).lines;
422
+ }
423
+
424
+ renderWithViewportAnchors(width: number): ViewportAnchorRender {
325
425
  width = Math.max(1, width);
326
426
  const lines: string[] = [];
427
+ const anchors: Array<ViewportAnchorRow | null> = [];
327
428
  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]);
429
+ const source = this.#viewportAnchorSources.get(child);
430
+ const rendered =
431
+ source === undefined
432
+ ? safeRenderComponentWithViewportAnchors(child, width, "container-child")
433
+ : safeRenderComponentWithViewportAnchorSource(child, width, source, "container-anchor-child");
434
+ for (let index = 0; index < rendered.lines.length; index++) {
435
+ lines.push(rendered.lines[index]);
436
+ anchors.push(rendered.anchors[index] ?? null);
331
437
  }
332
438
  }
333
- return lines;
439
+ return { lines, anchors };
334
440
  }
335
441
  }
336
442
 
@@ -348,23 +454,58 @@ const reportedRenderErrors = new Set<string>();
348
454
  * command such as `/background`). Isolate the failure: log it once, emit a
349
455
  * visible fallback line, and keep rendering the rest of the tree.
350
456
  */
457
+ function renderFailure(component: Component, where: string, err: unknown): string[] {
458
+ const name = component?.constructor?.name ?? "Component";
459
+ const key = `${where}:${name}:${err instanceof Error ? err.message : String(err)}`;
460
+ if (!reportedRenderErrors.has(key)) {
461
+ if (reportedRenderErrors.size >= MAX_REPORTED_RENDER_ERRORS) reportedRenderErrors.clear();
462
+ reportedRenderErrors.add(key);
463
+ logger.error("Component render failed; emitting fallback line", {
464
+ where,
465
+ component: name,
466
+ error: err instanceof Error ? err.message : String(err),
467
+ stack: err instanceof Error ? err.stack : undefined,
468
+ });
469
+ }
470
+ return [`[render error: ${name}]`];
471
+ }
472
+
473
+ let viewportAnchorRenderFailureCount = 0;
474
+
351
475
  function safeRenderComponent(component: Component, width: number, where: string): string[] {
352
476
  try {
353
477
  return component.render(width);
354
478
  } 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}]`];
479
+ return renderFailure(component, where, err);
480
+ }
481
+ }
482
+
483
+ function safeRenderComponentWithViewportAnchors(
484
+ component: Component,
485
+ width: number,
486
+ where: string,
487
+ ): ViewportAnchorRender {
488
+ try {
489
+ return renderComponentWithViewportAnchors(component, width);
490
+ } catch (err) {
491
+ viewportAnchorRenderFailureCount += 1;
492
+ const lines = renderFailure(component, where, err);
493
+ return { lines, anchors: lines.map(() => null) };
494
+ }
495
+ }
496
+
497
+ function safeRenderComponentWithViewportAnchorSource(
498
+ component: Component,
499
+ width: number,
500
+ source: ViewportAnchorSource,
501
+ where: string,
502
+ ): ViewportAnchorRender {
503
+ try {
504
+ return renderComponentWithViewportAnchorSource(component, width, source);
505
+ } catch (err) {
506
+ viewportAnchorRenderFailureCount += 1;
507
+ const lines = renderFailure(component, where, err);
508
+ return { lines, anchors: lines.map(() => null) };
368
509
  }
369
510
  }
370
511
 
@@ -374,6 +515,18 @@ type LineNormalizationCacheEntry = {
374
515
  width: number | undefined;
375
516
  };
376
517
 
518
+ type ViewportAnchorFrame = {
519
+ startRow: number;
520
+ anchors: Array<ViewportAnchorRow | null>;
521
+ };
522
+
523
+ type ManualViewportAnchor = {
524
+ id: ViewportAnchorId;
525
+ graphemeIndex: number;
526
+ cellOffset: number;
527
+ desiredScreenRow: number;
528
+ };
529
+
377
530
  type TuiRenderCounterSnapshot = {
378
531
  debugRedrawEnvReads: number;
379
532
  debugRedrawAppendWrites: number;
@@ -386,6 +539,7 @@ type TuiRenderCounterSnapshot = {
386
539
  export class TUI extends Container {
387
540
  terminal: Terminal;
388
541
  #previousLines: string[] = [];
542
+ #latestRenderedLines: string[] = [];
389
543
  /**
390
544
  * Raw (pre-normalization) lines from the previous frame, kept only when the
391
545
  * virtual-viewport flag is on. Used to detect whether the off-screen prefix is
@@ -418,6 +572,11 @@ export class TUI extends Container {
418
572
  #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
419
573
  #viewportTopRow = 0; // Content row currently mapped to screen row 0
420
574
  #manualViewportTop: number | undefined;
575
+ #viewportAnchorComponent: Component | null = null;
576
+ #viewportAnchorFrame: ViewportAnchorFrame | null = null;
577
+ #manualViewportAnchor: ManualViewportAnchor | null = null;
578
+ #manualViewportFallbackAnchors: ManualViewportAnchor[] = [];
579
+ #reconcileMissingViewportAnchor = false;
421
580
  #lastCursorPosition: { row: number; col: number } | null = null;
422
581
  #sixelProbePendingDa = false;
423
582
  #sixelProbePendingGraphics = false;
@@ -555,15 +714,100 @@ export class TUI extends Container {
555
714
  this.#bottomPinnedComponent = component;
556
715
  this.requestRender();
557
716
  }
717
+
718
+ /** Register the direct child whose rows are eligible for semantic viewport anchoring. */
719
+ setViewportAnchorComponent(component: Component | null): void {
720
+ if (component !== null && !isViewportAnchorProvider(component)) {
721
+ throw new TypeError("Viewport anchor components must provide renderer-owned row metadata");
722
+ }
723
+ if (this.#viewportAnchorComponent === component) return;
724
+ this.#viewportAnchorComponent = component;
725
+ this.#viewportAnchorFrame = null;
726
+ }
727
+
728
+ /** Clear manual viewport ownership before replacing the transcript identity namespace. */
729
+ resetViewportAnchorIntent(): void {
730
+ this.#manualViewportTop = undefined;
731
+ this.#manualViewportAnchor = null;
732
+ this.#manualViewportFallbackAnchors = [];
733
+ this.#reconcileMissingViewportAnchor = false;
734
+ this.#viewportAnchorFrame = null;
735
+ }
736
+
737
+ /** Allow one semantic-neighbor reconciliation after a definitive same-transcript rebuild. */
738
+ prepareViewportAnchorForTranscriptRebuild(): void {
739
+ if (this.#manualViewportAnchor !== null) this.#reconcileMissingViewportAnchor = true;
740
+ }
741
+
558
742
  scrollViewportPages(direction: -1 | 1): boolean {
559
743
  const height = this.terminal.rows;
560
744
  const width = this.terminal.columns;
561
745
  if (height <= 0 || width <= 0 || this.#previousLines.length === 0) return false;
562
746
  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
-
747
+ let currentViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop ?? this.#viewportTopRow));
748
+ const frame = this.#viewportAnchorFrame;
749
+ if (this.#manualViewportAnchor !== null) {
750
+ if (frame === null) return false;
751
+ const resolvedViewportTop = this.#resolveManualAnchor(frame);
752
+ if (resolvedViewportTop === null) return false;
753
+ currentViewportTop = Math.max(0, Math.min(maxViewportTop, resolvedViewportTop));
754
+ }
755
+ const targetViewportTop = Math.max(
756
+ 0,
757
+ Math.min(maxViewportTop, currentViewportTop + direction * Math.max(1, height - 1)),
758
+ );
759
+ if (frame !== null) {
760
+ const desiredScreenRow = this.#manualViewportAnchor?.desiredScreenRow ?? (direction < 0 ? 0 : height - 1);
761
+ const targetRow = targetViewportTop + desiredScreenRow - frame.startRow;
762
+ let selected: { row: number; anchor: ViewportAnchorRow } | undefined;
763
+ const firstCandidateRow = Math.max(0, targetViewportTop - frame.startRow);
764
+ const lastCandidateRow = Math.min(frame.anchors.length, targetViewportTop + height - frame.startRow);
765
+ for (let row = firstCandidateRow; row < lastCandidateRow; row++) {
766
+ const anchor = frame.anchors[row];
767
+ if (anchor === null) continue;
768
+ if (
769
+ selected === undefined ||
770
+ Math.abs(row - targetRow) < Math.abs(selected.row - targetRow) ||
771
+ (Math.abs(row - targetRow) === Math.abs(selected.row - targetRow) &&
772
+ (direction < 0 ? row < selected.row : row > selected.row))
773
+ )
774
+ selected = { row, anchor };
775
+ }
776
+ if (selected === undefined) {
777
+ if (this.#manualViewportAnchor !== null) return false;
778
+ } else {
779
+ this.#manualViewportAnchor = {
780
+ id: selected.anchor.id,
781
+ graphemeIndex:
782
+ direction < 0
783
+ ? selected.anchor.graphemeStart
784
+ : Math.max(selected.anchor.graphemeStart, selected.anchor.graphemeEnd - 1),
785
+ cellOffset:
786
+ direction < 0
787
+ ? selected.anchor.cellStart
788
+ : Math.max(selected.anchor.cellStart, selected.anchor.cellEnd - 1),
789
+ desiredScreenRow: selected.row + frame.startRow - targetViewportTop,
790
+ };
791
+ const fallbacks: ManualViewportAnchor[] = [];
792
+ for (let row = firstCandidateRow; row < lastCandidateRow; row++) {
793
+ const anchor = frame.anchors[row];
794
+ if (anchor === null || row === selected.row) continue;
795
+ fallbacks.push({
796
+ id: anchor.id,
797
+ graphemeIndex:
798
+ direction < 0 ? anchor.graphemeStart : Math.max(anchor.graphemeStart, anchor.graphemeEnd - 1),
799
+ cellOffset: direction < 0 ? anchor.cellStart : Math.max(anchor.cellStart, anchor.cellEnd - 1),
800
+ desiredScreenRow: row + frame.startRow - targetViewportTop,
801
+ });
802
+ }
803
+ fallbacks.sort(
804
+ (a, b) =>
805
+ Math.abs(a.desiredScreenRow - this.#manualViewportAnchor!.desiredScreenRow) -
806
+ Math.abs(b.desiredScreenRow - this.#manualViewportAnchor!.desiredScreenRow),
807
+ );
808
+ this.#manualViewportFallbackAnchors = fallbacks;
809
+ }
810
+ }
567
811
  this.#manualViewportTop = targetViewportTop;
568
812
  return this.#repaintViewportFromLines(
569
813
  this.#previousLines,
@@ -572,6 +816,7 @@ export class TUI extends Container {
572
816
  targetViewportTop,
573
817
  null,
574
818
  "manual viewport scroll",
819
+ this.#manualViewportAnchor !== null,
575
820
  );
576
821
  }
577
822
 
@@ -579,16 +824,22 @@ export class TUI extends Container {
579
824
  if (this.#manualViewportTop === undefined) return false;
580
825
  const height = this.terminal.rows;
581
826
  const width = this.terminal.columns;
582
- const liveViewportTop = Math.max(0, this.#previousLines.length - height);
827
+ const liveLines = this.#latestRenderedLines;
828
+ const liveViewportTop = Math.max(0, liveLines.length - height);
583
829
  this.#manualViewportTop = undefined;
584
- return this.#repaintViewportFromLines(
585
- this.#previousLines,
830
+ this.#manualViewportAnchor = null;
831
+ this.#manualViewportFallbackAnchors = [];
832
+ this.#reconcileMissingViewportAnchor = false;
833
+ const repainted = this.#repaintViewportFromLines(
834
+ liveLines,
586
835
  width,
587
836
  height,
588
837
  liveViewportTop,
589
838
  this.#lastCursorPosition,
590
839
  "manual viewport follow live",
591
840
  );
841
+ if (repainted) this.#previousLines = liveLines;
842
+ return repainted;
592
843
  }
593
844
 
594
845
  /**
@@ -926,6 +1177,7 @@ export class TUI extends Container {
926
1177
  // focus/listener state is intentionally preserved so input routing survives
927
1178
  // a resume.
928
1179
  this.#previousLines = [];
1180
+ this.#latestRenderedLines = [];
929
1181
  this.#previousRaw = [];
930
1182
  this.#lineNormalizationCache.clear();
931
1183
  this.#lineTruncationCache.clear();
@@ -962,6 +1214,7 @@ export class TUI extends Container {
962
1214
  // A forced full redraw supersedes any queued input-priority render.
963
1215
  this.#inputRenderPending = false;
964
1216
  this.#previousLines = [];
1217
+ this.#latestRenderedLines = [];
965
1218
  this.#previousRaw = [];
966
1219
  this.#lineNormalizationCache.clear();
967
1220
  this.#lineTruncationCache.clear();
@@ -1547,7 +1800,11 @@ export class TUI extends Container {
1547
1800
  return lines;
1548
1801
  }
1549
1802
 
1550
- #padBeforeBottomPinnedComponent(lines: string[], height: number): string[] {
1803
+ #padBeforeBottomPinnedComponent(
1804
+ lines: string[],
1805
+ height: number,
1806
+ renderedChildren: Map<Component, string[]>,
1807
+ ): string[] {
1551
1808
  const component = this.#bottomPinnedComponent;
1552
1809
  if (component === null || lines.length >= height) return lines;
1553
1810
 
@@ -1562,7 +1819,7 @@ export class TUI extends Container {
1562
1819
 
1563
1820
  let pinnedLineCount = 0;
1564
1821
  for (let i = pinnedStart; i < this.children.length; i++) {
1565
- pinnedLineCount += safeRenderComponent(this.children[i], this.terminal.columns, "pinned").length;
1822
+ pinnedLineCount += (renderedChildren.get(this.children[i]) ?? []).length;
1566
1823
  }
1567
1824
 
1568
1825
  const blankRows = height - lines.length;
@@ -1571,6 +1828,55 @@ export class TUI extends Container {
1571
1828
  padded.splice(insertAt, 0, ...Array.from({ length: blankRows }, () => ""));
1572
1829
  return padded;
1573
1830
  }
1831
+ #resolveManualAnchor(frame: ViewportAnchorFrame): number | null {
1832
+ const anchor = this.#manualViewportAnchor;
1833
+ if (anchor === null) return null;
1834
+ const row = frame.anchors.findIndex(
1835
+ candidate =>
1836
+ candidate !== null &&
1837
+ candidate.id === anchor.id &&
1838
+ candidate.graphemeStart <= anchor.graphemeIndex &&
1839
+ anchor.graphemeIndex < candidate.graphemeEnd &&
1840
+ candidate.cellStart <= anchor.cellOffset &&
1841
+ anchor.cellOffset < candidate.cellEnd,
1842
+ );
1843
+ return row < 0 ? null : Math.max(0, frame.startRow + row - anchor.desiredScreenRow);
1844
+ }
1845
+
1846
+ #resolvePreparedManualAnchor(frame: ViewportAnchorFrame): number | null {
1847
+ const previous = this.#manualViewportAnchor;
1848
+ for (const fallback of this.#manualViewportFallbackAnchors) {
1849
+ this.#manualViewportAnchor = fallback;
1850
+ const resolved = this.#resolveManualAnchor(frame);
1851
+ if (resolved !== null) return resolved;
1852
+ }
1853
+ const targetRow = Math.max(
1854
+ 0,
1855
+ Math.min(
1856
+ frame.anchors.length - 1,
1857
+ (this.#manualViewportTop ?? 0) + (previous?.desiredScreenRow ?? 0) - frame.startRow,
1858
+ ),
1859
+ );
1860
+ let selectedRow = -1;
1861
+ for (let distance = 0; distance < frame.anchors.length; distance++) {
1862
+ for (const row of [targetRow - distance, targetRow + distance]) {
1863
+ if (row < 0 || row >= frame.anchors.length || frame.anchors[row] === null) continue;
1864
+ selectedRow = row;
1865
+ break;
1866
+ }
1867
+ if (selectedRow >= 0) break;
1868
+ }
1869
+ const selected = selectedRow >= 0 ? frame.anchors[selectedRow] : null;
1870
+ if (selected === null) return null;
1871
+ this.#manualViewportAnchor = {
1872
+ id: selected.id,
1873
+ graphemeIndex: selected.graphemeStart,
1874
+ cellOffset: selected.cellStart,
1875
+ desiredScreenRow: previous?.desiredScreenRow ?? 0,
1876
+ };
1877
+ return this.#resolveManualAnchor(frame);
1878
+ }
1879
+
1574
1880
  #repaintViewportFromLines(
1575
1881
  lines: string[],
1576
1882
  width: number,
@@ -1578,9 +1884,10 @@ export class TUI extends Container {
1578
1884
  viewportTop: number,
1579
1885
  cursorPos: { row: number; col: number } | null,
1580
1886
  reason: string,
1887
+ allowPastLiveBottom = false,
1581
1888
  ): boolean {
1582
1889
  if (height <= 0 || width <= 0) return false;
1583
- const maxViewportTop = Math.max(0, lines.length - height);
1890
+ const maxViewportTop = Math.max(0, lines.length - (allowPastLiveBottom ? 1 : height));
1584
1891
  const nextViewportTop = Math.max(0, Math.min(maxViewportTop, viewportTop));
1585
1892
  const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - this.#viewportTopRow));
1586
1893
  let buffer = "\x1b[?2026h";
@@ -1616,7 +1923,7 @@ export class TUI extends Container {
1616
1923
  this.#hardwareCursorRow = cursorToRow;
1617
1924
  buffer += cursorSeq;
1618
1925
  buffer += "\x1b[?2026l";
1619
- if (!this.#writeTerminal(buffer)) return false;
1926
+ if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, lines.length)) return false;
1620
1927
 
1621
1928
  if (this.#debugRedraw) {
1622
1929
  const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
@@ -1642,13 +1949,27 @@ export class TUI extends Container {
1642
1949
  return targetScreenRow - currentScreenRow;
1643
1950
  };
1644
1951
 
1645
- // Render all components to get new lines
1952
+ // Render direct children once so the registered transcript component retains row ownership.
1646
1953
  const renderTreeStart = renderMetrics.now();
1647
- let newLines = this.render(width);
1954
+ const renderedLines: string[] = [];
1955
+ const renderedChildren = new Map<Component, string[]>();
1956
+ let anchorFrame: ViewportAnchorFrame | null = null;
1957
+ const anchorRenderFailureCountBefore = viewportAnchorRenderFailureCount;
1958
+ for (const child of this.children) {
1959
+ const rendered = safeRenderComponentWithViewportAnchors(child, width, "tui-child");
1960
+ renderedChildren.set(child, rendered.lines);
1961
+ if (child === this.#viewportAnchorComponent && rendered.anchors.some(anchor => anchor !== null)) {
1962
+ anchorFrame = { startRow: renderedLines.length, anchors: rendered.anchors };
1963
+ }
1964
+ for (const line of rendered.lines) renderedLines.push(line);
1965
+ }
1966
+ const anchorRenderFailed = viewportAnchorRenderFailureCount !== anchorRenderFailureCountBefore;
1967
+ let newLines = renderedLines;
1968
+ this.#viewportAnchorFrame = anchorFrame;
1648
1969
  if (renderMetrics.enabled) renderMetrics.recordHelper("renderTree", renderMetrics.now() - renderTreeStart);
1649
1970
 
1650
1971
  if (this.#bottomPinnedComponent !== null && height > 0) {
1651
- newLines = this.#padBeforeBottomPinnedComponent(newLines, height);
1972
+ newLines = this.#padBeforeBottomPinnedComponent(newLines, height, renderedChildren);
1652
1973
  }
1653
1974
 
1654
1975
  // Composite overlays into the rendered lines (before differential compare)
@@ -1719,20 +2040,78 @@ export class TUI extends Container {
1719
2040
  renderMetrics.recordLineCount("measured", total - diffStart);
1720
2041
  if (usedWindowNormalize) renderMetrics.recordLineCount("offscreenScan", diffStart);
1721
2042
  }
2043
+ this.#latestRenderedLines = newLines;
1722
2044
 
1723
2045
  if (this.#manualViewportTop !== undefined) {
1724
- const maxViewportTop = Math.max(0, newLines.length - height);
1725
- const nextViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop));
2046
+ let resolvedAnchorTop = anchorFrame === null ? null : this.#resolveManualAnchor(anchorFrame);
2047
+ if (
2048
+ this.#manualViewportAnchor !== null &&
2049
+ resolvedAnchorTop === null &&
2050
+ this.#reconcileMissingViewportAnchor
2051
+ ) {
2052
+ resolvedAnchorTop = anchorFrame === null ? null : this.#resolvePreparedManualAnchor(anchorFrame);
2053
+ this.#reconcileMissingViewportAnchor = false;
2054
+ if (resolvedAnchorTop === null) {
2055
+ this.#manualViewportAnchor = null;
2056
+ this.#manualViewportFallbackAnchors = [];
2057
+ }
2058
+ }
2059
+ if (this.#manualViewportAnchor !== null && resolvedAnchorTop === null) {
2060
+ if (anchorRenderFailed) {
2061
+ // Keep semantic intent armed for recovery, but render the diagnostic frame
2062
+ // instead of masking a provider failure behind stale transcript content.
2063
+ this.#repaintViewportFromLines(
2064
+ newLines,
2065
+ width,
2066
+ height,
2067
+ this.#manualViewportTop,
2068
+ null,
2069
+ "failed semantic viewport render",
2070
+ true,
2071
+ );
2072
+ this.#previousLines = newLines;
2073
+ this.#previousWidth = width;
2074
+ this.#previousHeight = height;
2075
+ return;
2076
+ }
2077
+ // A formerly valid semantic target is temporarily absent (provider removal,
2078
+ // replacement, eviction, or object deletion). Keep the last resolved frame
2079
+ // instead of silently reinterpreting manual intent as a numeric viewport.
2080
+ const retainedLines = this.#previousLines.length > 0 ? this.#previousLines : newLines;
2081
+ this.#repaintViewportFromLines(
2082
+ retainedLines,
2083
+ width,
2084
+ height,
2085
+ this.#manualViewportTop,
2086
+ null,
2087
+ "unresolved semantic viewport render",
2088
+ true,
2089
+ );
2090
+ this.#previousWidth = width;
2091
+ this.#previousHeight = height;
2092
+ return;
2093
+ }
2094
+ const nextViewportTop = resolvedAnchorTop ?? this.#manualViewportTop;
2095
+ if (
2096
+ this.#previousWidth === width &&
2097
+ this.#previousHeight === height &&
2098
+ nextViewportTop === this.#manualViewportTop &&
2099
+ newLines.length === this.#previousLines.length &&
2100
+ newLines.every((line, index) => line === this.#previousLines[index])
2101
+ ) {
2102
+ return;
2103
+ }
1726
2104
  this.#manualViewportTop = nextViewportTop;
1727
- const repaintCursorPos = null;
2105
+ this.#reconcileMissingViewportAnchor = false;
1728
2106
  if (
1729
2107
  this.#repaintViewportFromLines(
1730
2108
  newLines,
1731
2109
  width,
1732
2110
  height,
1733
2111
  nextViewportTop,
1734
- repaintCursorPos,
2112
+ null,
1735
2113
  "manual viewport render",
2114
+ this.#manualViewportAnchor !== null,
1736
2115
  )
1737
2116
  ) {
1738
2117
  this.#previousLines = newLines;
@@ -1761,7 +2140,7 @@ export class TUI extends Container {
1761
2140
  this.#hardwareCursorRow = toRow;
1762
2141
  buffer += seq;
1763
2142
  buffer += "\x1b[?2026l"; // End synchronized output
1764
- if (!this.#writeTerminal(buffer)) return;
2143
+ if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length)) return;
1765
2144
  // Reset max lines when clearing, otherwise track growth
1766
2145
  if (clear) {
1767
2146
  this.#maxLinesRendered = newLines.length;
@@ -1811,7 +2190,7 @@ export class TUI extends Container {
1811
2190
  this.#hardwareCursorRow = cursorToRow;
1812
2191
  buffer += cursorSeq;
1813
2192
  buffer += "\x1b[?2026l";
1814
- if (!this.#writeTerminal(buffer)) return;
2193
+ if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length)) return;
1815
2194
 
1816
2195
  if (this.#debugRedraw) {
1817
2196
  const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
@@ -1878,7 +2257,11 @@ export class TUI extends Container {
1878
2257
  // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
1879
2258
  if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
1880
2259
  logRedraw(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
1881
- if (useViewportRepaintPath(this.terminal)) {
2260
+ if (
2261
+ useViewportRepaintPath(this.terminal) ||
2262
+ ((this.#previousLines.length > height || newLines.length > height) &&
2263
+ allowsHostNeutralOverflowRepaint(this.terminal))
2264
+ ) {
1882
2265
  viewportRepaint(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
1883
2266
  } else {
1884
2267
  fullRender(true, "clearOnShrink");
@@ -1963,7 +2346,7 @@ export class TUI extends Container {
1963
2346
  this.#hardwareCursorRow = toRow;
1964
2347
  buffer += seq;
1965
2348
  buffer += "\x1b[?2026l";
1966
- if (!this.#writeTerminal(buffer)) return;
2349
+ if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length)) return;
1967
2350
  }
1968
2351
  this.#previousLines = newLines;
1969
2352
  this.#previousWidth = width;
@@ -1981,7 +2364,12 @@ export class TUI extends Container {
1981
2364
  // back to live.
1982
2365
  if (firstChanged < prevViewportTop) {
1983
2366
  logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1984
- if (useViewportRepaintPath(this.terminal)) {
2367
+ if (
2368
+ useViewportRepaintPath(this.terminal) ||
2369
+ (newLines.length <= this.#previousLines.length &&
2370
+ (this.#previousLines.length > height || newLines.length > height) &&
2371
+ allowsHostNeutralOverflowRepaint(this.terminal))
2372
+ ) {
1985
2373
  viewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1986
2374
  return;
1987
2375
  }
@@ -2108,7 +2496,7 @@ export class TUI extends Container {
2108
2496
  }
2109
2497
 
2110
2498
  // Write entire buffer at once
2111
- if (!this.#writeTerminal(buffer)) return;
2499
+ if (!this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, newLines.length)) return;
2112
2500
 
2113
2501
  // Track cursor position for next render.
2114
2502
  // cursorRow tracks end of content (for viewport calculation).
@@ -2163,19 +2551,47 @@ export class TUI extends Container {
2163
2551
  return { seq, toRow: targetRow };
2164
2552
  }
2165
2553
 
2554
+ /**
2555
+ * Register an emitter whose escape payload is appended to every render
2556
+ * write (inside its own synchronized-output block, cursor saved/restored).
2557
+ * Used for absolute-positioned overlays such as pixel-image pets that live
2558
+ * outside the line-based component model. Return null to emit nothing.
2559
+ */
2560
+ setPostRenderEmitter(emitter: (() => string | null) | undefined): void {
2561
+ this.#postRenderEmitter = emitter;
2562
+ }
2563
+
2564
+ #postRenderEmitter: (() => string | null) | undefined;
2565
+
2566
+ #writeRenderBufferAndReanchorImeCursor(
2567
+ buffer: string,
2568
+ cursorPos: { row: number; col: number } | null,
2569
+ totalLines: number,
2570
+ ): boolean {
2571
+ const overlay = this.#postRenderEmitter?.();
2572
+ if (overlay) {
2573
+ // DECSC/DECRC keep the hardware cursor stable; the dedicated
2574
+ // synchronized block prevents visible tearing while the overlay
2575
+ // area is cleared and redrawn.
2576
+ buffer += `\x1b[?2026h\x1b7${overlay}\x1b8\x1b[?2026l`;
2577
+ }
2578
+ if (!this.#writeTerminal(buffer)) return false;
2579
+ if (!this.#imeCursorActive) return true;
2580
+ return this.#writeCursorPosition(cursorPos, totalLines);
2581
+ }
2582
+
2166
2583
  /**
2167
2584
  * Write the hardware cursor position to the terminal as a standalone
2168
2585
  * synchronized output block. Use when there is no surrounding render buffer
2169
2586
  * to embed the sequences into.
2170
2587
  */
2171
- #writeCursorPosition(cursorPos: { row: number; col: number } | null, totalLines: number): void {
2588
+ #writeCursorPosition(cursorPos: { row: number; col: number } | null, totalLines: number): boolean {
2172
2589
  if (!cursorPos || totalLines <= 0) {
2173
- this.#hideCursor();
2174
- return;
2590
+ return this.#hideCursor();
2175
2591
  }
2176
2592
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
2177
2593
  this.#hardwareCursorRow = toRow;
2178
2594
  // No \x1b[?2026h/l wrapper: synchronized output flushes terminal state and discards macOS IME composition.
2179
- this.#writeTerminal(seq);
2595
+ return this.#writeTerminal(seq);
2180
2596
  }
2181
2597
  }