@oh-my-pi/pi-tui 18.0.11 → 18.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.1.0] - 2026-09-01
6
+
7
+ ### Fixed
8
+
9
+ - Improved terminal stability when resuming image-heavy sessions, preventing large transcript repaints from being mistaken for stalled output or exceeding the terminal output limit.
10
+ - Fixed inline images leaving blank rows in Herdr panes when resuming or rendering sessions in nested terminals.
11
+ - Fixed the TUI crashing on reference-style Markdown links whose labels match JavaScript built-in names; these links now render safely as plain text.
12
+ - Fixed fatal cleanup leaving the cursor inside a focused input before error output is displayed.
13
+ - Fixed resumed sessions showing stale background bands until the next keypress in WSL and Windows Terminal.
14
+
5
15
  ## [18.0.11] - 2026-08-29
6
16
 
7
17
  ### Added
@@ -29,9 +29,9 @@ export declare const DEFAULT_MAX_INLINE_IMAGES = 8;
29
29
  *
30
30
  * The budget keeps the most recent `cap` images live and demotes older ones to
31
31
  * their text fallback. Demotion needs a full redraw (so off-screen rows are
32
- * rewritten) plus an explicit graphics purge of the demoted ids — {@link Image}
33
- * reports display order via {@link observe}, and the TUI drives the purge +
34
- * redraw on the frame after a new image pushes the count past the cap.
32
+ * rewritten) plus an explicit graphics purge of the demoted ids. {@link Image}
33
+ * reports display order via {@link observe}; when that reveals a stricter split,
34
+ * the TUI repeats the pass before emitting its terminal frame.
35
35
  *
36
36
  * `cap <= 0` disables budgeting: every image stays a live graphic.
37
37
  */
@@ -67,9 +67,8 @@ export declare class ImageBudget {
67
67
  */
68
68
  observe(imageId: number): boolean;
69
69
  /**
70
- * End a render pass. Returns true when this frame must purge graphics and
71
- * fully repaint to apply a stricter budget; read the ids via
72
- * {@link takePurgeIds}.
70
+ * End a render pass. Returns true when the pass discovered a stricter budget
71
+ * and must be repeated before its terminal frame is emitted.
73
72
  */
74
73
  endPass(): boolean;
75
74
  /** Image ids to delete from the terminal this frame; clears the pending set. */
@@ -26,12 +26,12 @@ export interface KittyGraphicsFeatures {
26
26
  * Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
27
27
  * U+10EEEE with row/column diacritics).
28
28
  *
29
- * Kitty and Ghostty advertise placeholder support directly. A tmux session
29
+ * Kitty and Ghostty advertise placeholder support directly. A multiplexer
30
30
  * cannot use cursor-positioned placements because the outer terminal does not
31
- * know pane scroll/reflow state, so an explicit `PI_FORCE_IMAGE_PROTOCOL=kitty`
32
- * also opts into placeholders there — matching `timg -pk`. Automatic tmux
33
- * fallback stays off because the unknown outer terminal may render U+10EEEE as
34
- * literal PUA boxes (#1877).
31
+ * know pane scroll/reflow state. An explicit `PI_FORCE_IMAGE_PROTOCOL=kitty`
32
+ * opts into placeholders under any multiplexer — matching `timg -pk`.
33
+ * Automatic Herdr fallback stays off because its pane marker does not prove
34
+ * that the experimental Kitty renderer is enabled.
35
35
  *
36
36
  * `PI_NO_KITTY_PLACEHOLDERS=1` and `PI_KITTY_PLACEHOLDERS=0` remain hard
37
37
  * opt-outs; `PI_KITTY_PLACEHOLDERS=1` explicitly opts in anywhere else.
@@ -1,4 +1,5 @@
1
1
  import type { HangulCompatibilityJamoWidth } from "./utils.js";
2
+ export * from "./terminal-multiplexer.js";
2
3
  export { isInsideTmux, wrapTmuxPassthrough } from "./tmux.js";
3
4
  export declare enum ImageProtocol {
4
5
  Kitty = "\u001B_G",
@@ -48,8 +49,6 @@ export declare class TerminalInfo {
48
49
  formatNotification(message: string | TerminalNotification): string;
49
50
  sendNotification(message: string | TerminalNotification): void;
50
51
  }
51
- /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
52
- export declare function isInsideTerminalMultiplexer(env?: NodeJS.ProcessEnv): boolean;
53
52
  /**
54
53
  * Whether the agent process is running inside a Zellij session. Read fresh on
55
54
  * each call (like {@link isInsideTmux}) so a session attached/detached mid-run
@@ -165,9 +164,9 @@ export declare function isPaseoEmbedder(env?: NodeJS.ProcessEnv): boolean;
165
164
  /**
166
165
  * Resolve the image protocol for a non-forced runtime: static per-terminal
167
166
  * support (with Warp's platform carve-out), then the multiplexer fallback,
168
- * then the Paseo embedder carve-out. `isTTY` is injectable because the
169
- * fallback only fires on a real TTY — a piped subprocess cannot exercise
170
- * that path, so regression tests call this directly.
167
+ * then host carve-outs. `isTTY` is injectable because the fallback only fires
168
+ * on a real TTY — a piped subprocess cannot exercise that path, so regression
169
+ * tests call this directly.
171
170
  */
172
171
  export declare function resolveImageProtocol(terminalId: TerminalId, env?: NodeJS.ProcessEnv, isTTY?: boolean): ImageProtocol | null;
173
172
  /** Resolve terminal identity from environment markers used by common emulators. */
@@ -0,0 +1,2 @@
1
+ /** Detect whether a terminal multiplexer owns the current screen grid. */
2
+ export declare function isInsideTerminalMultiplexer(env?: NodeJS.ProcessEnv): boolean;
@@ -20,33 +20,50 @@
20
20
  */
21
21
  export declare function chunkForConPTY(data: string, maxChunkBytes?: number): string[];
22
22
  /**
23
- * Turns an unbounded, never-draining stdout writable buffer into a bounded
24
- * disconnect signal.
23
+ * Backlog at or below which stdout is healthy again: the pump has kept up, the
24
+ * TUI resumes composing frames, and a {@link StdoutStallWatchdog} episode ends.
25
+ * The TUI render gate (`TUI.#MAX_PENDING_OUTPUT_BYTES`) is this same value, so
26
+ * the watchdog stays armed across the entire range where frames are deferred —
27
+ * otherwise a consumer that wedges between this level and the arm cap is never
28
+ * re-sampled and the session freezes instead of disconnecting (#10434 review).
29
+ */
30
+ export declare const STDOUT_BACKLOG_CLEAR_BYTES: number;
31
+ /**
32
+ * Bounds a never-draining stdout backlog without killing a single large but
33
+ * actively-draining frame.
25
34
  *
26
35
  * `process.stdout.write()` returns `false` once its buffer exceeds the stream
27
- * high-water mark; the bytes stay queued and are only freed when the consumer
28
- * drains (the `drain` event). While the consumer keeps up, writes are accepted
29
- * and nothing accumulates. When it stalls, every subsequent write piles onto
30
- * the buffer — a stalled-but-alive PTY reader never throws, so the write path
31
- * has no other signal that output is going nowhere. This guard sums the bytes
32
- * queued since backpressure began and reports when that backlog crosses the
33
- * cap, at which point the caller treats the terminal as disconnected.
36
+ * high-water mark, and the off-thread pump's `pending()` climbs the same way;
37
+ * a stalled-but-alive PTY reader never throws, so the byte count is the only
38
+ * signal that output is going nowhere. Tripping on the instantaneous count
39
+ * alone is wrong: a legitimate oversized frame (a resume repaint of dozens of
40
+ * inline screenshots, #10430) briefly exceeds the cap and then drains.
41
+ *
42
+ * An episode starts when the backlog first exceeds `armBytes` and lasts until
43
+ * it drains back to `clearBytes` (healthy). The backlog can fall below
44
+ * `armBytes` while still unhealthy, so the episode must outlive that dip
45
+ * (#10434): during it the watchdog declares the terminal disconnected only when
46
+ * the backlog makes no drain progress (no new low-water mark) for `stallMs` —
47
+ * a draining terminal keeps lowering the mark and never trips, while a wedged
48
+ * one (#6854) still tears down within the window.
34
49
  *
35
50
  * Exported for unit testing; `ProcessTerminal` is the sole production user.
36
51
  */
37
- export declare class OutputBacklogGuard {
52
+ export declare class StdoutStallWatchdog {
38
53
  #private;
39
- private readonly capBytes;
40
- constructor(capBytes?: number);
41
- /** True once a refused write started a backlog that has not yet drained. */
42
- get tracking(): boolean;
54
+ private readonly armBytes;
55
+ private readonly clearBytes;
56
+ private readonly stallMs;
57
+ constructor(armBytes?: number, clearBytes?: number, stallMs?: number);
58
+ /** True while an episode is active and the backlog must be polled to completion. */
59
+ get armed(): boolean;
43
60
  /**
44
- * Record one `stdout.write()`: `accepted` is that call's return value and
45
- * `bytes` its encoded size. Returns true when the pending backlog now
46
- * exceeds the cap and the terminal should be treated as disconnected.
61
+ * Feed the current pending-byte count and clock reading. Returns true once an
62
+ * armed episode has gone `stallMs` with no drain progress, at which point the
63
+ * caller treats the terminal as disconnected.
47
64
  */
48
- record(accepted: boolean, bytes: number): boolean;
49
- /** Called on the stdout `drain` event: the buffer emptied, backlog cleared. */
65
+ sample(pending: number, nowMs: number): boolean;
66
+ /** Episode ended (drained) or terminal torn down: stop watching. */
50
67
  reset(): void;
51
68
  }
52
69
  /** Record alternate-screen state (called by the TUI on `?1049h`/`?1049l` writes). */
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.0.11",
4
+ "version": "18.1.1",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -29,16 +29,16 @@
29
29
  "main": "./src/index.ts",
30
30
  "types": "./dist/types/index.d.ts",
31
31
  "scripts": {
32
- "check": "biome check . && bun run check:types",
32
+ "check": "oxlint . && oxfmt --check --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts' && bun run check:types",
33
33
  "check:types": "tsgo -p tsconfig.json --noEmit",
34
- "lint": "biome lint .",
34
+ "lint": "oxlint .",
35
35
  "test": "bun test --parallel test/*.test.ts",
36
- "fix": "biome check --write --unsafe .",
37
- "fmt": "biome format --write ."
36
+ "fix": "oxlint --fix --fix-suggestions . && bun run fmt",
37
+ "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "18.0.11",
41
- "@oh-my-pi/pi-utils": "18.0.11"
40
+ "@oh-my-pi/pi-natives": "18.1.1",
41
+ "@oh-my-pi/pi-utils": "18.1.1"
42
42
  },
43
43
  "devDependencies": {
44
44
  "kitty-vt-wasm": "^0.2.0"
@@ -382,7 +382,7 @@ function buildSlashCommandCompletions(
382
382
  // Equal text-match scores fall back to usage frequency, then to the
383
383
  // stable registry order.
384
384
  .sort((a, b) => b.score - a.score || b.usage - a.usage)
385
- .map(({ score: _, usage: _usage, ...rest }) => rest)
385
+ .map(({ score: _score, usage: _usage, ...rest }) => rest)
386
386
  );
387
387
  }
388
388
 
@@ -73,9 +73,9 @@ function nextImageIdSeed(): number {
73
73
  *
74
74
  * The budget keeps the most recent `cap` images live and demotes older ones to
75
75
  * their text fallback. Demotion needs a full redraw (so off-screen rows are
76
- * rewritten) plus an explicit graphics purge of the demoted ids — {@link Image}
77
- * reports display order via {@link observe}, and the TUI drives the purge +
78
- * redraw on the frame after a new image pushes the count past the cap.
76
+ * rewritten) plus an explicit graphics purge of the demoted ids. {@link Image}
77
+ * reports display order via {@link observe}; when that reveals a stricter split,
78
+ * the TUI repeats the pass before emitting its terminal frame.
79
79
  *
80
80
  * `cap <= 0` disables budgeting: every image stays a live graphic.
81
81
  */
@@ -87,6 +87,8 @@ export class ImageBudget {
87
87
  #idToKey = new Map<number, string>();
88
88
  /** Display-order image ids observed during the in-flight pass. */
89
89
  #passIds: number[] = [];
90
+ /** Per-id suppression decision from the first observation in this pass. */
91
+ #passSuppression = new Map<number, boolean>();
90
92
  /**
91
93
  * Suppress threshold reflected in the frame currently on the terminal: images
92
94
  * at display indices `[0, #onTerminal)` are shown as text there.
@@ -104,7 +106,7 @@ export class ImageBudget {
104
106
  /** Image ids whose data is believed to be loaded in the terminal's store. */
105
107
  #transmitted = new Set<number>();
106
108
  /** Transmit sequences (full base64) to write once, before this frame's placements. */
107
- #pendingTransmits: string[] = [];
109
+ #pendingTransmits = new Map<number, string>();
108
110
  // True while the in-flight pass is a partial/throwaway pass (the
109
111
  // non-multiplexer resize viewport fast path) that walks only the visible
110
112
  // tail, bottom-up. Such a pass cannot derive display order from observe()
@@ -185,6 +187,7 @@ export class ImageBudget {
185
187
  */
186
188
  beginPass(stable = false): void {
187
189
  this.#passIds.length = 0;
190
+ this.#passSuppression.clear();
188
191
  this.#stablePass = stable;
189
192
  this.#applyingReset = !stable && this.#cap > 0 && this.#planned > this.#onTerminal;
190
193
  }
@@ -199,47 +202,49 @@ export class ImageBudget {
199
202
  * (`#suppressedIds`) keyed by id — order- and partiality-independent.
200
203
  */
201
204
  observe(imageId: number): boolean {
205
+ const existing = this.#passSuppression.get(imageId);
206
+ if (existing !== undefined) return existing;
202
207
  if (this.#stablePass) {
203
208
  const suppressed = this.#cap > 0 && this.#suppressedIds.has(imageId);
209
+ this.#passSuppression.set(imageId, suppressed);
204
210
  if (suppressed) this.#forgetKeyForId(imageId);
205
211
  return suppressed;
206
212
  }
207
213
  const index = this.#passIds.length;
208
214
  this.#passIds.push(imageId);
209
215
  const suppressed = this.#cap > 0 && index < this.#planned;
216
+ this.#passSuppression.set(imageId, suppressed);
210
217
  if (suppressed) this.#forgetKeyForId(imageId);
211
218
  return suppressed;
212
219
  }
213
220
 
214
221
  /**
215
- * End a render pass. Returns true when this frame must purge graphics and
216
- * fully repaint to apply a stricter budget; read the ids via
217
- * {@link takePurgeIds}.
222
+ * End a render pass. Returns true when the pass discovered a stricter budget
223
+ * and must be repeated before its terminal frame is emitted.
218
224
  */
219
225
  endPass(): boolean {
220
226
  const total = this.#passIds.length;
221
227
  this.#lastTotal = total;
222
- let reset = false;
223
228
  if (this.#applyingReset) {
224
229
  for (let i = this.#onTerminal; i < this.#planned && i < total; i++) {
225
230
  const id = this.#passIds[i];
226
- this.#purgeIds.push(id);
227
- // d=I frees the data too, so the image must re-transmit if it returns.
231
+ // A transmit queued by a discarded discovery pass never reached
232
+ // the terminal, so cancel it instead of transmitting then purging.
233
+ if (!this.#pendingTransmits.delete(id)) this.#purgeIds.push(id);
228
234
  this.#transmitted.delete(id);
229
235
  this.#deletePlacementState(id);
230
236
  this.#forgetKeyForId(id);
231
237
  }
232
238
  this.#onTerminal = this.#planned;
233
239
  this.#applyingReset = false;
234
- reset = true;
235
240
  }
236
- this.#reconcile(total);
241
+ const retry = this.#reconcile(total);
237
242
  // Snapshot the committed display-order suppression by id: the prefix
238
243
  // [0, #onTerminal) is what the terminal currently shows as text. Partial
239
244
  // passes replay this per id (see #stablePass) instead of re-deriving it
240
245
  // from a reversed, tail-only walk.
241
246
  this.#suppressedIds = new Set(this.#passIds.slice(0, this.#onTerminal));
242
- return reset;
247
+ return retry;
243
248
  }
244
249
 
245
250
  /** Image ids to delete from the terminal this frame; clears the pending set. */
@@ -256,7 +261,7 @@ export class ImageBudget {
256
261
  const ids = [...this.#transmitted];
257
262
  this.#transmitted.clear();
258
263
  this.#purgeIds = [];
259
- this.#pendingTransmits = [];
264
+ this.#pendingTransmits.clear();
260
265
  this.#keyToId.clear();
261
266
  this.#idToKey.clear();
262
267
  this.#placementState.clear();
@@ -393,12 +398,12 @@ export class ImageBudget {
393
398
  enqueueTransmit(imageId: number, sequence: string): void {
394
399
  if (this.#transmitted.has(imageId)) return;
395
400
  this.#transmitted.add(imageId);
396
- this.#pendingTransmits.push(sequence);
401
+ this.#pendingTransmits.set(imageId, sequence);
397
402
  }
398
403
 
399
404
  /** Whether a frame has image data queued but not yet written to the terminal. */
400
405
  hasPendingTransmits(): boolean {
401
- return this.#pendingTransmits.length > 0;
406
+ return this.#pendingTransmits.size > 0;
402
407
  }
403
408
 
404
409
  /**
@@ -410,7 +415,7 @@ export class ImageBudget {
410
415
  get quiescent(): boolean {
411
416
  return (
412
417
  this.#lastTotal === 0 &&
413
- this.#pendingTransmits.length === 0 &&
418
+ this.#pendingTransmits.size === 0 &&
414
419
  this.#purgeIds.length === 0 &&
415
420
  this.#planned === this.#onTerminal
416
421
  );
@@ -418,9 +423,9 @@ export class ImageBudget {
418
423
 
419
424
  /** Transmit sequences to write before this frame's placements; clears the queue. */
420
425
  takeTransmits(): readonly string[] {
421
- if (this.#pendingTransmits.length === 0) return EMPTY_TRANSMITS;
422
- const sequences = this.#pendingTransmits;
423
- this.#pendingTransmits = [];
426
+ if (this.#pendingTransmits.size === 0) return EMPTY_TRANSMITS;
427
+ const sequences = [...this.#pendingTransmits.values()];
428
+ this.#pendingTransmits.clear();
424
429
  return sequences;
425
430
  }
426
431
 
@@ -433,9 +438,9 @@ export class ImageBudget {
433
438
  * re-emit together; keeps no base64 in budget state (the transmit-once design).
434
439
  */
435
440
  forgetTransmitted(): void {
436
- if (this.#transmitted.size === 0 && this.#pendingTransmits.length === 0) return;
441
+ if (this.#transmitted.size === 0 && this.#pendingTransmits.size === 0) return;
437
442
  this.#transmitted.clear();
438
- this.#pendingTransmits = [];
443
+ this.#pendingTransmits.clear();
439
444
  }
440
445
 
441
446
  #forgetKeyForId(id: number): void {
@@ -445,15 +450,16 @@ export class ImageBudget {
445
450
  if (this.#keyToId.get(key) === id) this.#keyToId.delete(key);
446
451
  }
447
452
 
448
- #reconcile(total: number): void {
453
+ #reconcile(total: number): boolean {
449
454
  const desired = this.#cap > 0 ? Math.max(0, total - this.#cap) : 0;
450
455
  if (desired === this.#planned) {
451
456
  // Budget relaxed without a stricter frame (cap raised or images
452
457
  // removed): surviving graphics are untouched and re-exposed rows
453
458
  // repaint normally, so just track the looser threshold.
454
459
  if (this.#planned < this.#onTerminal) this.#onTerminal = this.#planned;
455
- return;
460
+ return false;
456
461
  }
462
+ const retry = desired > this.#onTerminal;
457
463
  this.#planned = desired;
458
464
  // More images must be demoted than the terminal shows: schedule the purge +
459
465
  // full-redraw frame. Fewer: no ghosts to clear, so just catch the tracking
@@ -461,6 +467,7 @@ export class ImageBudget {
461
467
  // render is needed to apply the new threshold.
462
468
  if (desired <= this.#onTerminal) this.#onTerminal = desired;
463
469
  this.#requestRender();
470
+ return retry;
464
471
  }
465
472
  }
466
473
 
@@ -137,7 +137,7 @@ function normalizeHtmlEntitiesForTerminal(raw: string): string {
137
137
  if (Number.isFinite(value) && value >= 0 && value <= 0x10ffff) {
138
138
  try {
139
139
  return String.fromCodePoint(value);
140
- } catch (_) {
140
+ } catch {
141
141
  // Fallback to empty string or original if invalid codepoint
142
142
  }
143
143
  }
@@ -261,7 +261,7 @@ function normalizeHtmlForTerminal(
261
261
  }
262
262
  lastIndex = index + tag.length;
263
263
 
264
- const isClosing = /^<\//.test(tag);
264
+ const isClosing = tag.startsWith("</");
265
265
  const isSelfClosing = /\/\s*>$/.test(tag);
266
266
 
267
267
  switch (name) {
@@ -794,7 +794,7 @@ export function urlTokenPossible(src: string): boolean {
794
794
  }
795
795
  if (i === 0) return false;
796
796
  if (i >= URL_GATE_EMAIL_SCAN_LIMIT) return true; // over-long run: give up conservatively
797
- return src.charCodeAt(i) === 64 /* @ */;
797
+ return src.charCodeAt(i) === 64; /* @ */
798
798
  }
799
799
 
800
800
  // Setext-underline pre-gate for marked's `lheading` rule. The rule's lazy body
@@ -1131,7 +1131,7 @@ function listMayContinueAt(text: string, tailStart: number, listRaw: string): bo
1131
1131
  // bare newline, or end-of-input (which appends can still extend).
1132
1132
  if (i >= n) return true;
1133
1133
  const after = text.charCodeAt(i);
1134
- return after === 0x20 /* space */ || after === 0x09 /* tab */ || after === 0x0a /* \n */;
1134
+ return after === 0x20 /* space */ || after === 0x09 /* tab */ || after === 0x0a; /* \n */
1135
1135
  }
1136
1136
 
1137
1137
  const NO_BLOCK_BOUNDARY = { end: 0, count: 0 } as const;
@@ -2355,8 +2355,11 @@ export class Markdown implements Component {
2355
2355
  }
2356
2356
 
2357
2357
  const recorder: TailRenderRecorder = {
2358
+ // oxlint-disable-next-line unicorn/no-new-array -- render-cache length preallocation
2358
2359
  rows: new Array(tokens.length - spliceEnd).fill(undefined),
2360
+ // oxlint-disable-next-line unicorn/no-new-array -- render-cache length preallocation
2359
2361
  raws: new Array(tokens.length - spliceEnd).fill(undefined),
2362
+ // oxlint-disable-next-line unicorn/no-new-array -- render-cache length preallocation
2360
2363
  nextTypes: new Array(tokens.length - spliceEnd).fill(undefined),
2361
2364
  };
2362
2365
  const fresh = this.#renderContentLines(tokens, spliceEnd, tokens.length, contentWidth, signature, recorder);
@@ -2369,8 +2372,11 @@ export class Markdown implements Component {
2369
2372
  // `start`), so a mostly-frozen document allocates only for the
2370
2373
  // unfrozen tail instead of the whole token list every frame.
2371
2374
  const tailCount = tokens.length - start;
2375
+ // oxlint-disable-next-line unicorn/no-new-array -- render-cache length preallocation
2372
2376
  const rows: (readonly string[] | undefined)[] = new Array(tailCount).fill(undefined);
2377
+ // oxlint-disable-next-line unicorn/no-new-array -- render-cache length preallocation
2373
2378
  const raws: (string | undefined)[] = new Array(tailCount).fill(undefined);
2379
+ // oxlint-disable-next-line unicorn/no-new-array -- render-cache length preallocation
2374
2380
  const nextTypes: (string | undefined)[] = new Array(tailCount).fill(undefined);
2375
2381
  if (cache !== undefined && cache.tokenStart === start) {
2376
2382
  for (let i = start; i < Math.min(cache.cachedThrough, spliceEnd); i++) {
@@ -3155,17 +3161,21 @@ export class Markdown implements Component {
3155
3161
  markHtmlItemWhenContent(token.text);
3156
3162
  const linkText = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
3157
3163
  const styledLinkText = this.#theme.link(this.#theme.underline(linkText));
3158
- const clickableLinkText = formatHyperlink(styledLinkText, token.href);
3159
- // If link text matches href, only show the link once
3164
+ const href = typeof token.href === "string" ? token.href : "";
3165
+ const clickableLinkText = formatHyperlink(styledLinkText, href);
3166
+ // If link text matches href, only show the link once. A missing
3167
+ // href (malformed/partial link token) renders as plain link text
3168
+ // instead of crashing the renderer or emitting an empty "()"
3169
+ // (issue #10283).
3160
3170
  // Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes
3161
3171
  // For mailto: links, strip the prefix before comparing (autolinked emails have
3162
3172
  // text="foo@bar.com" but href="mailto:foo@bar.com")
3163
- const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
3164
- if (token.text === token.href || token.text === hrefForComparison)
3173
+ const hrefForComparison = href.startsWith("mailto:") ? href.slice(7) : href;
3174
+ if (!href || token.text === href || token.text === hrefForComparison)
3165
3175
  result += clickableLinkText + stylePrefix;
3166
3176
  else {
3167
- const styledLinkUrl = this.#theme.linkUrl(`(${token.href})`);
3168
- result += `${clickableLinkText} ${formatHyperlink(styledLinkUrl, token.href)}${stylePrefix}`;
3177
+ const styledLinkUrl = this.#theme.linkUrl(`(${href})`);
3178
+ result += `${clickableLinkText} ${formatHyperlink(styledLinkUrl, href)}${stylePrefix}`;
3169
3179
  }
3170
3180
  break;
3171
3181
  }
@@ -3464,6 +3474,7 @@ export class Markdown implements Component {
3464
3474
  let minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
3465
3475
 
3466
3476
  if (minCellsWidth > availableForCells) {
3477
+ // oxlint-disable-next-line unicorn/no-new-array -- column-width allocation
3467
3478
  minColumnWidths = new Array(numCols).fill(1);
3468
3479
  const remaining = availableForCells - numCols;
3469
3480
 
@@ -207,6 +207,7 @@ export class SelectList implements Component, MouseRoutable {
207
207
  // every count is 1, so visualTotal == #filteredItems and overflow falls
208
208
  // back to the original `N > maxVisible` predicate exactly.
209
209
  const conservativeRowWidth = Math.max(0, width - 1);
210
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
210
211
  const rowCounts = new Array<number>(this.#filteredItems.length);
211
212
  let visualTotal = 0;
212
213
  for (let i = 0; i < this.#filteredItems.length; i++) {
@@ -20,7 +20,6 @@ export class Spacer implements Component {
20
20
  this.#lines = lines;
21
21
  this.#cached = undefined;
22
22
  }
23
-
24
23
  invalidate(): void {
25
24
  // No cached state to invalidate currently
26
25
  }
@@ -28,6 +27,7 @@ export class Spacer implements Component {
28
27
  render(_width: number): readonly string[] {
29
28
  let cached = this.#cached;
30
29
  if (cached === undefined) {
30
+ // oxlint-disable-next-line unicorn/no-new-array -- cached line allocation
31
31
  cached = new Array(this.#lines).fill("");
32
32
  this.#cached = cached;
33
33
  }
@@ -168,7 +168,7 @@ export class Text implements Component {
168
168
 
169
169
  const result = [...emptyLines, ...contentLines, ...emptyLines];
170
170
  if (resultWidths !== undefined) {
171
- // Pad rows are exactly `width` cells wide.
171
+ // oxlint-disable-next-line unicorn/no-new-array -- line-width allocation
172
172
  const emptyWidths = new Array<number>(emptyLines.length).fill(width);
173
173
  publishLineWidths(result, [...emptyWidths, ...resultWidths, ...emptyWidths]);
174
174
  }
package/src/deccara.ts CHANGED
@@ -239,7 +239,9 @@ export interface DeccaraPlan {
239
239
  */
240
240
  export function planDeccaraFills(lines: string[], width: number, firstScreenRow = 0): DeccaraPlan {
241
241
  const n = lines.length;
242
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
242
243
  const texts: string[] = new Array(n);
244
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
243
245
  const candidates: (FillCandidate | null)[] = new Array(n);
244
246
 
245
247
  for (let k = 0; k < n; k++) {
@@ -15,6 +15,7 @@
15
15
  * forms. Protocol gating (`imageProtocol === Kitty`) lives in the caller.
16
16
  */
17
17
 
18
+ import { isInsideTerminalMultiplexer } from "./terminal-multiplexer";
18
19
  import { wrapTmuxPassthroughIfNeeded } from "./tmux";
19
20
 
20
21
  /** Kitty Unicode placeholder base character (U+10EEEE, Plane 16 PUA). */
@@ -62,12 +63,12 @@ export interface KittyGraphicsFeatures {
62
63
  * Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
63
64
  * U+10EEEE with row/column diacritics).
64
65
  *
65
- * Kitty and Ghostty advertise placeholder support directly. A tmux session
66
+ * Kitty and Ghostty advertise placeholder support directly. A multiplexer
66
67
  * cannot use cursor-positioned placements because the outer terminal does not
67
- * know pane scroll/reflow state, so an explicit `PI_FORCE_IMAGE_PROTOCOL=kitty`
68
- * also opts into placeholders there — matching `timg -pk`. Automatic tmux
69
- * fallback stays off because the unknown outer terminal may render U+10EEEE as
70
- * literal PUA boxes (#1877).
68
+ * know pane scroll/reflow state. An explicit `PI_FORCE_IMAGE_PROTOCOL=kitty`
69
+ * opts into placeholders under any multiplexer — matching `timg -pk`.
70
+ * Automatic Herdr fallback stays off because its pane marker does not prove
71
+ * that the experimental Kitty renderer is enabled.
71
72
  *
72
73
  * `PI_NO_KITTY_PLACEHOLDERS=1` and `PI_KITTY_PLACEHOLDERS=0` remain hard
73
74
  * opt-outs; `PI_KITTY_PLACEHOLDERS=1` explicitly opts in anywhere else.
@@ -78,7 +79,9 @@ export function detectKittyUnicodePlaceholdersSupport(terminalId: string, env: N
78
79
  const force = env.PI_KITTY_PLACEHOLDERS?.trim().toLowerCase();
79
80
  if (force === "1" || force === "true" || force === "on" || force === "yes" || force === "y") return true;
80
81
  if (force === "0" || force === "false" || force === "off" || force === "no" || force === "n") return false;
81
- if (env.TMUX && env.PI_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase() === "kitty") return true;
82
+ const insideMultiplexer = isInsideTerminalMultiplexer(env);
83
+ if (insideMultiplexer && env.PI_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase() === "kitty") return true;
84
+ if (env.HERDR_ENV === "1") return false;
82
85
  return terminalId === "kitty" || terminalId === "ghostty";
83
86
  }
84
87
 
@@ -460,6 +460,7 @@ function gridBox(rows: Box[][], align: (col: number) => CellAlign, gap: (col: nu
460
460
  let ncols = 0;
461
461
  for (const row of rows) ncols = Math.max(ncols, row.length);
462
462
  if (ncols === 0 || rows.length === 0) return textBox("");
463
+ // oxlint-disable-next-line unicorn/no-new-array -- grid-width allocation
463
464
  const widths = new Array<number>(ncols).fill(0);
464
465
  for (const row of rows) {
465
466
  row.forEach((cell, j) => {