@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 +7 -0
- package/dist/types/terminal-capabilities.d.ts +7 -0
- package/package.json +3 -3
- package/src/components/editor.ts +12 -7
- package/src/terminal-capabilities.ts +10 -0
- package/src/tui.ts +53 -56
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.
|
|
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.
|
|
41
|
-
"@oh-my-pi/pi-utils": "17.3.
|
|
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"
|
package/src/components/editor.ts
CHANGED
|
@@ -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
|
-
(
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
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
|
|
1709
|
-
|
|
1710
|
-
this.#
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
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
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
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.#
|
|
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 =
|
|
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,
|
|
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 =
|
|
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 =
|
|
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
|
-
:
|
|
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 =
|
|
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);
|