@oh-my-pi/pi-tui 17.3.7 → 17.4.0
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 +14 -0
- package/dist/types/components/composer/borderless.d.ts +8 -0
- package/dist/types/components/composer/box.d.ts +2 -0
- package/dist/types/components/composer/claude.d.ts +8 -0
- package/dist/types/components/composer/field.d.ts +3 -0
- package/dist/types/components/composer/index.d.ts +9 -0
- package/dist/types/components/composer/pi.d.ts +2 -0
- package/dist/types/components/composer/rail.d.ts +3 -0
- package/dist/types/components/composer/registry.d.ts +12 -0
- package/dist/types/components/composer/rule.d.ts +7 -0
- package/dist/types/components/composer/types.d.ts +90 -0
- package/dist/types/components/editor.d.ts +10 -9
- package/dist/types/components/settings-list.d.ts +7 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/terminal-capabilities.d.ts +7 -0
- package/dist/types/utils.d.ts +0 -1
- package/package.json +3 -3
- package/src/components/composer/borderless.ts +37 -0
- package/src/components/composer/box.ts +85 -0
- package/src/components/composer/claude.ts +39 -0
- package/src/components/composer/field.ts +47 -0
- package/src/components/composer/index.ts +9 -0
- package/src/components/composer/pi.ts +37 -0
- package/src/components/composer/rail.ts +48 -0
- package/src/components/composer/registry.ts +46 -0
- package/src/components/composer/rule.ts +55 -0
- package/src/components/composer/types.ts +98 -0
- package/src/components/editor.ts +115 -99
- package/src/components/settings-list.ts +43 -11
- package/src/index.ts +1 -0
- package/src/terminal-capabilities.ts +10 -0
- package/src/tui.ts +53 -56
- package/src/utils.ts +53 -58
|
@@ -20,6 +20,8 @@ export interface SettingItem {
|
|
|
20
20
|
label: string;
|
|
21
21
|
/** Optional description shown when selected */
|
|
22
22
|
description?: string;
|
|
23
|
+
/** Optional risk note shown in warning styling above the description, with a glyph on the row. */
|
|
24
|
+
warning?: string;
|
|
23
25
|
/** Current value to display (right side) */
|
|
24
26
|
currentValue: string;
|
|
25
27
|
/** If provided, Enter/Space cycles through these values */
|
|
@@ -36,6 +38,10 @@ export interface SettingsListTheme {
|
|
|
36
38
|
label: (text: string, selected: boolean, changed: boolean) => string;
|
|
37
39
|
value: (text: string, selected: boolean, changed: boolean) => string;
|
|
38
40
|
description: (text: string) => string;
|
|
41
|
+
/** Style for risk notes and the row warning glyph. Falls back to `description` when omitted. */
|
|
42
|
+
warning?: (text: string) => string;
|
|
43
|
+
/** Glyph marking rows that carry a `warning`. Omitted hides the row marker. */
|
|
44
|
+
warningMark?: string;
|
|
39
45
|
cursor: string;
|
|
40
46
|
hint: (text: string) => string;
|
|
41
47
|
/** Style for section heading rows (dimmed when outside the active section). Falls back to `hint` when omitted. */
|
|
@@ -78,12 +84,15 @@ export interface SettingsListOptions {
|
|
|
78
84
|
sidebarWidth?: number;
|
|
79
85
|
}
|
|
80
86
|
|
|
81
|
-
/** Searchable text for a setting item: label, id, value, description, and cycle values. */
|
|
87
|
+
/** Searchable text for a setting item: label, id, value, description, warning, and cycle values. */
|
|
82
88
|
export function getSettingItemFilterText(item: SettingItem): string {
|
|
83
89
|
let text = `${item.label} ${item.id} ${item.currentValue}`;
|
|
84
90
|
if (item.description) {
|
|
85
91
|
text += ` ${item.description}`;
|
|
86
92
|
}
|
|
93
|
+
if (item.warning) {
|
|
94
|
+
text += ` ${item.warning}`;
|
|
95
|
+
}
|
|
87
96
|
if (item.values) {
|
|
88
97
|
text += ` ${item.values.join(" ")}`;
|
|
89
98
|
}
|
|
@@ -477,6 +486,11 @@ export class SettingsList implements Component {
|
|
|
477
486
|
return this.#padLines(this.#renderMainList(width));
|
|
478
487
|
}
|
|
479
488
|
|
|
489
|
+
/** Warning glyph suffix for a row that carries a risk note, or "" when none applies. */
|
|
490
|
+
#warningMark(item: SettingItem): string {
|
|
491
|
+
return item.warning && this.#theme.warningMark ? ` ${this.#theme.warningMark}` : "";
|
|
492
|
+
}
|
|
493
|
+
|
|
480
494
|
#renderItemRow(
|
|
481
495
|
item: SettingItem,
|
|
482
496
|
index: number,
|
|
@@ -495,7 +509,9 @@ export class SettingsList implements Component {
|
|
|
495
509
|
const isSelected = index === this.#selectedIndex && !this.#sectionFocus;
|
|
496
510
|
const prefix = isSelected ? this.#theme.cursor : " ";
|
|
497
511
|
const prefixWidth = visibleWidth(prefix);
|
|
498
|
-
const
|
|
512
|
+
const mark = this.#warningMark(item);
|
|
513
|
+
const labelPlain = item.label + mark;
|
|
514
|
+
const labelPad = padding(Math.max(0, maxLabelWidth - visibleWidth(labelPlain)));
|
|
499
515
|
const separator = " ";
|
|
500
516
|
const valueMaxWidth = rowWidth - prefixWidth - maxLabelWidth - visibleWidth(separator) - 2;
|
|
501
517
|
const valuePlain = truncateToWidth(String(item.currentValue ?? ""), valueMaxWidth, Ellipsis.Omit);
|
|
@@ -504,11 +520,13 @@ export class SettingsList implements Component {
|
|
|
504
520
|
// under one dim wash so inner label/value colors don't fight it.
|
|
505
521
|
if (dimmed && !isSelected) {
|
|
506
522
|
const text = this.#theme.hint(
|
|
507
|
-
truncateToWidth(` ${
|
|
523
|
+
truncateToWidth(` ${labelPlain}${labelPad}${separator}${valuePlain}`, Math.max(0, rowWidth)),
|
|
508
524
|
);
|
|
509
525
|
return hovered && this.#theme.hovered ? this.#theme.hovered(text) : text;
|
|
510
526
|
}
|
|
511
|
-
const
|
|
527
|
+
const warningStyle = this.#theme.warning ?? this.#theme.description;
|
|
528
|
+
const labelText =
|
|
529
|
+
this.#theme.label(item.label, isSelected, item.changed === true) + (mark ? warningStyle(mark) : "") + labelPad;
|
|
512
530
|
const valueText = this.#theme.value(valuePlain, isSelected, item.changed === true);
|
|
513
531
|
const text = truncateToWidth(prefix + labelText + separator + valueText, Math.max(0, rowWidth));
|
|
514
532
|
// Pointer hover paints a band behind the whole row, distinct from the
|
|
@@ -550,7 +568,9 @@ export class SettingsList implements Component {
|
|
|
550
568
|
0,
|
|
551
569
|
Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#filteredItems.length - viewportHeight),
|
|
552
570
|
);
|
|
553
|
-
const labelWidths = this.#filteredItems
|
|
571
|
+
const labelWidths = this.#filteredItems
|
|
572
|
+
.filter(item => !item.heading)
|
|
573
|
+
.map(item => visibleWidth(item.label + this.#warningMark(item)));
|
|
554
574
|
const maxLabelWidth = Math.min(30, labelWidths.length > 0 ? Math.max(...labelWidths) : 0);
|
|
555
575
|
const itemRowsOverflow = this.#filteredItems.length > viewportHeight;
|
|
556
576
|
const itemRowWidth = Math.max(0, width - (itemRowsOverflow ? 1 : 0));
|
|
@@ -589,15 +609,25 @@ export class SettingsList implements Component {
|
|
|
589
609
|
|
|
590
610
|
// Description area: 1 blank + exactly 3 rows, clamped with an ellipsis,
|
|
591
611
|
// so moving between items with/without descriptions never shifts rows.
|
|
612
|
+
// The risk note leads so it survives the clamp when both are present.
|
|
592
613
|
lines.push("");
|
|
593
614
|
const selectedItem = this.#filteredItems[this.#selectedIndex];
|
|
594
615
|
const descLines: string[] = [];
|
|
595
|
-
if (selectedItem
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
616
|
+
if (selectedItem && !selectedItem.heading) {
|
|
617
|
+
if (selectedItem.warning) {
|
|
618
|
+
const warningStyle = this.#theme.warning ?? this.#theme.description;
|
|
619
|
+
const mark = this.#theme.warningMark ? `${this.#theme.warningMark} ` : "";
|
|
620
|
+
for (const line of wrapTextWithAnsi(`${mark}${selectedItem.warning}`, width - 4)) {
|
|
621
|
+
descLines.push(warningStyle(` ${line}`));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
if (selectedItem.description) {
|
|
625
|
+
for (const line of wrapTextWithAnsi(selectedItem.description, width - 4)) {
|
|
626
|
+
descLines.push(this.#theme.description(` ${line}`));
|
|
627
|
+
}
|
|
599
628
|
}
|
|
600
|
-
if (
|
|
629
|
+
if (descLines.length > 3) {
|
|
630
|
+
descLines.splice(3);
|
|
601
631
|
descLines[2] = truncateToWidth(`${descLines[2]}…`, width);
|
|
602
632
|
}
|
|
603
633
|
}
|
|
@@ -659,7 +689,9 @@ export class SettingsList implements Component {
|
|
|
659
689
|
Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#filteredItems.length - viewportHeight),
|
|
660
690
|
);
|
|
661
691
|
// Label column width spans all items so the layout stays stable across sections.
|
|
662
|
-
const labelWidths = this.#filteredItems
|
|
692
|
+
const labelWidths = this.#filteredItems
|
|
693
|
+
.filter(item => !item.heading)
|
|
694
|
+
.map(item => visibleWidth(item.label + this.#warningMark(item)));
|
|
663
695
|
const maxLabelWidth = Math.min(30, labelWidths.length > 0 ? Math.max(...labelWidths) : 0);
|
|
664
696
|
const overflow = this.#filteredItems.length > viewportHeight;
|
|
665
697
|
const rowWidth = Math.max(0, paneWidth - (overflow ? 1 : 0));
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export * from "./autocomplete";
|
|
|
5
5
|
// Components
|
|
6
6
|
export * from "./components/box";
|
|
7
7
|
export * from "./components/cancellable-loader";
|
|
8
|
+
export * from "./components/composer";
|
|
8
9
|
export * from "./components/editor";
|
|
9
10
|
export * from "./components/image";
|
|
10
11
|
export * from "./components/input";
|
|
@@ -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);
|
package/src/utils.ts
CHANGED
|
@@ -225,9 +225,7 @@ export function getSegmenter(): Intl.Segmenter {
|
|
|
225
225
|
// added back so width matches the native truncate/slice/wrap helpers.
|
|
226
226
|
const OSC66_SPAN_REGEX = /\x1b\]66;([^;]*);([\s\S]*?)(?:\x07|\x1b\\)/g;
|
|
227
227
|
const OSC66_PREFIX = "\x1b]66;";
|
|
228
|
-
const
|
|
229
|
-
const TAB = "\t";
|
|
230
|
-
const LONG_WIDTH_FAST_PATH_MIN = 128;
|
|
228
|
+
const PRINTABLE_ASCII_REGEX = /^[\u0020-\u007e]*$/;
|
|
231
229
|
|
|
232
230
|
// Pin Bun.stringWidth semantics to the native width engine and guard against Bun
|
|
233
231
|
// default drift: strip ANSI/OSC (don't count escape bytes) and treat
|
|
@@ -243,8 +241,6 @@ const STRING_WIDTH_OPTS = { countAnsiEscapeCodes: false, ambiguousIsNarrow: true
|
|
|
243
241
|
// `setHangulCompatibilityJamoWidth`; mirror the same correction here so the TS
|
|
244
242
|
// width stays in parity with the native truncate/slice/wrap model — and so the
|
|
245
243
|
// hardware cursor column lands on the actual glyph during Korean IME input.
|
|
246
|
-
const HANGUL_COMPAT_JAMO_REGEX = /[\u3131-\u318e]/;
|
|
247
|
-
const HANGUL_COMPAT_JAMO_GLOBAL_REGEX = /[\u3131-\u318e]/g;
|
|
248
244
|
const HANGUL_FILLER_CODE_POINT = 0x3164;
|
|
249
245
|
// `Bun.stringWidth` counts every code point in the Compatibility Jamo block as
|
|
250
246
|
// 2 cells (even the U+3164 filler that `unicode-width` treats as zero-width).
|
|
@@ -274,19 +270,28 @@ function hangulCompatibilityJamoTargetWidth(): 1 | 2 | null {
|
|
|
274
270
|
// crates/pi-natives/src/text.rs, including the rule that the zero-width filler
|
|
275
271
|
// (U+3164) is never widened past the narrow correction (a wide terminal still
|
|
276
272
|
// renders it at its Unicode width of 0).
|
|
277
|
-
function correctHangulCompatibilityJamoWidth(
|
|
278
|
-
|
|
273
|
+
function correctHangulCompatibilityJamoWidth(
|
|
274
|
+
width: number,
|
|
275
|
+
compatibilityJamoCount: number,
|
|
276
|
+
fillerCount: number,
|
|
277
|
+
): number {
|
|
278
|
+
if (compatibilityJamoCount === 0) return width;
|
|
279
279
|
const target = hangulCompatibilityJamoTargetWidth();
|
|
280
|
-
|
|
281
|
-
HANGUL_COMPAT_JAMO_GLOBAL_REGEX.lastIndex = 0;
|
|
282
|
-
for (let m = HANGUL_COMPAT_JAMO_GLOBAL_REGEX.exec(str); m !== null; m = HANGUL_COMPAT_JAMO_GLOBAL_REGEX.exec(str)) {
|
|
283
|
-
const unicodeWidth = m[0].codePointAt(0) === HANGUL_FILLER_CODE_POINT ? 0 : 2;
|
|
284
|
-
const finalWidth = target === null || (unicodeWidth === 0 && target > 1) ? unicodeWidth : target;
|
|
285
|
-
corrected += finalWidth - HANGUL_COMPAT_JAMO_BUN_WIDTH;
|
|
286
|
-
}
|
|
287
|
-
return corrected;
|
|
280
|
+
return target === 1 ? width - compatibilityJamoCount : width - fillerCount * HANGUL_COMPAT_JAMO_BUN_WIDTH;
|
|
288
281
|
}
|
|
289
282
|
|
|
283
|
+
// Terminal redraws re-measure the same visible lines every frame, usually as
|
|
284
|
+
// the same string objects (JSC caches their hashes, so repeat lookups are
|
|
285
|
+
// O(1) — cheaper than even the ASCII fast scan). Strings longer than the
|
|
286
|
+
// length gate skip the cache entirely: hashing them costs as much as measuring
|
|
287
|
+
// them, and retaining them would pin large render buffers. Worst-case
|
|
288
|
+
// retention is MAX * MAX_LEN UTF-16 units (~2 MiB); cleared when the width
|
|
289
|
+
// configuration epoch changes.
|
|
290
|
+
const VISIBLE_WIDTH_CACHE_MAX = 2048;
|
|
291
|
+
const VISIBLE_WIDTH_CACHE_MAX_LEN = 512;
|
|
292
|
+
const visibleWidthCache = new Map<string, number>();
|
|
293
|
+
let visibleWidthCacheEpoch = widthConfigEpoch;
|
|
294
|
+
|
|
290
295
|
/**
|
|
291
296
|
* Visible width of a string in terminal columns, excluding ANSI/OSC escapes.
|
|
292
297
|
*
|
|
@@ -296,65 +301,51 @@ function correctHangulCompatibilityJamoWidth(width: number, str: string): number
|
|
|
296
301
|
*/
|
|
297
302
|
export function visibleWidth(str: string): number {
|
|
298
303
|
if (!str) return 0;
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
let width = Bun.stringWidth(str, STRING_WIDTH_OPTS);
|
|
305
|
-
let tabCount = 0;
|
|
306
|
-
for (let tabIndex = str.indexOf(TAB); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) {
|
|
307
|
-
tabCount++;
|
|
304
|
+
const cacheable = str.length <= VISIBLE_WIDTH_CACHE_MAX_LEN;
|
|
305
|
+
if (cacheable) {
|
|
306
|
+
if (visibleWidthCacheEpoch !== widthConfigEpoch) {
|
|
307
|
+
visibleWidthCache.clear();
|
|
308
|
+
visibleWidthCacheEpoch = widthConfigEpoch;
|
|
308
309
|
}
|
|
309
|
-
|
|
310
|
-
|
|
310
|
+
const cached = visibleWidthCache.get(str);
|
|
311
|
+
if (cached !== undefined) return cached;
|
|
311
312
|
}
|
|
312
313
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
tabCount++;
|
|
320
|
-
continue;
|
|
321
|
-
}
|
|
322
|
-
break;
|
|
314
|
+
// This regex compiles to a native ASCII scan, cheaper than Bun's width
|
|
315
|
+
// scanner for the overwhelmingly common source-code path.
|
|
316
|
+
if (PRINTABLE_ASCII_REGEX.test(str)) {
|
|
317
|
+
if (cacheable) {
|
|
318
|
+
if (visibleWidthCache.size >= VISIBLE_WIDTH_CACHE_MAX) visibleWidthCache.clear();
|
|
319
|
+
visibleWidthCache.set(str, str.length);
|
|
323
320
|
}
|
|
324
|
-
|
|
325
|
-
if (i === str.length) {
|
|
326
|
-
return tabCount === 0 ? str.length : str.length + tabCount * (DEFAULT_TAB_WIDTH - 1);
|
|
321
|
+
return str.length;
|
|
327
322
|
}
|
|
328
323
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
337
|
-
} else {
|
|
338
|
-
for (let tabIndex = str.indexOf(TAB, i + 1); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) {
|
|
324
|
+
let tabCount = 0;
|
|
325
|
+
let compatibilityJamoCount = 0;
|
|
326
|
+
let fillerCount = 0;
|
|
327
|
+
let hasEsc = false;
|
|
328
|
+
for (let i = 0; i < str.length; i++) {
|
|
329
|
+
const code = str.charCodeAt(i);
|
|
330
|
+
if (code === 0x09) {
|
|
339
331
|
tabCount++;
|
|
332
|
+
} else if (code === 0x1b) {
|
|
333
|
+
hasEsc = true;
|
|
334
|
+
} else if (code >= 0x3131 && code <= 0x318e) {
|
|
335
|
+
compatibilityJamoCount++;
|
|
336
|
+
if (code === HANGUL_FILLER_CODE_POINT) fillerCount++;
|
|
340
337
|
}
|
|
341
338
|
}
|
|
342
339
|
|
|
343
|
-
// `Bun.stringWidth` is a JSC builtin (no per-call N-API number box, unlike
|
|
344
|
-
// the native scanner that traps under Bun 1.3.x GC/N-API load). It strips
|
|
345
|
-
// CSI/OSC to zero cells and shares the native engine's UAX#11 width tables.
|
|
346
340
|
let width = Bun.stringWidth(str, STRING_WIDTH_OPTS);
|
|
347
341
|
if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH;
|
|
348
342
|
|
|
349
|
-
|
|
350
|
-
// width)`. Matched rather than replaced to avoid reallocating the string.
|
|
351
|
-
if (str.includes(OSC66_PREFIX, i)) {
|
|
343
|
+
if (hasEsc && str.includes(OSC66_PREFIX)) {
|
|
352
344
|
OSC66_SPAN_REGEX.lastIndex = 0;
|
|
353
345
|
for (let m = OSC66_SPAN_REGEX.exec(str); m !== null; m = OSC66_SPAN_REGEX.exec(str)) {
|
|
354
346
|
let scale = 1;
|
|
355
347
|
let explicit: number | undefined;
|
|
356
348
|
for (const part of m[1].split(":")) {
|
|
357
|
-
// metadata keys are single chars, e.g. `s=2`, `w=5`
|
|
358
349
|
if (part.indexOf("=") !== 1) continue;
|
|
359
350
|
const value = Number.parseInt(part.slice(2), 10);
|
|
360
351
|
if (!Number.isFinite(value)) continue;
|
|
@@ -368,11 +359,15 @@ export function visibleWidth(str: string): number {
|
|
|
368
359
|
}
|
|
369
360
|
}
|
|
370
361
|
|
|
371
|
-
|
|
362
|
+
width = correctHangulCompatibilityJamoWidth(width, compatibilityJamoCount, fillerCount);
|
|
363
|
+
if (cacheable) {
|
|
364
|
+
if (visibleWidthCache.size >= VISIBLE_WIDTH_CACHE_MAX) visibleWidthCache.clear();
|
|
365
|
+
visibleWidthCache.set(str, width);
|
|
366
|
+
}
|
|
367
|
+
return width;
|
|
372
368
|
}
|
|
373
369
|
|
|
374
370
|
/**
|
|
375
|
-
* True when a row carries a Kitty OSC 66 text-sizing span (`\x1b]66;…`).
|
|
376
371
|
* Scaled spans must bypass wrapping/padding and, when scaled up, reserve the
|
|
377
372
|
* terminal rows their multicell glyphs flow into.
|
|
378
373
|
*/
|