@oh-my-pi/pi-tui 17.3.7 → 17.3.8

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,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.3.8] - 2026-08-19
6
+
7
+ ### Fixed
8
+
9
+ - Fixed images rendering as the `[Image: …]` text card on SIXEL terminals that expose no identifying environment variable (foot, xterm, contour): the graphics probe no longer requires Windows Terminal, and no longer reads an XTSMGRAPHICS success reply as a failure.
10
+ - Fixed the multiline editor ignoring a `tui.input.submit` remap onto Ctrl+Enter: the hardcoded Ctrl/Shift+Enter → newline fallbacks now yield to an explicit submit binding, so Ctrl+Enter can be used to submit ([#8906](https://github.com/can1357/oh-my-pi/issues/8906)).
11
+
5
12
  ## [17.3.5] - 2026-08-16
6
13
 
7
14
  ### Fixed
@@ -57,6 +57,13 @@ export declare function isInsideTerminalMultiplexer(env?: NodeJS.ProcessEnv): bo
57
57
  */
58
58
  export declare function isInsideZellij(env?: NodeJS.ProcessEnv): boolean;
59
59
  export declare function isNotificationSuppressed(): boolean;
60
+ /**
61
+ * Whether `PI_FORCE_IMAGE_PROTOCOL` pins the image protocol, including its
62
+ * `off`/`none` kill switch. A runtime capability probe must not override an
63
+ * explicit user choice: a forced protocol is already applied to {@link TERMINAL},
64
+ * and a forced "off" leaves `imageProtocol` null on purpose.
65
+ */
66
+ export declare function isImageProtocolForced(): boolean;
60
67
  /**
61
68
  * Returns true when running in Windows Terminal with known SIXEL support.
62
69
  *
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.3.7",
4
+ "version": "17.3.8",
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.3.7",
41
- "@oh-my-pi/pi-utils": "17.3.7"
40
+ "@oh-my-pi/pi-natives": "17.3.8",
41
+ "@oh-my-pi/pi-utils": "17.3.8"
42
42
  },
43
43
  "devDependencies": {
44
44
  "ghostty-web": "^0.4.0"
@@ -1458,14 +1458,19 @@ export class Editor implements Component, Focusable {
1458
1458
  this.#addNewLine();
1459
1459
  }
1460
1460
  }
1461
- // New line
1461
+ // New line. A key the user explicitly bound to `tui.input.submit` wins
1462
+ // over these hardcoded newline fallbacks, so Ctrl/Shift+Enter can be
1463
+ // remapped to submit (#8906). The bare-LF case is exempt: its canonical
1464
+ // form is "enter" (indistinguishable from plain Enter), so gating it
1465
+ // would hijack the default Enter=submit binding.
1462
1466
  else if (
1463
- (data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
1464
- matchesKey(data, "ctrl+enter") || // Ctrl+Enter (Kitty/modifyOtherKeys, including lock bits/keypad Enter)
1465
- data === "\x1b\r" || // Option+Enter in some terminals (legacy)
1466
- data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
1467
- kb.matchesCanonical(canonical, "tui.input.newLine") || // Shift+Enter (Kitty protocol, handles lock bits)
1468
- (data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
1467
+ (!kb.matchesCanonical(canonical, "tui.input.submit") &&
1468
+ ((data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
1469
+ matchesKey(data, "ctrl+enter") || // Ctrl+Enter (Kitty/modifyOtherKeys, including lock bits/keypad Enter)
1470
+ data === "\x1b\r" || // Option+Enter in some terminals (legacy)
1471
+ data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
1472
+ kb.matchesCanonical(canonical, "tui.input.newLine") || // Shift+Enter (Kitty protocol, handles lock bits)
1473
+ (data.length > 1 && data.includes("\x1b") && data.includes("\r")))) ||
1469
1474
  (data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping
1470
1475
  ) {
1471
1476
  if (this.#shouldSubmitOnBackslashEnter(data, kb)) {
@@ -223,6 +223,16 @@ function getForcedImageProtocol(): ImageProtocol | null | undefined {
223
223
  return null;
224
224
  }
225
225
 
226
+ /**
227
+ * Whether `PI_FORCE_IMAGE_PROTOCOL` pins the image protocol, including its
228
+ * `off`/`none` kill switch. A runtime capability probe must not override an
229
+ * explicit user choice: a forced protocol is already applied to {@link TERMINAL},
230
+ * and a forced "off" leaves `imageProtocol` null on purpose.
231
+ */
232
+ export function isImageProtocolForced(): boolean {
233
+ return getForcedImageProtocol() !== undefined;
234
+ }
235
+
226
236
  function parseMajorMinorVersion(versionRaw?: string): { major: number; minor: number } | null {
227
237
  if (!versionRaw) return null;
228
238
  const match = /^(\d+)\.(\d+)/u.exec(versionRaw.trim());
package/src/tui.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  encodeKittyDeletePlacement,
29
29
  encodeKittyPlacementLine,
30
30
  ImageProtocol,
31
+ isImageProtocolForced,
31
32
  isInsideTerminalMultiplexer,
32
33
  parseKittyDirectPlacementLine,
33
34
  setCellDimensions,
@@ -1249,7 +1250,6 @@ export class TUI extends Container {
1249
1250
  #hardwareCursorState: HardwareCursorState | null = null;
1250
1251
  #hardwareCursorVisibilityKnown = false;
1251
1252
  #hardwareCursorVisible = false;
1252
- #sixelProbePendingDa = false;
1253
1253
  #sixelProbePendingGraphics = false;
1254
1254
  #sixelProbeBuffer = "";
1255
1255
  #sixelProbeTimeout?: NodeJS.Timeout;
@@ -1332,6 +1332,12 @@ export class TUI extends Container {
1332
1332
  #previousWindow: string[] = [];
1333
1333
  #nativeScrollbackLiveRegionStart: number | undefined;
1334
1334
  #nativeScrollbackLiveRegionPinned = false;
1335
+ // Start row of the topmost live region that pinned itself. The topmost seam
1336
+ // governs the exactness boundary and the frame-wide pin policy, but a pinned
1337
+ // region BELOW an unpinned seam (an anchored HUD/panel under a streaming
1338
+ // transcript) still must never commit its rows to native scrollback. This is
1339
+ // the ceiling no commit may cross, independent of the topmost seam's policy.
1340
+ #nativeScrollbackPinnedBoundary: number | undefined;
1335
1341
  #fullRedrawCount = 0;
1336
1342
  // Caps how many inline images render as live graphics; older ones fall back
1337
1343
  // to text via a purge + full redraw. Cap is configured by the host app.
@@ -1625,6 +1631,7 @@ export class TUI extends Container {
1625
1631
  width = Math.max(1, width);
1626
1632
  this.#nativeScrollbackLiveRegionStart = undefined;
1627
1633
  this.#nativeScrollbackLiveRegionPinned = false;
1634
+ this.#nativeScrollbackPinnedBoundary = undefined;
1628
1635
  const children = this.children;
1629
1636
  const previousSegments = this.#frameSegments;
1630
1637
  const segments: FrameSegment[] = new Array(children.length);
@@ -1705,9 +1712,18 @@ export class TUI extends Container {
1705
1712
  // transcript) must never overwrite it — moving the boundary down
1706
1713
  // would commit the earlier child's still-mutable rows as stale
1707
1714
  // history.
1708
- if (liveLocalStart !== undefined && this.#nativeScrollbackLiveRegionStart === undefined) {
1709
- this.#nativeScrollbackLiveRegionStart = offset + liveLocalStart;
1710
- this.#nativeScrollbackLiveRegionPinned = liveRegionPinned;
1715
+ if (liveLocalStart !== undefined) {
1716
+ const start = offset + liveLocalStart;
1717
+ if (this.#nativeScrollbackLiveRegionStart === undefined) {
1718
+ this.#nativeScrollbackLiveRegionStart = start;
1719
+ this.#nativeScrollbackLiveRegionPinned = liveRegionPinned;
1720
+ }
1721
+ // A pinned region anywhere in the frame caps commits at its start,
1722
+ // even when an earlier unpinned seam won the topmost merge above:
1723
+ // its rows (a growing anchored panel) must never reach scrollback.
1724
+ if (liveRegionPinned && this.#nativeScrollbackPinnedBoundary === undefined) {
1725
+ this.#nativeScrollbackPinnedBoundary = start;
1726
+ }
1711
1727
  }
1712
1728
  if (chainStable) {
1713
1729
  if (previous !== undefined && previous.component === child && previous.start === offset) {
@@ -2200,19 +2216,21 @@ export class TUI extends Container {
2200
2216
  }
2201
2217
 
2202
2218
  #querySixelSupport(): void {
2219
+ // A statically known protocol (Kitty/iTerm2 terminals) or an explicit
2220
+ // PI_FORCE_IMAGE_PROTOCOL choice — including its `off` kill switch — wins
2221
+ // over the probe.
2203
2222
  if (TERMINAL.imageProtocol) return;
2204
- // win32 native or WSL under Windows Terminal — both are ConPTY-hosted and
2205
- // reach the same WT graphics negotiation. WSL reports process.platform
2206
- // "linux", so a bare win32 check silently skips the probe there (#6009).
2207
- if (!isConPTYHosted()) return;
2208
- if (!Bun.env.WT_SESSION) return;
2223
+ if (isImageProtocolForced()) return;
2209
2224
  if (!process.stdin.isTTY || !process.stdout.isTTY) return;
2210
2225
 
2211
2226
  this.#clearSixelProbeState();
2212
- this.#sixelProbePendingDa = true;
2213
2227
  this.#sixelProbePendingGraphics = true;
2214
2228
  this.#sixelProbeUnsubscribe = this.addInputListener(data => this.#handleSixelProbeInput(data));
2215
- this.terminal.write("\x1b[c");
2229
+ // XTSMGRAPHICS item 2 reports the terminal's maximum SIXEL geometry. DA1
2230
+ // attribute 4 advertises SIXEL as well, but ProcessTerminal swallows every
2231
+ // `CSI ? … c` reply for the whole session so a late one cannot leak into the
2232
+ // composer (#8542): those bytes never reach an input listener, so this probe
2233
+ // cannot read them.
2216
2234
  this.terminal.write("\x1b[?2;1;0S");
2217
2235
  this.#sixelProbeTimeout = setTimeout(() => {
2218
2236
  this.#finishSixelProbe(false);
@@ -2220,7 +2238,7 @@ export class TUI extends Container {
2220
2238
  }
2221
2239
 
2222
2240
  #handleSixelProbeInput(data: string): InputListenerResult {
2223
- if (!this.#sixelProbePendingDa && !this.#sixelProbePendingGraphics) {
2241
+ if (!this.#sixelProbePendingGraphics) {
2224
2242
  return undefined;
2225
2243
  }
2226
2244
 
@@ -2229,47 +2247,24 @@ export class TUI extends Container {
2229
2247
  let probeOutcome: boolean | null = null;
2230
2248
 
2231
2249
  while (this.#sixelProbeBuffer.length > 0) {
2232
- const daMatch = this.#sixelProbeBuffer.match(/\x1b\[\?([0-9;]+)c/u);
2233
2250
  const graphicsMatch = this.#sixelProbeBuffer.match(/\x1b\[\?2;(\d+);([0-9;]+)S/u);
2251
+ if (!graphicsMatch || graphicsMatch.index === undefined) break;
2234
2252
 
2235
- if (!daMatch && !graphicsMatch) break;
2236
-
2237
- const daIndex = daMatch?.index ?? Number.POSITIVE_INFINITY;
2238
- const graphicsIndex = graphicsMatch?.index ?? Number.POSITIVE_INFINITY;
2239
- const useDa = daIndex <= graphicsIndex;
2240
- const match = useDa ? daMatch : graphicsMatch;
2241
- if (!match || match.index === undefined) break;
2242
-
2243
- passthrough += this.#sixelProbeBuffer.slice(0, match.index);
2244
- this.#sixelProbeBuffer = this.#sixelProbeBuffer.slice(match.index + match[0].length);
2245
-
2246
- if (useDa && this.#sixelProbePendingDa) {
2247
- this.#sixelProbePendingDa = false;
2248
- const attributes = (match[1] ?? "")
2249
- .split(";")
2250
- .map(value => Number.parseInt(value, 10))
2251
- .filter(value => Number.isFinite(value));
2252
- const hasSixelAttribute = attributes.includes(4);
2253
- if (hasSixelAttribute) {
2254
- this.#sixelProbePendingGraphics = false;
2255
- probeOutcome = true;
2256
- } else if (!this.#sixelProbePendingGraphics) {
2257
- probeOutcome = false;
2258
- }
2259
- } else if (!useDa && this.#sixelProbePendingGraphics) {
2253
+ passthrough += this.#sixelProbeBuffer.slice(0, graphicsMatch.index);
2254
+ this.#sixelProbeBuffer = this.#sixelProbeBuffer.slice(graphicsMatch.index + graphicsMatch[0].length);
2255
+
2256
+ if (this.#sixelProbePendingGraphics) {
2260
2257
  this.#sixelProbePendingGraphics = false;
2261
- const status = Number.parseInt(match[1] ?? "", 10);
2262
- const supportsSixel = !Number.isNaN(status) && status !== 0;
2263
- if (supportsSixel) {
2264
- this.#sixelProbePendingDa = false;
2265
- probeOutcome = true;
2266
- } else if (!this.#sixelProbePendingDa) {
2267
- probeOutcome = false;
2268
- }
2258
+ // Reply shape `CSI ? 2 ; Ps ; Pv S`: per xterm ctlseqs Ps is the status
2259
+ // (0 = success, 1..3 = error/failure) and Pv the maximum SIXEL geometry,
2260
+ // which a terminal without SIXEL reports as zero.
2261
+ const status = Number.parseInt(graphicsMatch[1] ?? "", 10);
2262
+ const hasGeometry = (graphicsMatch[2] ?? "").split(";").some(part => Number.parseInt(part, 10) > 0);
2263
+ probeOutcome = status === 0 && hasGeometry;
2269
2264
  }
2270
2265
  }
2271
2266
 
2272
- if (this.#sixelProbePendingDa || this.#sixelProbePendingGraphics) {
2267
+ if (this.#sixelProbePendingGraphics) {
2273
2268
  const partialStart = this.#getSixelProbePartialStart(this.#sixelProbeBuffer);
2274
2269
  if (partialStart >= 0) {
2275
2270
  passthrough += this.#sixelProbeBuffer.slice(0, partialStart);
@@ -2313,7 +2308,6 @@ export class TUI extends Container {
2313
2308
  this.#sixelProbeUnsubscribe();
2314
2309
  this.#sixelProbeUnsubscribe = undefined;
2315
2310
  }
2316
- this.#sixelProbePendingDa = false;
2317
2311
  this.#sixelProbePendingGraphics = false;
2318
2312
  this.#sixelProbeBuffer = "";
2319
2313
  }
@@ -3551,6 +3545,11 @@ export class TUI extends Container {
3551
3545
  // reports no seam (shell semantics).
3552
3546
  const frameLength = rawFrame.length;
3553
3547
  const finalBoundary = Math.max(0, Math.min(frameLength, liveRegionStart ?? frameLength));
3548
+ // No commit may cross into a pinned region, even one below an unpinned
3549
+ // topmost seam (an anchored HUD/panel under a streaming transcript). The
3550
+ // topmost seam still governs exactness (finalBoundary); this ceiling only
3551
+ // bars a growing pinned region's scrolled-off rows from native scrollback.
3552
+ const commitCeiling = this.#nativeScrollbackPinnedBoundary ?? frameLength;
3554
3553
 
3555
3554
  // 2. Transition state captured before any emitter runs.
3556
3555
  let prevWindowTop = this.#windowTopRow;
@@ -3769,7 +3768,7 @@ export class TUI extends Container {
3769
3768
  if (fullPaint) {
3770
3769
  committedPrefixResliced = true;
3771
3770
  windowTop = Math.max(0, frameLength - height);
3772
- chunkTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop;
3771
+ chunkTo = Math.min(windowTop, commitCeiling);
3773
3772
  } else if (widthEpochReset) {
3774
3773
  // A terminal width change ends the physical-row coordinate epoch.
3775
3774
  // Resolve the last emitted logical source boundary at the new width;
@@ -3789,7 +3788,7 @@ export class TUI extends Container {
3789
3788
  hasVisibleOverlay || widthEpochCurrentRows === undefined
3790
3789
  ? hasVisibleOverlay
3791
3790
  ? widthEpochAppendFrom
3792
- : Math.max(widthEpochAppendFrom, liveRegionPinned ? finalBoundary : frameLength)
3791
+ : Math.max(widthEpochAppendFrom, commitCeiling)
3793
3792
  : Math.max(widthEpochAppendFrom, widthEpochCurrentRows);
3794
3793
  } else if (this.#widthEpochBaselineRows !== undefined) {
3795
3794
  // Only rows physically appended after the width epoch may drive the
@@ -3800,7 +3799,7 @@ export class TUI extends Container {
3800
3799
  windowTop = Math.max(0, frameLength - height);
3801
3800
  chunkTo = this.#committedRows;
3802
3801
  widthEpochAppendFrom = this.#widthEpochBaselineRows;
3803
- const appendBoundary = liveRegionPinned ? finalBoundary : frameLength;
3802
+ const appendBoundary = commitCeiling;
3804
3803
  widthEpochAppendTo = hasVisibleOverlay ? widthEpochAppendFrom : Math.max(widthEpochAppendFrom, appendBoundary);
3805
3804
  } else if (
3806
3805
  frameLength <= this.#committedRows ||
@@ -3821,7 +3820,7 @@ export class TUI extends Container {
3821
3820
  // "duplication, never loss" is the ED3-unsafe fallback contract.
3822
3821
  committedPrefixResliced = true;
3823
3822
  windowTop = Math.max(0, frameLength - height);
3824
- chunkTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop;
3823
+ chunkTo = Math.min(windowTop, commitCeiling);
3825
3824
  this.#committedRows = chunkTo;
3826
3825
  this.#committedPrefix = rawFrame.slice(0, chunkTo);
3827
3826
  } else if (geometryChanged && Math.max(0, frameLength - height) < this.#committedRows) {
@@ -3853,9 +3852,7 @@ export class TUI extends Container {
3853
3852
  chunkTo =
3854
3853
  hasVisibleOverlay || geometryChanged
3855
3854
  ? this.#committedRows
3856
- : liveRegionPinned
3857
- ? Math.min(windowTop, Math.max(this.#committedRows, finalBoundary))
3858
- : windowTop;
3855
+ : Math.min(windowTop, Math.max(this.#committedRows, commitCeiling));
3859
3856
  if (geometryChanged) {
3860
3857
  committedPrefixResliced = true;
3861
3858
  this.#committedPrefix = rawFrame.slice(0, this.#committedRows);
@@ -3965,7 +3962,7 @@ export class TUI extends Container {
3965
3962
  let commitTo: number;
3966
3963
  if (replayUnresolvedWidthEpoch) {
3967
3964
  commitFrom = 0;
3968
- commitTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop;
3965
+ commitTo = Math.min(windowTop, commitCeiling);
3969
3966
  scrollRows = commitTo;
3970
3967
  } else if (logicalAppend && !logicalPrefixAppend) {
3971
3968
  const sourceWindowTop = Math.max(0, widthEpochSourceBoundary - height);