@oh-my-pi/pi-tui 17.2.11 → 17.2.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.13] - 2026-08-11
6
+
7
+ ### Fixed
8
+
9
+ - Fixed inline images rendering permanently cropped on Kitty direct-placement terminals (WezTerm, Warp) when an image block straddled the viewport top during streaming: placements are now clipped to the visible slice at write time, and a placement id whose cells reached native scrollback is never re-used ([#8070](https://github.com/can1357/oh-my-pi/pull/8070) by [@voonfoo](https://github.com/voonfoo))
10
+
11
+ ## [17.2.12] - 2026-08-08
12
+
13
+ ### Fixed
14
+
15
+ - Fixed slow Loader paints exceeding their cost-aware CPU duty cycle on WSL/ConPTY when a 200 ms backpressure cap was shorter than the proportional delay ([#8012](https://github.com/can1357/oh-my-pi/issues/8012)).
16
+ - Fixed display-math (`$$…$$`) fractions rendering as fragmented text when the numerator and denominator are written on separate source lines: `latexToBlock` treated the top-level newline between `\frac{num}` and `{den}` as a row break, severing `\frac` from its denominator. Such argument-continuation newlines are now preserved so the fraction stays stacked ([#7996](https://github.com/can1357/oh-my-pi/issues/7996)).
17
+
5
18
  ## [17.2.11] - 2026-08-07
6
19
 
7
20
  ### Fixed
@@ -78,6 +78,53 @@ export declare class ImageBudget {
78
78
  takeAllTransmittedIds(): readonly number[];
79
79
  /** Whether `imageId`'s data still needs to be transmitted to the terminal. */
80
80
  shouldTransmit(imageId: number): boolean;
81
+ /**
82
+ * Record a direct-placement image's source pixel geometry so the renderer
83
+ * can clip its placement to the visible slice at write time; cleared when
84
+ * the image is purged from the terminal store.
85
+ */
86
+ registerPlacementGeometry(imageId: number, widthPx: number, heightPx: number): void;
87
+ /**
88
+ * Record this frame's native-scrollback commit target (the frame-row count
89
+ * that is committed once the frame's writes land). Called once per rendered
90
+ * frame — including frames that emit no placements — so an epoch whose rows
91
+ * commit while its line is never rewritten is still flagged before the next
92
+ * re-emission.
93
+ */
94
+ observeCommitWatermark(committedTo: number): void;
95
+ /**
96
+ * Resolve the placement id and geometry for a direct-placement emit whose
97
+ * topmost attached cell sits at `attachTopFrameRow` — the first frame row
98
+ * the placement covers, i.e. the block's first *visible* row, not its
99
+ * origin (-1 when the writer has no frame-space position: alt-screen,
100
+ * resize, ConPTY-truncated replays). `committedTo` is this frame's commit
101
+ * target in the same frame-row space (-1 when unknown).
102
+ *
103
+ * Invariant: a placement id may be re-used (Kitty replace strips that id's
104
+ * cells everywhere, scrollback included) only while none of the cells it
105
+ * attached have entered native scrollback. The epoch — the `p=` id —
106
+ * advances exactly when the archived flag says otherwise; rewrites with no
107
+ * commit progression keep replacing the same id in place.
108
+ */
109
+ resolvePlacementEmit(imageId: number, attachTopFrameRow: number, committedTo: number): {
110
+ placementId: number;
111
+ widthPx: number;
112
+ heightPx: number;
113
+ } | null;
114
+ /**
115
+ * Restart every placement epoch after a destructive history clear (`CSI 3 J`
116
+ * full paint). The clear destroys all placement cells — scrollback rows are
117
+ * gone and the replay rewrites the viewport — so no archive remains to
118
+ * protect. Reverting to epoch 1 lets the replay's placements replace the
119
+ * terminal's stale registry entries; the returned list names every image
120
+ * and the highest epoch it reached so the caller can delete all of its
121
+ * registry entries explicitly (`d=i` keeps the transmitted data) — an image
122
+ * absent from the replay never re-places, so even its epoch-1 entry must go.
123
+ */
124
+ resetPlacementEpochs(): ReadonlyArray<{
125
+ imageId: number;
126
+ lastEpoch: number;
127
+ }>;
81
128
  /**
82
129
  * Queue a one-time transmit for `imageId`. No-op if already transmitted, so a
83
130
  * repeated call (e.g. a width-change re-render) never re-sends the data.
@@ -235,6 +235,38 @@ export declare function encodeKittyPlacement(options: {
235
235
  columns?: number;
236
236
  rows?: number;
237
237
  }): string;
238
+ export interface ParsedKittyPlacementLine {
239
+ imageId: number;
240
+ placementId: number | undefined;
241
+ columns: number;
242
+ rows: number;
243
+ }
244
+ /**
245
+ * Parse a frame line that consists solely of a Kitty direct placement (the
246
+ * last line of an {@link Image} block). Returns null for anything else —
247
+ * placeholder grids, tmux-wrapped placements, sixel/iTerm2 payloads — so
248
+ * callers fall back to writing the line verbatim.
249
+ */
250
+ export declare function parseKittyDirectPlacementLine(line: string): ParsedKittyPlacementLine | null;
251
+ /**
252
+ * Rebuild an {@link Image} direct-placement line for the viewport row it is
253
+ * written at. The component-rendered line encodes `CUU(rows-1)`, which clamps
254
+ * at the viewport top once the block's leading rows have scrolled out — the
255
+ * placement then re-anchors the full image shifted down over foreign rows.
256
+ * Anchor at the block's first *visible* row instead, clipping the source
257
+ * rectangle (`y=`/`h=`, image pixels) to the visible bottom slice.
258
+ */
259
+ export declare function encodeKittyPlacementLine(options: {
260
+ imageId: number;
261
+ placementId: number;
262
+ columns: number;
263
+ /** Total cell rows of the image block. */
264
+ rows: number;
265
+ /** Viewport row the block's last line is being written at. */
266
+ screenRow: number;
267
+ /** Source image height in pixels, for the clipped source rectangle. */
268
+ imageHeightPx: number;
269
+ }): string;
238
270
  /**
239
271
  * Kitty graphics delete command for a single image id. Uses `d=I` (capital)
240
272
  * which removes the image and every one of its placements — on screen *and* in
@@ -243,6 +275,13 @@ export declare function encodeKittyPlacement(options: {
243
275
  * this is the only way to actually purge a placed image.
244
276
  */
245
277
  export declare function encodeKittyDeleteImage(imageId: number): string;
278
+ /**
279
+ * Delete a single placement of an image (`d=i`, lowercase): removes its cells
280
+ * and registry entry but keeps the transmitted data, so a later `a=p` under a
281
+ * fresh placement id needs no retransmit. Used to clear stale placement-epoch
282
+ * entries after a destructive history clear.
283
+ */
284
+ export declare function encodeKittyDeletePlacement(imageId: number, placementId: number): string;
246
285
  export declare function encodeITerm2(base64Data: string, options?: {
247
286
  width?: number | string;
248
287
  height?: number | string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "17.2.11",
4
+ "version": "17.2.13",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "17.2.11",
41
- "@oh-my-pi/pi-utils": "17.2.11"
40
+ "@oh-my-pi/pi-natives": "17.2.13",
41
+ "@oh-my-pi/pi-utils": "17.2.13"
42
42
  },
43
43
  "devDependencies": {
44
44
  "ghostty-web": "^0.4.0"
@@ -29,6 +29,7 @@ export interface ImageOptions {
29
29
 
30
30
  const EMPTY_IDS: readonly number[] = [];
31
31
  const EMPTY_TRANSMITS: readonly string[] = [];
32
+ const EMPTY_STALE_EPOCHS: ReadonlyArray<{ imageId: number; lastEpoch: number }> = [];
32
33
  const SAVE_CURSOR = "\x1b7";
33
34
  const RESTORE_CURSOR = "\x1b8";
34
35
  // Direct placements reserve height with leading zero-width rows. Keep them
@@ -38,6 +39,24 @@ const RESERVED_IMAGE_ROW = "\x1b[0m";
38
39
  /** Default count of inline images kept as live graphics before older ones fall back to text. */
39
40
  export const DEFAULT_MAX_INLINE_IMAGES = 8;
40
41
 
42
+ /** Per-image direct-placement emit state tracked by {@link ImageBudget}. */
43
+ interface PlacementEmitState {
44
+ widthPx: number;
45
+ heightPx: number;
46
+ /** Current placement-id (`p=`) generation. */
47
+ epoch: number;
48
+ /** First frame row the current epoch's last emit attached cells to. */
49
+ lastAttachTopFrameRow: number | undefined;
50
+ /**
51
+ * Whether any cell attached by the current epoch's last emit has entered
52
+ * native scrollback. Set by {@link ImageBudget.observeCommitWatermark}
53
+ * comparing each frame's raw commit target against the attach top —
54
+ * era-local comparisons, so a divergence recommit that rewinds and
55
+ * re-advances the ledger is detected the moment it re-crosses the attach
56
+ * top, and a stale pre-rewind peak can never re-trigger.
57
+ */
58
+ cellsArchived: boolean;
59
+ }
41
60
  let nextImageBudgetSeed = Math.floor(Math.random() * 0xffffff);
42
61
  function nextImageIdSeed(): number {
43
62
  nextImageBudgetSeed = (nextImageBudgetSeed + 0x10000) & 0xffffff;
@@ -96,6 +115,22 @@ export class ImageBudget {
96
115
  // id so a partial pass reproduces the on-screen live/text split without a
97
116
  // full, correctly-ordered walk.
98
117
  #suppressedIds = new Set<number>();
118
+ /**
119
+ * Per-image direct-placement emit state: source pixel geometry for the
120
+ * renderer's clipped source rectangle, plus the placement-id epoch (see
121
+ * {@link resolvePlacementEmit}). Entries deliberately live as long as the
122
+ * terminal's own placement registry for the image — they are the ledger the
123
+ * destructive-clear sweep uses to delete every registry entry an image ever
124
+ * placed — and die with it on demotion purge (`d=I`) or full cleanup.
125
+ */
126
+ #placementState = new Map<number, PlacementEmitState>();
127
+ /**
128
+ * States with an un-archived live attach top — the only ones a frame's
129
+ * commit watermark can affect. {@link observeCommitWatermark} runs every
130
+ * rendered frame, so it scans this set (bounded by concurrently live
131
+ * placements) instead of every image ever registered.
132
+ */
133
+ #watchedPlacements = new Set<PlacementEmitState>();
99
134
 
100
135
  constructor(cap: number = DEFAULT_MAX_INLINE_IMAGES, requestRender: () => void = () => {}) {
101
136
  this.#cap = normalizeCap(cap);
@@ -191,6 +226,7 @@ export class ImageBudget {
191
226
  this.#purgeIds.push(id);
192
227
  // d=I frees the data too, so the image must re-transmit if it returns.
193
228
  this.#transmitted.delete(id);
229
+ this.#deletePlacementState(id);
194
230
  this.#forgetKeyForId(id);
195
231
  }
196
232
  this.#onTerminal = this.#planned;
@@ -223,6 +259,8 @@ export class ImageBudget {
223
259
  this.#pendingTransmits = [];
224
260
  this.#keyToId.clear();
225
261
  this.#idToKey.clear();
262
+ this.#placementState.clear();
263
+ this.#watchedPlacements.clear();
226
264
  return ids;
227
265
  }
228
266
 
@@ -231,6 +269,116 @@ export class ImageBudget {
231
269
  return !this.#transmitted.has(imageId);
232
270
  }
233
271
 
272
+ /**
273
+ * Record a direct-placement image's source pixel geometry so the renderer
274
+ * can clip its placement to the visible slice at write time; cleared when
275
+ * the image is purged from the terminal store.
276
+ */
277
+ registerPlacementGeometry(imageId: number, widthPx: number, heightPx: number): void {
278
+ const state = this.#placementState.get(imageId);
279
+ if (state) {
280
+ state.widthPx = widthPx;
281
+ state.heightPx = heightPx;
282
+ return;
283
+ }
284
+ this.#placementState.set(imageId, {
285
+ widthPx,
286
+ heightPx,
287
+ epoch: 1,
288
+ lastAttachTopFrameRow: undefined,
289
+ cellsArchived: false,
290
+ });
291
+ }
292
+
293
+ /**
294
+ * Record this frame's native-scrollback commit target (the frame-row count
295
+ * that is committed once the frame's writes land). Called once per rendered
296
+ * frame — including frames that emit no placements — so an epoch whose rows
297
+ * commit while its line is never rewritten is still flagged before the next
298
+ * re-emission.
299
+ */
300
+ observeCommitWatermark(committedTo: number): void {
301
+ if (committedTo < 0 || this.#watchedPlacements.size === 0) return;
302
+ for (const state of this.#watchedPlacements) {
303
+ if (state.lastAttachTopFrameRow !== undefined && committedTo > state.lastAttachTopFrameRow) {
304
+ // Latched: the flag only clears when the next emit consumes it,
305
+ // so the state needs no further per-frame scans until then.
306
+ state.cellsArchived = true;
307
+ this.#watchedPlacements.delete(state);
308
+ }
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Resolve the placement id and geometry for a direct-placement emit whose
314
+ * topmost attached cell sits at `attachTopFrameRow` — the first frame row
315
+ * the placement covers, i.e. the block's first *visible* row, not its
316
+ * origin (-1 when the writer has no frame-space position: alt-screen,
317
+ * resize, ConPTY-truncated replays). `committedTo` is this frame's commit
318
+ * target in the same frame-row space (-1 when unknown).
319
+ *
320
+ * Invariant: a placement id may be re-used (Kitty replace strips that id's
321
+ * cells everywhere, scrollback included) only while none of the cells it
322
+ * attached have entered native scrollback. The epoch — the `p=` id —
323
+ * advances exactly when the archived flag says otherwise; rewrites with no
324
+ * commit progression keep replacing the same id in place.
325
+ */
326
+ resolvePlacementEmit(
327
+ imageId: number,
328
+ attachTopFrameRow: number,
329
+ committedTo: number,
330
+ ): { placementId: number; widthPx: number; heightPx: number } | null {
331
+ const state = this.#placementState.get(imageId);
332
+ if (!state) return null;
333
+ // Frames that commit as they write (seam/full-paint chunk passes) pass
334
+ // their own commit target; fold it in before deciding, so a commit that
335
+ // lands in the same frame as the re-emission still advances the epoch.
336
+ if (committedTo >= 0 && state.lastAttachTopFrameRow !== undefined && committedTo > state.lastAttachTopFrameRow) {
337
+ state.cellsArchived = true;
338
+ this.#watchedPlacements.delete(state);
339
+ }
340
+ if (state.cellsArchived) {
341
+ state.epoch += 1;
342
+ state.cellsArchived = false;
343
+ state.lastAttachTopFrameRow = undefined;
344
+ }
345
+ if (attachTopFrameRow >= 0) {
346
+ state.lastAttachTopFrameRow = attachTopFrameRow;
347
+ this.#watchedPlacements.add(state);
348
+ }
349
+ return { placementId: state.epoch, widthPx: state.widthPx, heightPx: state.heightPx };
350
+ }
351
+
352
+ /**
353
+ * Restart every placement epoch after a destructive history clear (`CSI 3 J`
354
+ * full paint). The clear destroys all placement cells — scrollback rows are
355
+ * gone and the replay rewrites the viewport — so no archive remains to
356
+ * protect. Reverting to epoch 1 lets the replay's placements replace the
357
+ * terminal's stale registry entries; the returned list names every image
358
+ * and the highest epoch it reached so the caller can delete all of its
359
+ * registry entries explicitly (`d=i` keeps the transmitted data) — an image
360
+ * absent from the replay never re-places, so even its epoch-1 entry must go.
361
+ */
362
+ resetPlacementEpochs(): ReadonlyArray<{ imageId: number; lastEpoch: number }> {
363
+ let stale: Array<{ imageId: number; lastEpoch: number }> | undefined;
364
+ for (const [imageId, state] of this.#placementState) {
365
+ stale ??= [];
366
+ stale.push({ imageId, lastEpoch: state.epoch });
367
+ state.epoch = 1;
368
+ state.lastAttachTopFrameRow = undefined;
369
+ state.cellsArchived = false;
370
+ }
371
+ this.#watchedPlacements.clear();
372
+ return stale ?? EMPTY_STALE_EPOCHS;
373
+ }
374
+
375
+ #deletePlacementState(imageId: number): void {
376
+ const state = this.#placementState.get(imageId);
377
+ if (!state) return;
378
+ this.#watchedPlacements.delete(state);
379
+ this.#placementState.delete(imageId);
380
+ }
381
+
234
382
  /**
235
383
  * Queue a one-time transmit for `imageId`. No-op if already transmitted, so a
236
384
  * repeated call (e.g. a width-change re-render) never re-sends the data.
@@ -407,9 +555,17 @@ export class Image implements Component {
407
555
  // Direct placement: return `rows` lines so TUI accounts for image
408
556
  // height. First (rows-1) lines are empty (TUI clears them); the last
409
557
  // saves the final-row cursor, moves up to the image origin, emits the
410
- // image sequence, then restores the final-row cursor. Save/restore is
411
- // required because CUU clamps at the viewport top when leading rows are
412
- // clipped away.
558
+ // image sequence, then restores the final-row cursor. When the block
559
+ // straddles the viewport top, the renderer rewrites this line to the
560
+ // visible slice (encodeKittyPlacementLine) from the geometry
561
+ // registered below.
562
+ if (this.#imageId != null && this.#budget !== undefined) {
563
+ this.#budget.registerPlacementGeometry(
564
+ this.#imageId,
565
+ this.#dimensions.widthPx,
566
+ this.#dimensions.heightPx,
567
+ );
568
+ }
413
569
  lines = [];
414
570
  for (let i = 0; i < result.rows - 1; i++) {
415
571
  lines.push(RESERVED_IMAGE_ROW);
@@ -4,7 +4,6 @@ import { Text } from "./text";
4
4
 
5
5
  const RENDER_INTERVAL_MS = 1000 / 30;
6
6
  const SPINNER_ADVANCE_MS = 80;
7
- const MAX_RENDER_BACKPRESSURE_MS = 200;
8
7
  const RENDER_BACKPRESSURE_MULTIPLIER = 9;
9
8
 
10
9
  type ColorFn = (str: string) => string;
@@ -144,8 +143,8 @@ export class Loader extends Text {
144
143
  if (this.#intervalId !== timer) return;
145
144
  const cadenceDelayMs = Math.max(0, intervalMs - frameCostMs);
146
145
  // Idle for nine times the paint cost to keep animation at or below
147
- // 10% CPU, while cheap frames retain their original cadence.
148
- const backpressureDelayMs = Math.min(MAX_RENDER_BACKPRESSURE_MS, frameCostMs * RENDER_BACKPRESSURE_MULTIPLIER);
146
+ // 10% CPU, even when a slow ConPTY write exceeds the normal cadence.
147
+ const backpressureDelayMs = frameCostMs * RENDER_BACKPRESSURE_MULTIPLIER;
149
148
  this.#scheduleTick(intervalMs, Math.max(cadenceDelayMs, backpressureDelayMs));
150
149
  }, delayMs);
151
150
  this.#intervalId = timer;
@@ -146,6 +146,16 @@ const HBRACE_COMMANDS: Record<string, HBraceSpec> = {
146
146
  underparen: { left: "╰", mid: "─", center: "─", right: "╯", over: false },
147
147
  };
148
148
 
149
+ /**
150
+ * Number of required arguments each display command consumes. Shared by
151
+ * {@link readArg} and {@link splitLines} so nested command atoms consume exactly
152
+ * their own arguments while preserving any outer command's pending arity.
153
+ */
154
+ const COMMAND_ARITY: Record<string, number> = { overset: 2, underset: 2, stackrel: 2, sqrt: 1 };
155
+ for (const name in FRAC_COMMANDS) COMMAND_ARITY[name] = 2;
156
+ for (const name in BINOM_COMMANDS) COMMAND_ARITY[name] = 2;
157
+ for (const name in HBRACE_COMMANDS) COMMAND_ARITY[name] = 1;
158
+
149
159
  // Vertical delimiter piece characters: `only` for single-line content, then
150
160
  // top/mid/bot columns for stretched forms; `axis` replaces `mid` at the
151
161
  // baseline row (the brace point).
@@ -516,11 +526,13 @@ function readBraceGroup(src: string, i: number): Span {
516
526
 
517
527
  /**
518
528
  * Read one command argument: a `{…}` group, a single char, or a `\command`
519
- * together with its attached `[…]`/`{…}` arguments (or whole `\begin…\end`
520
- * block), so e.g. `\frac\sqrt{a}{b}` reads `\sqrt{a}` as the numerator.
529
+ * together with its arguments (or whole `\begin…\end` block). Commands whose
530
+ * arity is known consume exactly that many arguments, including across source
531
+ * whitespace, so `\frac\sqrt {a} {b}` reads `\sqrt {a}` as the numerator and
532
+ * leaves `{b}` for the denominator.
521
533
  */
522
534
  function readArg(src: string, i: number): Span {
523
- while (src[i] === " ") i++;
535
+ while (src[i] === " " || src[i] === "\t" || src[i] === "\n") i++;
524
536
  if (i >= src.length) return { text: "", end: i };
525
537
  if (src[i] === "{") return readBraceGroup(src, i);
526
538
  if (src[i] !== "\\") return { text: src[i], end: i + 1 };
@@ -535,6 +547,22 @@ function readArg(src: string, i: number): Span {
535
547
  if (env) return env;
536
548
  }
537
549
  if (!name) return { text: src.slice(i, i + 2), end: i + 2 }; // non-letter command (\,, \{, …)
550
+
551
+ const arity = COMMAND_ARITY[name];
552
+ if (arity !== undefined) {
553
+ let end = j;
554
+ // Optional command arguments (e.g. the degree in `\sqrt[3]{x}`) do not
555
+ // consume a required-argument slot.
556
+ for (;;) {
557
+ while (src[end] === " " || src[end] === "\t" || src[end] === "\n") end++;
558
+ if (src[end] !== "[") break;
559
+ const close = src.indexOf("]", end);
560
+ end = close === -1 ? src.length : close + 1;
561
+ }
562
+ for (let arg = 0; arg < arity; arg++) end = readArg(src, end).end;
563
+ return { text: src.slice(i, end), end };
564
+ }
565
+
538
566
  let end = j;
539
567
  while (src[end] === "[" || src[end] === "{") {
540
568
  if (src[end] === "{") end = readBraceGroup(src, end).end;
@@ -1271,6 +1299,80 @@ function parseExpr(src: string, ctx: Ctx = ROOT_CTX): Box {
1271
1299
  return hconcat(boxes);
1272
1300
  }
1273
1301
 
1302
+ /**
1303
+ * Count the command arguments still owed at the end of `seg` — non-zero when
1304
+ * the row ends mid-construct (`\frac{a}` awaiting its denominator, or
1305
+ * `\frac`/`x^` awaiting any argument). Pending arities form a stack: an
1306
+ * unbraced nested command consumes one outer argument, then retains its own
1307
+ * pending arguments without discarding the outer command's remaining arity.
1308
+ * Used to keep a command joined to an argument written on the next source line
1309
+ * while still treating an ordinary next row (`a\n{b+c}`) as a real row break.
1310
+ */
1311
+ function bracesOwed(seg: string): number {
1312
+ const pending: number[] = [];
1313
+ const consumeArg = (): void => {
1314
+ const top = pending.length - 1;
1315
+ if (top < 0) return;
1316
+ if (pending[top] === 1) pending.pop();
1317
+ else pending[top]--;
1318
+ };
1319
+
1320
+ let i = 0;
1321
+ while (i < seg.length) {
1322
+ const c = seg[i];
1323
+ if (c === "\\") {
1324
+ let j = i + 1;
1325
+ let name = "";
1326
+ while (j < seg.length && /[A-Za-z]/.test(seg[j])) name += seg[j++];
1327
+ // A command plus its immediately attached `[…]`/`{…}` groups is one
1328
+ // atom for an enclosing argument, matching readArg. Consume that outer
1329
+ // argument first, then retain only the command's own missing arguments
1330
+ // in a nested frame. Attached groups beyond the known arity still stay
1331
+ // part of the atom and cannot consume another outer argument.
1332
+ consumeArg();
1333
+ const arity = name ? (COMMAND_ARITY[name] ?? 0) : 0;
1334
+ let attached = 0;
1335
+ if (name) {
1336
+ while (seg[j] === "[" || seg[j] === "{") {
1337
+ if (seg[j] === "{") {
1338
+ j = readBraceGroup(seg, j).end;
1339
+ if (attached < arity) attached++;
1340
+ } else {
1341
+ const close = seg.indexOf("]", j);
1342
+ j = close === -1 ? seg.length : close + 1;
1343
+ }
1344
+ }
1345
+ } else {
1346
+ j = i + 2; // non-letter command (`\,`, `\{`, …)
1347
+ }
1348
+ const missing = arity - attached;
1349
+ if (missing > 0) pending.push(missing);
1350
+ i = j;
1351
+ continue;
1352
+ }
1353
+ if (c === "{") {
1354
+ i = readBraceGroup(seg, i).end;
1355
+ consumeArg();
1356
+ continue;
1357
+ }
1358
+ if (c === "^" || c === "_") {
1359
+ pending.push(1);
1360
+ i++;
1361
+ continue;
1362
+ }
1363
+ if (c === " " || c === "\t" || c === "\n") {
1364
+ i++;
1365
+ continue;
1366
+ }
1367
+ consumeArg(); // a bare atom satisfies one pending argument
1368
+ i++;
1369
+ }
1370
+
1371
+ let owed = 0;
1372
+ for (const remaining of pending) owed += remaining;
1373
+ return owed;
1374
+ }
1375
+
1274
1376
  /** Split on top-level `\n` and `\\` row separators (outside braces and environments). */
1275
1377
  function splitLines(src: string): string[] {
1276
1378
  const lines: string[] = [];
@@ -1308,8 +1410,16 @@ function splitLines(src: string): string[] {
1308
1410
  if (c === "{") braceDepth++;
1309
1411
  else if (c === "}") braceDepth--;
1310
1412
  else if (c === "\n" && braceDepth === 0 && envDepth === 0) {
1311
- lines.push(src.slice(last, i));
1312
- last = i + 1;
1413
+ // A top-level newline is a row break UNLESS the current row ends with a
1414
+ // command still awaiting an argument (e.g. `\frac{num}\n{den}`,
1415
+ // `\frac{num}\n\sqrt{x}`, or `x^\n2`). Splitting there would sever the
1416
+ // command from its argument, so keep both in one segment; latexToBlock
1417
+ // collapses the interior newline to a space before parsing. A row that
1418
+ // merely opens with a braced group (`a\n{b+c}`) stays a break.
1419
+ if (bracesOwed(src.slice(last, i)) === 0) {
1420
+ lines.push(src.slice(last, i));
1421
+ last = i + 1;
1422
+ }
1313
1423
  }
1314
1424
  i++;
1315
1425
  }
@@ -1327,7 +1437,7 @@ function splitLines(src: string): string[] {
1327
1437
  export function latexToBlock(src: string): string[] {
1328
1438
  if (typeof src !== "string" || src.trim() === "") return [];
1329
1439
  const rows = splitLines(src.trim())
1330
- .map(line => line.trim())
1440
+ .map(line => line.replace(/[ \t]*\n[ \t]*/g, " ").trim())
1331
1441
  .filter(line => line !== "")
1332
1442
  .map(line => parseExpr(line));
1333
1443
  if (rows.length === 0) return [];
@@ -731,6 +731,80 @@ export function encodeKittyPlacement(options: {
731
731
  return wrapTmuxPassthroughIfNeeded(`\x1b_G${params.join(",")}\x1b\\`);
732
732
  }
733
733
 
734
+ /**
735
+ * Exact shape of the direct-placement line {@link Image} emits as its block's
736
+ * last row: optional `ESC 7` + `CUU(rows-1)` prefix, the {@link encodeKittyPlacement}
737
+ * APC, optional `ESC 8` suffix. tmux-passthrough-wrapped lines deliberately do
738
+ * not match (passthrough placements stay untouched).
739
+ */
740
+ const KITTY_DIRECT_PLACEMENT_LINE =
741
+ /^(?:\x1b7(?:\x1b\[(\d+)A)?)?\x1b_Ga=p,q=2,C=1,i=(\d+)(?:,p=(\d+))?(?:,c=(\d+))?(?:,r=(\d+))?\x1b\\(?:\x1b8)?$/;
742
+
743
+ export interface ParsedKittyPlacementLine {
744
+ imageId: number;
745
+ placementId: number | undefined;
746
+ columns: number;
747
+ rows: number;
748
+ }
749
+
750
+ /**
751
+ * Parse a frame line that consists solely of a Kitty direct placement (the
752
+ * last line of an {@link Image} block). Returns null for anything else —
753
+ * placeholder grids, tmux-wrapped placements, sixel/iTerm2 payloads — so
754
+ * callers fall back to writing the line verbatim.
755
+ */
756
+ export function parseKittyDirectPlacementLine(line: string): ParsedKittyPlacementLine | null {
757
+ const m = KITTY_DIRECT_PLACEMENT_LINE.exec(line);
758
+ if (!m) return null;
759
+ const columns = m[4] !== undefined ? Number(m[4]) : 0;
760
+ const rows = m[5] !== undefined ? Number(m[5]) : 0;
761
+ if (columns <= 0 || rows <= 0) return null;
762
+ return {
763
+ imageId: Number(m[2]),
764
+ placementId: m[3] !== undefined ? Number(m[3]) : undefined,
765
+ columns,
766
+ rows,
767
+ };
768
+ }
769
+
770
+ /**
771
+ * Rebuild an {@link Image} direct-placement line for the viewport row it is
772
+ * written at. The component-rendered line encodes `CUU(rows-1)`, which clamps
773
+ * at the viewport top once the block's leading rows have scrolled out — the
774
+ * placement then re-anchors the full image shifted down over foreign rows.
775
+ * Anchor at the block's first *visible* row instead, clipping the source
776
+ * rectangle (`y=`/`h=`, image pixels) to the visible bottom slice.
777
+ */
778
+ export function encodeKittyPlacementLine(options: {
779
+ imageId: number;
780
+ placementId: number;
781
+ columns: number;
782
+ /** Total cell rows of the image block. */
783
+ rows: number;
784
+ /** Viewport row the block's last line is being written at. */
785
+ screenRow: number;
786
+ /** Source image height in pixels, for the clipped source rectangle. */
787
+ imageHeightPx: number;
788
+ }): string {
789
+ // Without a source pixel height the slice cannot be expressed — emit the
790
+ // component's own full form (status quo) rather than squashing the whole
791
+ // image into the reduced row count.
792
+ const clippable = options.imageHeightPx > 0;
793
+ const hiddenRows = clippable ? Math.max(0, options.rows - 1 - options.screenRow) : 0;
794
+ const visibleRows = options.rows - hiddenRows;
795
+ const params: string[] = ["a=p", "q=2", "C=1", `i=${options.imageId}`, `p=${options.placementId}`];
796
+ params.push(`c=${options.columns}`, `r=${visibleRows}`);
797
+ if (hiddenRows > 0) {
798
+ const srcY = Math.floor((options.imageHeightPx * hiddenRows) / options.rows);
799
+ params.push(`y=${srcY}`, `h=${Math.max(1, options.imageHeightPx - srcY)}`);
800
+ }
801
+ // No tmux passthrough: inside tmux the component's own line arrives
802
+ // wrapped, never parses, and never reaches this rewrite.
803
+ const apc = `\x1b_G${params.join(",")}\x1b\\`;
804
+ const cuu = visibleRows - 1;
805
+ return cuu > 0 ? `\x1b7\x1b[${cuu}A${apc}\x1b8` : apc;
806
+ }
807
+
734
808
  /**
735
809
  * Kitty graphics delete command for a single image id. Uses `d=I` (capital)
736
810
  * which removes the image and every one of its placements — on screen *and* in
@@ -742,6 +816,16 @@ export function encodeKittyDeleteImage(imageId: number): string {
742
816
  return wrapTmuxPassthroughIfNeeded(`\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`);
743
817
  }
744
818
 
819
+ /**
820
+ * Delete a single placement of an image (`d=i`, lowercase): removes its cells
821
+ * and registry entry but keeps the transmitted data, so a later `a=p` under a
822
+ * fresh placement id needs no retransmit. Used to clear stale placement-epoch
823
+ * entries after a destructive history clear.
824
+ */
825
+ export function encodeKittyDeletePlacement(imageId: number, placementId: number): string {
826
+ return wrapTmuxPassthroughIfNeeded(`\x1b_Ga=d,d=i,i=${imageId},p=${placementId},q=2\x1b\\`);
827
+ }
828
+
745
829
  export function encodeITerm2(
746
830
  base64Data: string,
747
831
  options: {
package/src/tui.ts CHANGED
@@ -25,8 +25,11 @@ import { LoopWatchdog } from "./loop-watchdog";
25
25
  import { isConPTYHosted, setAltScreenActive, type Terminal } from "./terminal";
26
26
  import {
27
27
  encodeKittyDeleteImage,
28
+ encodeKittyDeletePlacement,
29
+ encodeKittyPlacementLine,
28
30
  ImageProtocol,
29
31
  isInsideTerminalMultiplexer,
32
+ parseKittyDirectPlacementLine,
30
33
  setCellDimensions,
31
34
  setTerminalImageProtocol,
32
35
  shouldEnableSynchronizedOutputByDefault,
@@ -2133,7 +2136,13 @@ export class TUI extends Container {
2133
2136
  buffer += "\r";
2134
2137
  for (let i = firstChanged; i <= lastChanged; i++) {
2135
2138
  if (i > firstChanged) buffer += "\r\n";
2136
- buffer += this.#lineRewriteSequence(this.#preparedFrame[segment.start + i] ?? "", width);
2139
+ buffer += this.#lineRewriteSequence(
2140
+ this.#preparedFrame[segment.start + i] ?? "",
2141
+ width,
2142
+ screenStart + i,
2143
+ segment.start + i,
2144
+ this.#committedRows,
2145
+ );
2137
2146
  }
2138
2147
  const cursorControl = this.#cursorControlSequence(
2139
2148
  cursorPos,
@@ -2796,8 +2805,39 @@ export class TUI extends Container {
2796
2805
  };
2797
2806
  }
2798
2807
 
2799
- #terminalLine(line: string): string {
2800
- if (TERMINAL.isImageLine(line)) return line;
2808
+ /**
2809
+ * Rewrite a Kitty direct-placement line for the viewport row it is written
2810
+ * at, clipping to the visible slice (see {@link encodeKittyPlacementLine})
2811
+ * under the placement id resolved by the budget's epoch tracking (see
2812
+ * {@link ImageBudget.resolvePlacementEmit}). `screenRow` -1 (write position
2813
+ * unknown) and non-placement image lines (placeholder grids, sixel, iTerm2,
2814
+ * tmux-wrapped) pass through verbatim.
2815
+ */
2816
+ #imageLineSequence(line: string, screenRow: number, frameRow: number, committedTo: number): string {
2817
+ if (screenRow < 0) return line;
2818
+ const parsed = parseKittyDirectPlacementLine(line);
2819
+ if (!parsed) return line;
2820
+ // The emitted placement attaches from the block's first *visible* row
2821
+ // (the clip drops the rows above the viewport), so epoch tracking keys
2822
+ // on that row — not the block origin, which may be long committed.
2823
+ const placement = this.#imageBudget.resolvePlacementEmit(
2824
+ parsed.imageId,
2825
+ frameRow >= 0 ? frameRow - Math.min(parsed.rows - 1, screenRow) : -1,
2826
+ committedTo,
2827
+ );
2828
+ if (!placement) return line;
2829
+ return encodeKittyPlacementLine({
2830
+ imageId: parsed.imageId,
2831
+ placementId: placement.placementId,
2832
+ columns: parsed.columns,
2833
+ rows: parsed.rows,
2834
+ screenRow,
2835
+ imageHeightPx: placement.heightPx,
2836
+ });
2837
+ }
2838
+
2839
+ #terminalLine(line: string, screenRow = -1, frameRow = -1, committedTo = -1): string {
2840
+ if (TERMINAL.isImageLine(line)) return this.#imageLineSequence(line, screenRow, frameRow, committedTo);
2801
2841
  const coalesced = coalesceAdjacentSgr(line);
2802
2842
  return coalesced + (line.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
2803
2843
  }
@@ -3208,6 +3248,12 @@ export class TUI extends Container {
3208
3248
  } else {
3209
3249
  this.#imageBudget.takePurgeIds();
3210
3250
  }
3251
+ // Feed this frame's commit target to the placement-epoch tracker before
3252
+ // any placement resolves against it — an epoch whose rows commit during
3253
+ // frames that never rewrite its line must still advance on the next
3254
+ // re-emission, and the raw per-frame value keeps the check correct
3255
+ // across committed-ledger rewinds.
3256
+ this.#imageBudget.observeCommitWatermark(chunkTo);
3211
3257
 
3212
3258
  // 6. Emit.
3213
3259
  if (intent.kind === "fullPaint") {
@@ -3506,8 +3552,10 @@ export class TUI extends Container {
3506
3552
  return col;
3507
3553
  }
3508
3554
 
3509
- #lineRewriteSequence(line: string, width: number): string {
3510
- if (TERMINAL.isImageLine(line)) return ERASE_LINE + line;
3555
+ #lineRewriteSequence(line: string, width: number, screenRow = -1, frameRow = -1, committedTo = -1): string {
3556
+ if (TERMINAL.isImageLine(line)) {
3557
+ return ERASE_LINE + this.#imageLineSequence(line, screenRow, frameRow, committedTo);
3558
+ }
3511
3559
  const terminalLine = this.#terminalLine(line);
3512
3560
  const asciiWidth = this.#ansiAsciiLineWidth(line, width);
3513
3561
  if (asciiWidth !== undefined) {
@@ -3674,7 +3722,17 @@ export class TUI extends Container {
3674
3722
  // Clear native history without blanking the live viewport first. The
3675
3723
  // replay below rewrites every visible row from home, including blanks,
3676
3724
  // so terminals without DEC 2026 never expose an ED2-cleared frame.
3725
+ // The clear also destroys every placement cell, so placement epochs
3726
+ // restart and every registry entry each image ever placed is deleted
3727
+ // explicitly (`d=i` keeps the transmitted data, so the replay needs
3728
+ // no retransmit). Deleting epoch 1 too matters for images absent from
3729
+ // the replay — nothing would ever replace their stale entry.
3677
3730
  buffer += "\x1b[H\x1b[3J";
3731
+ for (const { imageId, lastEpoch } of this.#imageBudget.resetPlacementEpochs()) {
3732
+ for (let placementId = 1; placementId <= lastEpoch; placementId++) {
3733
+ buffer += encodeKittyDeletePlacement(imageId, placementId);
3734
+ }
3735
+ }
3678
3736
  } else {
3679
3737
  // Best-effort: push the pre-paint screen into scrollback on
3680
3738
  // terminals that implement kitty's ED 22
@@ -3711,20 +3769,31 @@ export class TUI extends Container {
3711
3769
  // each row must self-clear stale cells left by the previous viewport.
3712
3770
  for (let i = 0; i < chunkTo; i++) {
3713
3771
  if (i > 0) buffer += "\r\n";
3772
+ const writeRow = Math.min(i, height - 1);
3714
3773
  buffer += options.clearScrollback
3715
- ? this.#lineRewriteSequence(frame[i] ?? "", width)
3716
- : this.#terminalLine(frame[i] ?? "");
3774
+ ? this.#lineRewriteSequence(frame[i] ?? "", width, writeRow, i, chunkTo)
3775
+ : this.#terminalLine(frame[i] ?? "", writeRow, i, chunkTo);
3717
3776
  }
3718
3777
  for (let screenRow = 0; screenRow < height; screenRow++) {
3719
3778
  if (chunkTo + screenRow > 0) buffer += "\r\n";
3720
3779
  const line = visibleTexts ? (visibleTexts[screenRow] ?? "") : (window[screenRow] ?? "");
3721
- buffer += options.clearScrollback ? this.#lineRewriteSequence(line, width) : this.#terminalLine(line);
3780
+ const writeRow = Math.min(chunkTo + screenRow, height - 1);
3781
+ const frameRow = windowTop + screenRow;
3782
+ buffer += options.clearScrollback
3783
+ ? this.#lineRewriteSequence(line, width, writeRow, frameRow, chunkTo)
3784
+ : this.#terminalLine(line, writeRow, frameRow, chunkTo);
3722
3785
  }
3723
3786
  } else {
3787
+ // ConPTY-truncated replay: leading rows were dropped, so frame-space
3788
+ // positions are unknown — placements still clip to the write row but
3789
+ // skip epoch bookkeeping.
3724
3790
  for (let i = 0; i < paintLines.length; i++) {
3725
3791
  if (i > 0) buffer += "\r\n";
3726
3792
  const line = visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : (paintLines[i] ?? "");
3727
- buffer += options.clearScrollback ? this.#lineRewriteSequence(line, width) : this.#terminalLine(line);
3793
+ const writeRow = Math.min(i, height - 1);
3794
+ buffer += options.clearScrollback
3795
+ ? this.#lineRewriteSequence(line, width, writeRow, -1, chunkTo)
3796
+ : this.#terminalLine(line, writeRow, -1, chunkTo);
3728
3797
  }
3729
3798
  }
3730
3799
  buffer += fillSequence;
@@ -3922,7 +3991,7 @@ export class TUI extends Container {
3922
3991
  let buffer = `${this.#paintBeginSequence + altEnter}\x1b[H`;
3923
3992
  for (let r = 0; r < height; r++) {
3924
3993
  if (r > 0) buffer += "\r\n";
3925
- buffer += this.#lineRewriteSequence(window[r] ?? "", width);
3994
+ buffer += this.#lineRewriteSequence(window[r] ?? "", width, r, -1, this.#committedRows);
3926
3995
  }
3927
3996
  // Park the hardware cursor at the real content bottom, not the padded
3928
3997
  // viewport bottom: a later height shrink would otherwise scroll the live
@@ -3988,7 +4057,7 @@ export class TUI extends Container {
3988
4057
  let buffer = `${this.#paintBeginSequence}\x1b[H`;
3989
4058
  for (let r = 0; r < height; r++) {
3990
4059
  if (r > 0) buffer += "\r\n";
3991
- buffer += this.#lineRewriteSequence(fitted[r], width);
4060
+ buffer += this.#lineRewriteSequence(fitted[r], width, r, -1, -1);
3992
4061
  }
3993
4062
  buffer += this.#paintEndSequence;
3994
4063
  this.terminal.write(buffer);
@@ -4067,7 +4136,7 @@ export class TUI extends Container {
4067
4136
  const moveToBottom = height - 1 - currentScreenRow;
4068
4137
  if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
4069
4138
  for (let r = height - scroll; r < height; r++) {
4070
- buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width)}`;
4139
+ buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width, height - 1, windowTop + r, chunkTo)}`;
4071
4140
  }
4072
4141
  // Rewrite any remaining changed rows after the shift.
4073
4142
  let firstChanged = -1;
@@ -4084,7 +4153,7 @@ export class TUI extends Container {
4084
4153
  buffer += "\r";
4085
4154
  for (let r = firstChanged; r <= lastChanged; r++) {
4086
4155
  if (r > firstChanged) buffer += "\r\n";
4087
- buffer += this.#lineRewriteSequence(window[r] ?? "", width);
4156
+ buffer += this.#lineRewriteSequence(window[r] ?? "", width, r, windowTop + r, chunkTo);
4088
4157
  }
4089
4158
  cursorFromRow = windowTop + lastChanged;
4090
4159
  }
@@ -4153,7 +4222,13 @@ export class TUI extends Container {
4153
4222
  }
4154
4223
  for (let r = firstChanged; r <= lastChanged; r++) {
4155
4224
  if (r > firstChanged) buffer += "\r\n";
4156
- buffer += this.#lineRewriteSequence(fillTexts ? fillTexts[r - firstChanged] : (window[r] ?? ""), width);
4225
+ buffer += this.#lineRewriteSequence(
4226
+ fillTexts ? fillTexts[r - firstChanged] : (window[r] ?? ""),
4227
+ width,
4228
+ r,
4229
+ windowTop + r,
4230
+ this.#committedRows,
4231
+ );
4157
4232
  }
4158
4233
  buffer += fillSequence;
4159
4234
  // Never park below real content (a height shrink would scroll live
@@ -4184,12 +4259,18 @@ export class TUI extends Container {
4184
4259
  let wroteLine = false;
4185
4260
  for (let i = chunkFrom; i < chunkTo; i++) {
4186
4261
  if (wroteLine) buffer += "\r\n";
4187
- buffer += this.#lineRewriteSequence(frame[i] ?? "", width);
4262
+ buffer += this.#lineRewriteSequence(frame[i] ?? "", width, Math.min(i - chunkFrom, height - 1), i, chunkTo);
4188
4263
  wroteLine = true;
4189
4264
  }
4190
4265
  for (let screenRow = 0; screenRow < height; screenRow++) {
4191
4266
  if (wroteLine) buffer += "\r\n";
4192
- buffer += this.#lineRewriteSequence(window[screenRow] ?? "", width);
4267
+ buffer += this.#lineRewriteSequence(
4268
+ window[screenRow] ?? "",
4269
+ width,
4270
+ Math.min(chunkTo - chunkFrom + screenRow, height - 1),
4271
+ windowTop + screenRow,
4272
+ chunkTo,
4273
+ );
4193
4274
  wroteLine = true;
4194
4275
  }
4195
4276
  const parkUp = height - 1 - (contentBottomRow - windowTop);