@oh-my-pi/pi-tui 18.9.0 → 18.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.9.2] - 2026-10-11
6
+
7
+ ### Added
8
+
9
+ - Added native TSP rendering support for Tern terminal sessions running inside tmux, with automatic session detection and safe title-setting behavior.
10
+
11
+ ### Fixed
12
+
13
+ - Improved responsiveness when inserting assistant messages after tool calls in long transcripts.
14
+
15
+ ## [18.9.1] - 2026-10-11
16
+
17
+ ### Fixed
18
+
19
+ - Fixed inline charts and images leaving overlapping copies over text when moved, resized, replaced, or hidden, while preserving images in scrollback.
20
+
5
21
  ## [18.9.0] - 2026-10-11
6
22
 
7
23
  ### Added
@@ -63,6 +63,11 @@ export interface TranscriptViewportSpan {
63
63
  export declare class TranscriptContainer extends Container {
64
64
  #private;
65
65
  addChild(component: Component): void;
66
+ /**
67
+ * `#entries` mirrors `children`; move both together or the next `#syncEntries`
68
+ * pays a full O(n) rebuild.
69
+ */
70
+ insertChildAt(component: Component, index: number): void;
66
71
  /** Removes `component` if {@link canRemoveBlock} allows it; otherwise does nothing. */
67
72
  removeChild(component: Component): void;
68
73
  clear(): void;
@@ -149,50 +149,8 @@ export declare class ImageBudget {
149
149
  * the image is purged from the terminal store.
150
150
  */
151
151
  registerPlacementGeometry(imageId: number, widthPx: number, heightPx: number): void;
152
- /**
153
- * Record this frame's native-scrollback commit target (the frame-row count
154
- * that is committed once the frame's writes land). Called once per rendered
155
- * frame — including frames that emit no placements — so an epoch whose rows
156
- * commit while its line is never rewritten is still flagged before the next
157
- * re-emission.
158
- */
159
- observeCommitWatermark(committedTo: number): void;
160
- /**
161
- * End the physical-row coordinate epoch after observing its final commit
162
- * watermark. Placement ids and latched archive state survive, but attachment
163
- * rows do not: the next placement emit records them in the new-width frame.
164
- */
165
- beginPlacementCoordinateEpoch(): void;
166
- /**
167
- * Resolve the placement id and geometry for a direct-placement emit whose
168
- * topmost attached cell sits at `attachTopFrameRow` — the first frame row
169
- * the placement covers, i.e. the block's first *visible* row, not its
170
- * origin (-1 when the writer has no frame-space position: alt-screen,
171
- * resize, ConPTY-truncated replays). `committedTo` is this frame's commit
172
- * target in the same frame-row space (-1 when unknown).
173
- *
174
- * Invariant: a placement id may be re-used (Kitty replace strips that id's
175
- * cells everywhere, scrollback included) only while none of the cells it
176
- * attached have entered native scrollback. The epoch — the `p=` id —
177
- * advances exactly when the archived flag says otherwise; rewrites with no
178
- * commit progression keep replacing the same id in place.
179
- */
180
- resolvePlacementEmit(imageId: number, attachTopFrameRow: number, committedTo: number): {
181
- placementId: number;
182
- widthPx: number;
183
- heightPx: number;
184
- } | null;
185
- /**
186
- * Restart every placement epoch after a destructive history clear (`CSI 3 J`
187
- * full paint). The clear destroys all placement cells — scrollback rows are
188
- * gone and the replay rewrites the viewport — so no archive remains to
189
- * protect. Reverting to epoch 1 lets the replay recreate every visible
190
- * placement after the terminal-wide cleanup.
191
- */
192
- resetPlacementEpochs(): ReadonlyArray<{
193
- imageId: number;
194
- lastEpoch: number;
195
- }>;
152
+ /** Source dimensions used by the frame writer to clip a direct placement. */
153
+ getPlacementGeometry(imageId: number): ImageDimensions | undefined;
196
154
  /**
197
155
  * Queue a one-time transmit for `imageId` on the surface the in-flight pass
198
156
  * paints. No-op if that surface already holds the data, so a repeated call
@@ -323,7 +323,7 @@ export interface ParsedKittyPlacementLine {
323
323
  /**
324
324
  * Parse a frame line that consists solely of a Kitty direct placement (the
325
325
  * last line of an {@link Image} block). Returns null for anything else —
326
- * placeholder grids, tmux-wrapped placements, sixel/iTerm2 payloads — so
326
+ * placeholder grids, sixel/iTerm2 payloads — so
327
327
  * callers fall back to writing the line verbatim.
328
328
  */
329
329
  export declare function parseKittyDirectPlacementLine(line: string): ParsedKittyPlacementLine | null;
@@ -363,8 +363,8 @@ export declare function encodeKittyDeleteAllImages(): string;
363
363
  /**
364
364
  * Delete a single placement of an image (`d=i`, lowercase): removes its cells
365
365
  * and registry entry but keeps the transmitted data, so a later `a=p` under a
366
- * fresh placement id needs no retransmit. Used to clear stale placement-epoch
367
- * entries after a destructive history clear.
366
+ * fresh placement id needs no retransmit. The frame writer uses this before
367
+ * replacing a live image without deleting its archived copies.
368
368
  */
369
369
  export declare function encodeKittyDeletePlacement(imageId: number, placementId: number): string;
370
370
  export declare function encodeITerm2(base64Data: string, options?: {
@@ -1,6 +1,16 @@
1
1
  export { wrapTmuxPassthrough } from "./multiplexers/tmux.js";
2
2
  /** Pass a control sequence through tmux, leaving direct-terminal output unchanged. */
3
3
  export declare function wrapTmuxPassthroughIfNeeded(payload: string, env?: NodeJS.ProcessEnv): string;
4
+ /**
5
+ * Whether the terminal hosting this tmux session draws TSP programs natively:
6
+ * it sets the session user option `@tern-tsp` while it watches the session.
7
+ */
8
+ export declare function tmuxSessionSpeaksTsp(env?: NodeJS.ProcessEnv): boolean;
9
+ /**
10
+ * Stop (`on` false) or restore (`on` true) tmux turning this pane's APC
11
+ * strings into its title, which would otherwise swallow every TSP message.
12
+ */
13
+ export declare function setTmuxPaneApcTitles(on: boolean, env?: NodeJS.ProcessEnv): void;
4
14
  /**
5
15
  * Resolve the terminal emulator name recorded for this tmux client.
6
16
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.9.0",
4
+ "version": "18.9.2",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -41,14 +41,14 @@
41
41
  "gen:glyphs": "bun scripts/gen-glyphs.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@oh-my-pi/omptype": "18.9.0",
45
- "@oh-my-pi/pi-agent-core": "18.9.0",
46
- "@oh-my-pi/pi-ai": "18.9.0",
47
- "@oh-my-pi/pi-catalog": "18.9.0",
48
- "@oh-my-pi/pi-natives": "18.9.0",
49
- "@oh-my-pi/pi-utils": "18.9.0",
50
- "@oh-my-pi/pi-wire": "18.9.0",
51
- "@oh-my-pi/snapcompact": "18.9.0"
44
+ "@oh-my-pi/omptype": "18.9.2",
45
+ "@oh-my-pi/pi-agent-core": "18.9.2",
46
+ "@oh-my-pi/pi-ai": "18.9.2",
47
+ "@oh-my-pi/pi-catalog": "18.9.2",
48
+ "@oh-my-pi/pi-natives": "18.9.2",
49
+ "@oh-my-pi/pi-utils": "18.9.2",
50
+ "@oh-my-pi/pi-wire": "18.9.2",
51
+ "@oh-my-pi/snapcompact": "18.9.2"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -227,11 +227,31 @@ export class TranscriptContainer extends Container {
227
227
  emitted: 0,
228
228
  stableFrozen: false,
229
229
  });
230
- // Callers may splice a just-added block into place (insert after an
231
- // anchor); re-check the whole list once instead of trusting positions.
230
+ // `children` is public and may be edited externally; re-check the whole
231
+ // list once instead of trusting positions. Insert-after-anchor callers
232
+ // use insertChildAt, which keeps both arrays in lockstep.
232
233
  this.#entriesUnverified = true;
233
234
  }
234
235
 
236
+ /**
237
+ * `#entries` mirrors `children`; move both together or the next `#syncEntries`
238
+ * pays a full O(n) rebuild.
239
+ */
240
+ insertChildAt(component: Component, index: number): void {
241
+ this.addChild(component);
242
+ const from = this.children.length - 1;
243
+ if (index >= from) {
244
+ this.#entriesUnverified = false;
245
+ return;
246
+ }
247
+ const [moved] = this.children.splice(from, 1);
248
+ if (moved) this.children.splice(index, 0, moved);
249
+ const [entry] = this.#entries.splice(this.#entries.length - 1, 1);
250
+ if (entry) this.#entries.splice(index, 0, entry);
251
+ // Both arrays moved in lockstep, so the live-tail check applies again.
252
+ this.#entriesUnverified = false;
253
+ }
254
+
235
255
  /** Removes `component` if {@link canRemoveBlock} allows it; otherwise does nothing. */
236
256
  override removeChild(component: Component): void {
237
257
  const index = this.#removableIndex(component);
@@ -57,7 +57,6 @@ export function renderForScrollback<T>(render: () => T): T {
57
57
 
58
58
  const EMPTY_IDS: readonly number[] = [];
59
59
  const EMPTY_TRANSMITS: readonly string[] = [];
60
- const EMPTY_STALE_EPOCHS: ReadonlyArray<{ imageId: number; lastEpoch: number }> = [];
61
60
  const SAVE_CURSOR = "\x1b7";
62
61
  const RESTORE_CURSOR = "\x1b8";
63
62
  // Direct placements reserve height with leading zero-width rows. Keep them
@@ -72,25 +71,6 @@ export function imageMaxColumns(width: number): number {
72
71
  /** Default count of inline images kept as live graphics before older ones fall back to text. */
73
72
  export const DEFAULT_MAX_INLINE_IMAGES = 8;
74
73
 
75
- /** Per-image direct-placement emit state tracked by {@link ImageBudget}. */
76
- interface PlacementEmitState {
77
- widthPx: number;
78
- heightPx: number;
79
- /** Current placement-id (`p=`) generation. */
80
- epoch: number;
81
- /** First frame row the current epoch's last emit attached cells to. */
82
- lastAttachTopFrameRow: number | undefined;
83
- /**
84
- * Whether any cell attached by the current epoch's last emit has entered
85
- * native scrollback. Set by {@link ImageBudget.observeCommitWatermark}
86
- * comparing each frame's raw commit target against the attach top —
87
- * era-local comparisons, so a divergence recommit that rewinds and
88
- * re-advances the ledger is detected the moment it re-crosses the attach
89
- * top, and a stale pre-rewind peak can never re-trigger.
90
- */
91
- cellsArchived: boolean;
92
- }
93
-
94
74
  /** A surface the renderer paints frames on. */
95
75
  type Surface = "screen" | "alt";
96
76
  const SURFACES: readonly Surface[] = ["screen", "alt"];
@@ -222,22 +202,8 @@ export class ImageBudget {
222
202
  * fullscreen overlay keeps its placements and is restored from cache on exit.
223
203
  */
224
204
  #liveIds: Record<Surface, Set<number>> = { screen: new Set(), alt: new Set() };
225
- /**
226
- * Per-image direct-placement emit state: source pixel geometry for the
227
- * renderer's clipped source rectangle, plus the placement-id epoch (see
228
- * {@link resolvePlacementEmit}). Entries deliberately live as long as the
229
- * terminal's own placement registry for the image — they are the ledger the
230
- * destructive-clear sweep uses to delete every registry entry an image ever
231
- * placed — and die with it on demotion purge (`d=I`) or full cleanup.
232
- */
233
- #placementState = new Map<number, PlacementEmitState>();
234
- /**
235
- * States with an un-archived live attach top — the only ones a frame's
236
- * commit watermark can affect. {@link observeCommitWatermark} runs every
237
- * rendered frame, so it scans this set (bounded by concurrently live
238
- * placements) instead of every image ever registered.
239
- */
240
- #watchedPlacements = new Set<PlacementEmitState>();
205
+ /** Source pixel dimensions for clipping direct placements at the viewport edge. */
206
+ #placementGeometry = new Map<number, ImageDimensions>();
241
207
  /** Ids whose owner replaced them for good ({@link release}); retired once no frame shows them. */
242
208
  #released = new Set<number>();
243
209
 
@@ -480,7 +446,7 @@ export class ImageBudget {
480
446
  // terminal, so cancel it instead of transmitting then purging.
481
447
  if (!this.#pendingTransmits[this.#surface].delete(imageId)) this.#purgeIds.push(imageId);
482
448
  this.#transmitted[this.#surface].delete(imageId);
483
- if (!this.#isTransmitted(imageId)) this.#deletePlacementState(imageId);
449
+ if (!this.#isTransmitted(imageId)) this.#placementGeometry.delete(imageId);
484
450
  this.#forgetKeyForId(imageId);
485
451
  return true;
486
452
  }
@@ -523,8 +489,7 @@ export class ImageBudget {
523
489
  this.#resetPurgeIds = [];
524
490
  this.#keyToId.clear();
525
491
  this.#idToKey.clear();
526
- this.#placementState.clear();
527
- this.#watchedPlacements.clear();
492
+ this.#placementGeometry.clear();
528
493
  this.#released.clear();
529
494
  for (const surface of SURFACES) this.#liveIds[surface].clear();
530
495
  return [...ids];
@@ -541,115 +506,18 @@ export class ImageBudget {
541
506
  * the image is purged from the terminal store.
542
507
  */
543
508
  registerPlacementGeometry(imageId: number, widthPx: number, heightPx: number): void {
544
- const state = this.#placementState.get(imageId);
545
- if (state) {
546
- state.widthPx = widthPx;
547
- state.heightPx = heightPx;
548
- return;
549
- }
550
- this.#placementState.set(imageId, {
551
- widthPx,
552
- heightPx,
553
- epoch: 1,
554
- lastAttachTopFrameRow: undefined,
555
- cellsArchived: false,
556
- });
557
- }
558
-
559
- /**
560
- * Record this frame's native-scrollback commit target (the frame-row count
561
- * that is committed once the frame's writes land). Called once per rendered
562
- * frame — including frames that emit no placements — so an epoch whose rows
563
- * commit while its line is never rewritten is still flagged before the next
564
- * re-emission.
565
- */
566
- observeCommitWatermark(committedTo: number): void {
567
- if (committedTo < 0 || this.#watchedPlacements.size === 0) return;
568
- for (const state of this.#watchedPlacements) {
569
- if (state.lastAttachTopFrameRow !== undefined && committedTo > state.lastAttachTopFrameRow) {
570
- // Latched: the flag only clears when the next emit consumes it,
571
- // so the state needs no further per-frame scans until then.
572
- state.cellsArchived = true;
573
- this.#watchedPlacements.delete(state);
574
- }
575
- }
576
- }
577
-
578
- /**
579
- * End the physical-row coordinate epoch after observing its final commit
580
- * watermark. Placement ids and latched archive state survive, but attachment
581
- * rows do not: the next placement emit records them in the new-width frame.
582
- */
583
- beginPlacementCoordinateEpoch(): void {
584
- for (const state of this.#placementState.values()) state.lastAttachTopFrameRow = undefined;
585
- this.#watchedPlacements.clear();
586
- }
587
-
588
- /**
589
- * Resolve the placement id and geometry for a direct-placement emit whose
590
- * topmost attached cell sits at `attachTopFrameRow` — the first frame row
591
- * the placement covers, i.e. the block's first *visible* row, not its
592
- * origin (-1 when the writer has no frame-space position: alt-screen,
593
- * resize, ConPTY-truncated replays). `committedTo` is this frame's commit
594
- * target in the same frame-row space (-1 when unknown).
595
- *
596
- * Invariant: a placement id may be re-used (Kitty replace strips that id's
597
- * cells everywhere, scrollback included) only while none of the cells it
598
- * attached have entered native scrollback. The epoch — the `p=` id —
599
- * advances exactly when the archived flag says otherwise; rewrites with no
600
- * commit progression keep replacing the same id in place.
601
- */
602
- resolvePlacementEmit(
603
- imageId: number,
604
- attachTopFrameRow: number,
605
- committedTo: number,
606
- ): { placementId: number; widthPx: number; heightPx: number } | null {
607
- const state = this.#placementState.get(imageId);
608
- if (!state) return null;
609
- // Frames that commit as they write (seam/full-paint chunk passes) pass
610
- // their own commit target; fold it in before deciding, so a commit that
611
- // lands in the same frame as the re-emission still advances the epoch.
612
- if (committedTo >= 0 && state.lastAttachTopFrameRow !== undefined && committedTo > state.lastAttachTopFrameRow) {
613
- state.cellsArchived = true;
614
- this.#watchedPlacements.delete(state);
615
- }
616
- if (state.cellsArchived) {
617
- state.epoch += 1;
618
- state.cellsArchived = false;
619
- state.lastAttachTopFrameRow = undefined;
620
- }
621
- if (attachTopFrameRow >= 0) {
622
- state.lastAttachTopFrameRow = attachTopFrameRow;
623
- this.#watchedPlacements.add(state);
624
- }
625
- return { placementId: state.epoch, widthPx: state.widthPx, heightPx: state.heightPx };
626
- }
627
-
628
- /**
629
- * Restart every placement epoch after a destructive history clear (`CSI 3 J`
630
- * full paint). The clear destroys all placement cells — scrollback rows are
631
- * gone and the replay rewrites the viewport — so no archive remains to
632
- * protect. Reverting to epoch 1 lets the replay recreate every visible
633
- * placement after the terminal-wide cleanup.
634
- */
635
- resetPlacementEpochs(): ReadonlyArray<{ imageId: number; lastEpoch: number }> {
636
- let stale: Array<{ imageId: number; lastEpoch: number }> | undefined;
637
- for (const [imageId, state] of this.#placementState) {
638
- stale ??= [];
639
- stale.push({ imageId, lastEpoch: state.epoch });
640
- state.epoch = 1;
641
- state.lastAttachTopFrameRow = undefined;
642
- state.cellsArchived = false;
509
+ const geometry = this.#placementGeometry.get(imageId);
510
+ if (geometry) {
511
+ geometry.widthPx = widthPx;
512
+ geometry.heightPx = heightPx;
513
+ } else {
514
+ this.#placementGeometry.set(imageId, { widthPx, heightPx });
643
515
  }
644
- this.#watchedPlacements.clear();
645
- return stale ?? EMPTY_STALE_EPOCHS;
646
516
  }
647
517
 
648
- #deletePlacementState(imageId: number): void {
649
- const state = this.#placementState.get(imageId);
650
- if (!state) return;
651
- this.#watchedPlacements.delete(state);
652
- this.#placementState.delete(imageId);
518
+ /** Source dimensions used by the frame writer to clip a direct placement. */
519
+ getPlacementGeometry(imageId: number): ImageDimensions | undefined {
520
+ return this.#placementGeometry.get(imageId);
653
521
  }
654
522
 
655
523
  /**
@@ -11,6 +11,8 @@ export function compositeLineAt(
11
11
  overlayWidth: number,
12
12
  totalWidth: number,
13
13
  ): string {
14
+ // A full-width image row owns its terminal row; the writer must see its exact placement shape.
15
+ if (startCol === 0 && overlayWidth >= totalWidth && TERMINAL.isImageLine(overlayLine)) return overlayLine;
14
16
  if (TERMINAL.isImageLine(baseLine)) {
15
17
  // Full-width overlays such as /switch are opaque: replace the
16
18
  // Unicode placeholder cells so the image cannot cover the modal.
@@ -917,8 +917,7 @@ export function encodeKittyPlacement(options: {
917
917
  /**
918
918
  * Exact shape of the direct-placement line {@link Image} emits as its block's
919
919
  * last row: optional `ESC 7` + `CUU(rows-1)` prefix, the {@link encodeKittyPlacement}
920
- * APC, optional `ESC 8` suffix. tmux-passthrough-wrapped lines deliberately do
921
- * not match (passthrough placements stay untouched).
920
+ * APC, optional `ESC 8` suffix. The parser unwraps tmux passthrough first.
922
921
  */
923
922
  const KITTY_DIRECT_PLACEMENT_LINE =
924
923
  /^(?:\x1b7(?:\x1b\[(\d+)A)?)?\x1b_Ga=p,q=2,C=1,i=(\d+)(?:,p=(\d+))?(?:,c=(\d+))?(?:,r=(\d+))?\x1b\\(?:\x1b8)?$/;
@@ -933,10 +932,15 @@ export interface ParsedKittyPlacementLine {
933
932
  /**
934
933
  * Parse a frame line that consists solely of a Kitty direct placement (the
935
934
  * last line of an {@link Image} block). Returns null for anything else —
936
- * placeholder grids, tmux-wrapped placements, sixel/iTerm2 payloads — so
935
+ * placeholder grids, sixel/iTerm2 payloads — so
937
936
  * callers fall back to writing the line verbatim.
938
937
  */
939
938
  export function parseKittyDirectPlacementLine(line: string): ParsedKittyPlacementLine | null {
939
+ if (line.includes("\x1bPtmux;")) {
940
+ line = line.replace(/\x1bPtmux;((?:[^\x1b]|\x1b\x1b)*)\x1b\\/g, (_match, payload: string) =>
941
+ payload.replaceAll("\x1b\x1b", "\x1b"),
942
+ );
943
+ }
940
944
  const m = KITTY_DIRECT_PLACEMENT_LINE.exec(line);
941
945
  if (!m) return null;
942
946
  const columns = m[4] !== undefined ? Number(m[4]) : 0;
@@ -981,9 +985,7 @@ export function encodeKittyPlacementLine(options: {
981
985
  const srcY = Math.floor((options.imageHeightPx * hiddenRows) / options.rows);
982
986
  params.push(`y=${srcY}`, `h=${Math.max(1, options.imageHeightPx - srcY)}`);
983
987
  }
984
- // No tmux passthrough: inside tmux the component's own line arrives
985
- // wrapped, never parses, and never reaches this rewrite.
986
- const apc = `\x1b_G${params.join(",")}\x1b\\`;
988
+ const apc = wrapTmuxPassthroughIfNeeded(`\x1b_G${params.join(",")}\x1b\\`);
987
989
  const cuu = visibleRows - 1;
988
990
  return cuu > 0 ? `\x1b7\x1b[${cuu}A${apc}\x1b8` : apc;
989
991
  }
@@ -1010,8 +1012,8 @@ export function encodeKittyDeleteAllImages(): string {
1010
1012
  /**
1011
1013
  * Delete a single placement of an image (`d=i`, lowercase): removes its cells
1012
1014
  * and registry entry but keeps the transmitted data, so a later `a=p` under a
1013
- * fresh placement id needs no retransmit. Used to clear stale placement-epoch
1014
- * entries after a destructive history clear.
1015
+ * fresh placement id needs no retransmit. The frame writer uses this before
1016
+ * replacing a live image without deleting its archived copies.
1015
1017
  */
1016
1018
  export function encodeKittyDeletePlacement(imageId: number, placementId: number): string {
1017
1019
  return wrapTmuxPassthroughIfNeeded(`\x1b_Ga=d,d=i,i=${imageId},p=${placementId},q=2\x1b\\`);
package/src/terminal.ts CHANGED
@@ -28,8 +28,10 @@ import {
28
28
  setTerminalGlyphProtocol,
29
29
  TERMINAL,
30
30
  } from "./terminal-capabilities";
31
+ import { tmuxMultiplexer } from "./multiplexers/tmux";
31
32
  import type { TerminalMultiplexerBackgroundColorCache } from "./multiplexers/types";
32
33
  import { terminalMultiplexerSessions } from "./terminal-multiplexer";
34
+ import { setTmuxPaneApcTitles, tmuxSessionSpeaksTsp } from "./tmux";
33
35
  import { setHangulCompatibilityJamoWidth } from "./utils";
34
36
  import { translateWindowsAltGrSequence } from "./windows-altgr";
35
37
  import { Win32InputModeDecoder, Win32PasteMarkerNormalizer } from "./windows-input-mode";
@@ -50,6 +52,9 @@ const WIN32_RELAYED_ESCAPE_TIMEOUT_MS = 75;
50
52
  */
51
53
  const TSP_OSC_PREFIX = `\x1b]877;${TSP_PREFIX.slice(2)}`;
52
54
 
55
+ /** How long a TSP hello waits for its reply inside tmux, which answers DA1 itself. */
56
+ const TSP_TMUX_HELLO_TIMEOUT_MS = 1000;
57
+
53
58
  /**
54
59
  * The TSP message a reassembled input string holds, in APC form, or
55
60
  * `undefined` while its terminator has not arrived. An OSC 877 message may end
@@ -874,6 +879,10 @@ export class ProcessTerminal implements Terminal {
874
879
  #tspPending = false;
875
880
  #tspResult: TspHello | null | undefined;
876
881
  #tspCallbacks: TspHelloHandler[] = [];
882
+ /** Hello deadline inside tmux, where no DA1 sentinel can end the probe. */
883
+ #tspTimeout: NodeJS.Timeout | undefined;
884
+ /** This tmux pane's APC titles are off for TSP and must be restored. */
885
+ #tspTmuxPaneGated = false;
877
886
  #tspReplyBuffer = "";
878
887
  #privateCsiResponseBuffer = "";
879
888
  #da1SentinelOwners: Da1SentinelOwner[] = [];
@@ -1831,6 +1840,9 @@ export class ProcessTerminal implements Terminal {
1831
1840
  #shouldQueryTspSupport(): boolean {
1832
1841
  const override = $env.PI_TUI_NATIVE;
1833
1842
  if (override === "0") return false;
1843
+ // tmux makes APC bodies the pane title unless the hosting terminal asked
1844
+ // for TSP through `@tern-tsp`: no override may force the probe there.
1845
+ if (tmuxMultiplexer.isInside($env)) return tmuxSessionSpeaksTsp($env);
1834
1846
  if (override === "1") return true;
1835
1847
  // Multiplexers swallow APC, so the reply could never arrive.
1836
1848
  if (isInsideTerminalMultiplexer($env)) return false;
@@ -1848,10 +1860,30 @@ export class ProcessTerminal implements Terminal {
1848
1860
  return;
1849
1861
  }
1850
1862
  this.#tspPending = true;
1863
+ if (tmuxMultiplexer.isInside($env)) {
1864
+ // tmux answers DA1 itself, ahead of any reply: wait for the reply instead.
1865
+ this.#tspTmuxPaneGated = true;
1866
+ setTmuxPaneApcTitles(false, $env);
1867
+ this.#tspTimeout = setTimeout(() => {
1868
+ this.#tspTimeout = undefined;
1869
+ this.#resolveTspSupport(null);
1870
+ }, TSP_TMUX_HELLO_TIMEOUT_MS);
1871
+ this.#safeWrite(encodeTspHelloQuery());
1872
+ return;
1873
+ }
1851
1874
  this.#da1SentinelOwners.push({ kind: "tsp" });
1852
1875
  this.#safeWrite(`${encodeTspHelloQuery()}\x1b[c`);
1853
1876
  }
1854
1877
 
1878
+ /** Hand APC titles back to tmux once this pane stops speaking TSP. */
1879
+ #endTspTmuxPane(): void {
1880
+ clearTimeout(this.#tspTimeout);
1881
+ this.#tspTimeout = undefined;
1882
+ if (!this.#tspTmuxPaneGated) return;
1883
+ this.#tspTmuxPaneGated = false;
1884
+ setTmuxPaneApcTitles(true, $env);
1885
+ }
1886
+
1855
1887
  #handleTspMessage(sequence: string): void {
1856
1888
  const message = parseTspMessage(sequence);
1857
1889
  if (message?.verb === "r" && message.reply.r === "hello") {
@@ -1867,6 +1899,12 @@ export class ProcessTerminal implements Terminal {
1867
1899
  this.#tspPending = false;
1868
1900
  const result = hello !== null && hello.v === TSP_VERSION ? hello : null;
1869
1901
  if (hello !== null && result === null) logger.warn("TSP: unsupported protocol version", { v: hello.v });
1902
+ if (result === null) {
1903
+ this.#endTspTmuxPane();
1904
+ } else {
1905
+ clearTimeout(this.#tspTimeout);
1906
+ this.#tspTimeout = undefined;
1907
+ }
1870
1908
  this.#tspResult = result;
1871
1909
  for (const cb of this.#tspCallbacks) {
1872
1910
  try {
@@ -2255,6 +2293,7 @@ export class ProcessTerminal implements Terminal {
2255
2293
  this.#sixelSupportCallbacks = [];
2256
2294
  setTerminalGlyphProtocol(false);
2257
2295
  this.#tspPending = false;
2296
+ this.#endTspTmuxPane();
2258
2297
  this.#tspResult = undefined;
2259
2298
  this.#tspReplyBuffer = "";
2260
2299
  this.#tspCallbacks = [];
package/src/tmux.ts CHANGED
@@ -31,6 +31,49 @@ function queryTmuxClientTerminalName(env: NodeJS.ProcessEnv): string | null {
31
31
  }
32
32
  }
33
33
 
34
+ const TMUX_COMMAND_TIMEOUT_MS = 500;
35
+
36
+ /** Run a tmux command against the current server; its stdout, or null on failure. */
37
+ function runTmux(args: string[], env: NodeJS.ProcessEnv): string | null {
38
+ const tmux = $which("tmux", { PATH: env.PATH });
39
+ if (!tmux) return null;
40
+ try {
41
+ const result = Bun.spawnSync([tmux, ...args], {
42
+ env,
43
+ stdout: "pipe",
44
+ stderr: "ignore",
45
+ timeout: TMUX_COMMAND_TIMEOUT_MS,
46
+ killSignal: "SIGKILL",
47
+ });
48
+ return result.exitCode === 0 ? result.stdout.toString() : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Whether the terminal hosting this tmux session draws TSP programs natively:
56
+ * it sets the session user option `@tern-tsp` while it watches the session.
57
+ */
58
+ export function tmuxSessionSpeaksTsp(env: NodeJS.ProcessEnv = Bun.env): boolean {
59
+ return (runTmux(["show-options", "-qv", "@tern-tsp"], env)?.trim() ?? "") !== "";
60
+ }
61
+
62
+ /**
63
+ * Stop (`on` false) or restore (`on` true) tmux turning this pane's APC
64
+ * strings into its title, which would otherwise swallow every TSP message.
65
+ */
66
+ export function setTmuxPaneApcTitles(on: boolean, env: NodeJS.ProcessEnv = Bun.env): void {
67
+ const pane = env.TMUX_PANE;
68
+ if (!pane) return;
69
+ runTmux(
70
+ on
71
+ ? ["set-option", "-pu", "-t", pane, "allow-set-title"]
72
+ : ["set-option", "-p", "-t", pane, "allow-set-title", "off"],
73
+ env,
74
+ );
75
+ }
76
+
34
77
  /**
35
78
  * Resolve the terminal emulator name recorded for this tmux client.
36
79
  *
package/src/tui.ts CHANGED
@@ -33,6 +33,7 @@ import { classifyTerminalMultiplexerModule, terminalMultiplexerSessions } from "
33
33
  import {
34
34
  encodeKittyDeleteAllImages,
35
35
  encodeKittyDeleteImage,
36
+ encodeKittyDeletePlacement,
36
37
  encodeKittyPlacementLine,
37
38
  ImageProtocol,
38
39
  isImageProtocolForced,
@@ -642,6 +643,13 @@ interface HardwareCursorState {
642
643
  visible: boolean;
643
644
  }
644
645
 
646
+ /** A cursor-positioned image owned by a mutable viewport, never by history. */
647
+ interface LiveImagePlacement {
648
+ imageId: number;
649
+ placementId: number;
650
+ line: string;
651
+ }
652
+
645
653
  interface PreparedLine {
646
654
  raw: string;
647
655
  width: number;
@@ -981,6 +989,9 @@ export class TUI extends Container {
981
989
  // Caps how many inline images render as live graphics; older ones fall back
982
990
  // to text via a purge + full redraw. Cap is configured by the host app.
983
991
  #imageBudget = new ImageBudget(DEFAULT_MAX_INLINE_IMAGES, () => this.requestRender());
992
+ #screenImagePlacements = new Map<number, LiveImagePlacement>();
993
+ #altImagePlacements = new Map<number, LiveImagePlacement>();
994
+ #nextImagePlacementId = 1;
984
995
  #ghosttyInitialImageDelayDone = false;
985
996
  #ghosttyInitialImageDelayTimer: RenderTimer | undefined;
986
997
  #ghosttyImageReadyAtMs = 0;
@@ -1162,6 +1173,8 @@ export class TUI extends Container {
1162
1173
 
1163
1174
  #purgeInlineImages(): void {
1164
1175
  const transmittedIds = this.#imageBudget.takeAllTransmittedIds();
1176
+ this.#screenImagePlacements.clear();
1177
+ this.#altImagePlacements.clear();
1165
1178
  if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return;
1166
1179
  for (const id of transmittedIds) {
1167
1180
  this.terminal.write(encodeKittyDeleteImage(id));
@@ -3148,34 +3161,49 @@ export class TUI extends Container {
3148
3161
  }
3149
3162
 
3150
3163
  /**
3151
- * Rewrite a Kitty direct-placement line for the viewport row it is written
3152
- * at, clipping to the visible slice (see {@link encodeKittyPlacementLine})
3153
- * under the placement id resolved by the budget's epoch tracking (see
3154
- * {@link ImageBudget.resolvePlacementEmit}). `screenRow` -1 (write position
3155
- * unknown) and non-placement image lines (placeholder grids, sixel, iTerm2,
3156
- * tmux-wrapped) pass through verbatim.
3164
+ * Remove placements before their viewport cells are replaced. History uses
3165
+ * separate placement ids, so deleting a live copy cannot erase scrollback.
3157
3166
  */
3158
- #imageLineSequence(line: string, screenRow: number, frameRow: number, committedTo: number): string {
3167
+ #retireImagePlacements(
3168
+ placements: Map<number, LiveImagePlacement>,
3169
+ lines: readonly string[],
3170
+ top: number,
3171
+ repaint: boolean,
3172
+ ): string {
3173
+ let output = "";
3174
+ for (const [row, placement] of placements) {
3175
+ if (!repaint && lines[row - top] === placement.line) continue;
3176
+ output += encodeKittyDeletePlacement(placement.imageId, placement.placementId);
3177
+ placements.delete(row);
3178
+ }
3179
+ return output;
3180
+ }
3181
+
3182
+ /** Clip a direct placement and record its live owner; history placements have no mutable owner. */
3183
+ #imageLineSequence(
3184
+ line: string,
3185
+ screenRow: number,
3186
+ placements: Map<number, LiveImagePlacement> | undefined,
3187
+ placementRow: number,
3188
+ ): string {
3159
3189
  if (screenRow < 0) return line;
3160
3190
  const parsed = parseKittyDirectPlacementLine(line);
3161
3191
  if (!parsed) return line;
3162
- // The emitted placement attaches from the block's first *visible* row
3163
- // (the clip drops the rows above the viewport), so epoch tracking keys
3164
- // on that row — not the block origin, which may be long committed.
3165
- const placement = this.#imageBudget.resolvePlacementEmit(
3166
- parsed.imageId,
3167
- frameRow >= 0 ? frameRow - Math.min(parsed.rows - 1, screenRow) : -1,
3168
- committedTo,
3192
+ const previous = placements?.get(placementRow);
3193
+ const placementId = previous?.placementId ?? this.#nextImagePlacementId++;
3194
+ const erase = previous ? encodeKittyDeletePlacement(previous.imageId, previous.placementId) : "";
3195
+ placements?.set(placementRow, { imageId: parsed.imageId, placementId, line });
3196
+ return (
3197
+ erase +
3198
+ encodeKittyPlacementLine({
3199
+ imageId: parsed.imageId,
3200
+ placementId,
3201
+ columns: parsed.columns,
3202
+ rows: parsed.rows,
3203
+ screenRow,
3204
+ imageHeightPx: this.#imageBudget.getPlacementGeometry(parsed.imageId)?.heightPx ?? 0,
3205
+ })
3169
3206
  );
3170
- if (!placement) return line;
3171
- return encodeKittyPlacementLine({
3172
- imageId: parsed.imageId,
3173
- placementId: placement.placementId,
3174
- columns: parsed.columns,
3175
- rows: parsed.rows,
3176
- screenRow,
3177
- imageHeightPx: placement.heightPx,
3178
- });
3179
3207
  }
3180
3208
 
3181
3209
  #terminalLine(line: PreparedLine): string {
@@ -3449,7 +3477,7 @@ export class TUI extends Container {
3449
3477
  // reset forgot are named explicitly here — their tracking is gone, so
3450
3478
  // nothing downstream could find them again.
3451
3479
  for (const id of this.#imageBudget.takeResetPurgeIds()) buffer += encodeKittyDeleteImage(id);
3452
- this.#imageBudget.resetPlacementEpochs();
3480
+ this.#screenImagePlacements.clear();
3453
3481
  }
3454
3482
  if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
3455
3483
  for (const id of this.#imageBudget.takePurgeIds()) buffer += encodeKittyDeleteImage(id);
@@ -3472,7 +3500,6 @@ export class TUI extends Container {
3472
3500
  // image the terminal no longer had and every inline image vanished
3473
3501
  // after a settled width resize.
3474
3502
  if (destructiveReset) buffer += `${LINE_TERMINATOR}\x1b[H\x1b[2J\x1b[3J`;
3475
- for (const sequence of this.#imageBudget.takeTransmits()) append(sequence);
3476
3503
  const diffable =
3477
3504
  geometryStable &&
3478
3505
  historyRows.length === 0 &&
@@ -3480,6 +3507,8 @@ export class TUI extends Container {
3480
3507
  !this.#forceViewportRepaintOnNextRender &&
3481
3508
  !destructiveReset &&
3482
3509
  this.#providerWindow.length > 0;
3510
+ buffer += this.#retireImagePlacements(this.#screenImagePlacements, prepared.lines, newTop, !diffable);
3511
+ for (const sequence of this.#imageBudget.takeTransmits()) append(sequence);
3483
3512
  if (diffable) {
3484
3513
  for (let index = 0; index < rows; index++) {
3485
3514
  if (
@@ -3498,9 +3527,8 @@ export class TUI extends Container {
3498
3527
  current,
3499
3528
  width,
3500
3529
  newTop + index,
3501
- -1,
3502
- -1,
3503
3530
  this.#osc66SpacerGlyphWidth(prepared.lines, index),
3531
+ { imagePlacements: this.#screenImagePlacements },
3504
3532
  )}`;
3505
3533
  }
3506
3534
  if (this.#providerWindow.length > rows && newTop + rows < height) {
@@ -3525,8 +3553,6 @@ export class TUI extends Container {
3525
3553
  preparedHistory.rows[index]!,
3526
3554
  width,
3527
3555
  Math.min(screenRow, height - 1),
3528
- -1,
3529
- -1,
3530
3556
  this.#osc66SpacerGlyphWidth(preparedHistory.lines, index),
3531
3557
  { blankRow: compactReplay },
3532
3558
  ),
@@ -3540,10 +3566,12 @@ export class TUI extends Container {
3540
3566
  prepared.rows[index]!,
3541
3567
  width,
3542
3568
  Math.min(screenRow, height - 1),
3543
- -1,
3544
- -1,
3545
3569
  this.#osc66SpacerGlyphWidth(prepared.lines, index),
3546
- { blankRow: compactReplay },
3570
+ {
3571
+ blankRow: compactReplay,
3572
+ imagePlacements: index < replayViewportRows ? undefined : this.#screenImagePlacements,
3573
+ imageRow: newTop + index,
3574
+ },
3547
3575
  ),
3548
3576
  );
3549
3577
  screenRow++;
@@ -4164,10 +4192,8 @@ export class TUI extends Container {
4164
4192
  line: PreparedLine,
4165
4193
  width: number,
4166
4194
  screenRow = -1,
4167
- frameRow = -1,
4168
- committedTo = -1,
4169
4195
  spacerGlyphWidth = -1,
4170
- options?: { blankRow?: boolean },
4196
+ options?: { blankRow?: boolean; imagePlacements?: Map<number, LiveImagePlacement>; imageRow?: number },
4171
4197
  ): string {
4172
4198
  // End every rewrite at column zero. ConPTY can materialize a pending
4173
4199
  // wrap before a following cursor-addressing sequence even while DECAWM is
@@ -4184,7 +4210,9 @@ export class TUI extends Container {
4184
4210
  if (spacerGlyphWidth >= 0) {
4185
4211
  rewrite = spacerGlyphWidth >= width ? "" : `${SEGMENT_RESET}\x1b[${spacerGlyphWidth}C${ERASE_TO_END_OF_LINE}`;
4186
4212
  } else if (line.isImage) {
4187
- rewrite = ERASE_LINE + this.#imageLineSequence(line.line, screenRow, frameRow, committedTo);
4213
+ rewrite =
4214
+ ERASE_LINE +
4215
+ this.#imageLineSequence(line.line, screenRow, options?.imagePlacements, options?.imageRow ?? screenRow);
4188
4216
  } else {
4189
4217
  const terminalLine = this.#terminalLine(line);
4190
4218
  if (options?.blankRow) {
@@ -4331,6 +4359,7 @@ export class TUI extends Container {
4331
4359
  prepared.rows.some((_row, r) =>
4332
4360
  this.#rowNeedsRewrite(this.#altPreviousLines, this.#altPreparedRows, prepared.lines, prepared.rows, r),
4333
4361
  ));
4362
+ prelude += this.#retireImagePlacements(this.#altImagePlacements, prepared.lines, 0, full);
4334
4363
  let rowsBuffer = "";
4335
4364
  for (let r = 0; r < height; r++) {
4336
4365
  if (full) {
@@ -4346,9 +4375,8 @@ export class TUI extends Container {
4346
4375
  prepared.rows[r]!,
4347
4376
  width,
4348
4377
  r,
4349
- -1,
4350
- -1,
4351
4378
  this.#osc66SpacerGlyphWidth(prepared.lines, r),
4379
+ { imagePlacements: this.#altImagePlacements },
4352
4380
  );
4353
4381
  }
4354
4382
  this.#altPreviousLines = prepared.lines;