@oh-my-pi/pi-tui 15.5.15 → 15.7.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 +15 -0
- package/dist/types/tui.d.ts +12 -0
- package/package.json +5 -5
- package/src/components/editor.ts +25 -0
- package/src/tui.ts +53 -18
- package/src/utils.ts +39 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.7.0] - 2026-05-31
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed slash-command autocomplete repainting when a Windows Terminal session cannot report native scrollback position; live input renders can now bypass the unknown-viewport deferral without weakening background scrollback protection. ([#1550](https://github.com/can1357/oh-my-pi/issues/1550))
|
|
10
|
+
|
|
11
|
+
## [15.6.0] - 2026-05-30
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Added autocomplete triggering for internal URL scheme tokens such as `local://` and `skill://` while typing in the editor
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Fixed streaming output staying invisible in Windows Terminal + WSL2 until the window was minimized + restored. The 15.5.14 WSL branch of `requiresNativeViewportProofForReplay` treated an unknown native viewport state as "scrolled into history" — but `ProcessTerminal.isNativeViewportAtBottom` can only return a real answer through `kernel32.dll` FFI, which a Linux user-space process inside WSL cannot load, so the probe was permanently `undefined`. Every row-inserting structural mutation (each new streaming token row above the bottom-anchored prompt) was therefore classified as `deferredMutation` and emitted zero bytes. Any geometry change (resize/minimize/restore) bypassed the gate via a different render intent, which is why the output became visible only on window resize. The WSL clause is removed; on platforms where the probe cannot answer, unknown is treated as at-bottom (the pre-15.5.14 behaviour) so the live render path runs again. Native Win32 keeps the conservative "assume scrolled when unknown" heuristic since `kernel32` FFI does succeed there and unknown means the probe transiently failed. ([#1534](https://github.com/can1357/oh-my-pi/issues/1534))
|
|
19
|
+
|
|
5
20
|
## [15.5.14] - 2026-05-29
|
|
6
21
|
|
|
7
22
|
### Added
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -44,6 +44,18 @@ export interface Focusable {
|
|
|
44
44
|
export interface RenderRequestOptions {
|
|
45
45
|
/** Clear terminal scrollback for intentional transcript replacement. */
|
|
46
46
|
clearScrollback?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Bypass the unknown-Windows-viewport deferral for this render so the
|
|
49
|
+
* caller's intentional live UI mutation reaches the terminal even when
|
|
50
|
+
* `Terminal#isNativeViewportAtBottom()` cannot answer.
|
|
51
|
+
*
|
|
52
|
+
* Use only for renders driven by direct user interaction (autocomplete
|
|
53
|
+
* updates, IME, etc.). Any background/offscreen transcript change that
|
|
54
|
+
* coalesces into the same frame WILL also bypass the deferral and reach
|
|
55
|
+
* native scrollback — that is the trade-off, and the reason ordinary
|
|
56
|
+
* `requestRender()` calls must continue to omit this flag.
|
|
57
|
+
*/
|
|
58
|
+
allowUnknownViewportMutation?: boolean;
|
|
47
59
|
}
|
|
48
60
|
/** Options for deferred native scrollback rebuild checkpoints. */
|
|
49
61
|
export interface NativeScrollbackRefreshOptions {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "15.
|
|
4
|
+
"version": "15.7.0",
|
|
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,10 +37,10 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "15.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.
|
|
42
|
-
"lru-cache": "11.
|
|
43
|
-
"marked": "^18.0.
|
|
40
|
+
"@oh-my-pi/pi-natives": "15.7.0",
|
|
41
|
+
"@oh-my-pi/pi-utils": "15.7.0",
|
|
42
|
+
"lru-cache": "11.5.1",
|
|
43
|
+
"marked": "^18.0.4"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"chalk": "^5.6.2",
|
package/src/components/editor.ts
CHANGED
|
@@ -1571,6 +1571,10 @@ export class Editor implements Component, Focusable {
|
|
|
1571
1571
|
else if (textBeforeCursor.match(/(?:^|[\s([{>]):[a-zA-Z0-9_+-]*$/)) {
|
|
1572
1572
|
this.#tryTriggerAutocomplete();
|
|
1573
1573
|
}
|
|
1574
|
+
// Check if we're typing an internal URL scheme (e.g. local://, skill://)
|
|
1575
|
+
else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) {
|
|
1576
|
+
this.#tryTriggerAutocomplete();
|
|
1577
|
+
}
|
|
1574
1578
|
}
|
|
1575
1579
|
} else {
|
|
1576
1580
|
this.#debouncedUpdateAutocomplete();
|
|
@@ -1762,6 +1766,10 @@ export class Editor implements Component, Focusable {
|
|
|
1762
1766
|
else if (textBeforeCursor.match(/#[^\s#]*$/)) {
|
|
1763
1767
|
this.#tryTriggerAutocomplete();
|
|
1764
1768
|
}
|
|
1769
|
+
// internal URL scheme context (e.g. local://, skill://)
|
|
1770
|
+
else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) {
|
|
1771
|
+
this.#tryTriggerAutocomplete();
|
|
1772
|
+
}
|
|
1765
1773
|
}
|
|
1766
1774
|
}
|
|
1767
1775
|
|
|
@@ -1913,6 +1921,8 @@ export class Editor implements Component, Focusable {
|
|
|
1913
1921
|
this.#tryTriggerAutocomplete();
|
|
1914
1922
|
} else if (textBeforeCursor.match(/#[^\s#]*$/)) {
|
|
1915
1923
|
this.#tryTriggerAutocomplete();
|
|
1924
|
+
} else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) {
|
|
1925
|
+
this.#tryTriggerAutocomplete();
|
|
1916
1926
|
}
|
|
1917
1927
|
}
|
|
1918
1928
|
}
|
|
@@ -2232,6 +2242,10 @@ export class Editor implements Component, Focusable {
|
|
|
2232
2242
|
else if (textBeforeCursor.match(/#[^\s#]*$/)) {
|
|
2233
2243
|
this.#tryTriggerAutocomplete();
|
|
2234
2244
|
}
|
|
2245
|
+
// internal URL scheme context (e.g. local://, skill://)
|
|
2246
|
+
else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) {
|
|
2247
|
+
this.#tryTriggerAutocomplete();
|
|
2248
|
+
}
|
|
2235
2249
|
}
|
|
2236
2250
|
}
|
|
2237
2251
|
|
|
@@ -2463,6 +2477,17 @@ export class Editor implements Component, Focusable {
|
|
|
2463
2477
|
}
|
|
2464
2478
|
|
|
2465
2479
|
// Autocomplete methods
|
|
2480
|
+
/**
|
|
2481
|
+
* Whether the text ending at the cursor looks like a `scheme://` URL token.
|
|
2482
|
+
* Generic by design: any scheme triggers a suggestion fetch and the active
|
|
2483
|
+
* provider decides whether it has candidates (returning none is a no-op).
|
|
2484
|
+
* MUST stay in sync with the token grammar in coding-agent's
|
|
2485
|
+
* `internal-url-autocomplete.ts`.
|
|
2486
|
+
*/
|
|
2487
|
+
#textTriggersUrlAutocomplete(textBeforeCursor: string): boolean {
|
|
2488
|
+
return /(?:^|[\s"'`(<=])[a-z][a-z0-9+.-]*:\/{1,2}[^\s"'`()<>]*$/i.test(textBeforeCursor);
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2466
2491
|
async #tryTriggerAutocomplete(explicitTab: boolean = false): Promise<void> {
|
|
2467
2492
|
if (!this.#autocompleteProvider) return;
|
|
2468
2493
|
// Check if we should trigger file completion on Tab
|
package/src/tui.ts
CHANGED
|
@@ -84,6 +84,18 @@ export interface Focusable {
|
|
|
84
84
|
export interface RenderRequestOptions {
|
|
85
85
|
/** Clear terminal scrollback for intentional transcript replacement. */
|
|
86
86
|
clearScrollback?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Bypass the unknown-Windows-viewport deferral for this render so the
|
|
89
|
+
* caller's intentional live UI mutation reaches the terminal even when
|
|
90
|
+
* `Terminal#isNativeViewportAtBottom()` cannot answer.
|
|
91
|
+
*
|
|
92
|
+
* Use only for renders driven by direct user interaction (autocomplete
|
|
93
|
+
* updates, IME, etc.). Any background/offscreen transcript change that
|
|
94
|
+
* coalesces into the same frame WILL also bypass the deferral and reach
|
|
95
|
+
* native scrollback — that is the trade-off, and the reason ordinary
|
|
96
|
+
* `requestRender()` calls must continue to omit this flag.
|
|
97
|
+
*/
|
|
98
|
+
allowUnknownViewportMutation?: boolean;
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
/** Options for deferred native scrollback rebuild checkpoints. */
|
|
@@ -154,15 +166,6 @@ function isMultiplexerSession(): boolean {
|
|
|
154
166
|
return Boolean(Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ);
|
|
155
167
|
}
|
|
156
168
|
|
|
157
|
-
function requiresNativeViewportProofForReplay(): boolean {
|
|
158
|
-
return (
|
|
159
|
-
process.platform === "win32" ||
|
|
160
|
-
(process.platform === "linux" &&
|
|
161
|
-
Boolean(Bun.env.WT_SESSION) &&
|
|
162
|
-
Boolean(Bun.env.WSL_DISTRO_NAME || Bun.env.WSL_INTEROP))
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
169
|
/**
|
|
167
170
|
* Options for overlay positioning and sizing.
|
|
168
171
|
* Values can be absolute numbers or percentage strings (e.g., "50%").
|
|
@@ -325,6 +328,7 @@ export class TUI extends Container {
|
|
|
325
328
|
#nativeScrollbackDirty = false;
|
|
326
329
|
#fullRedrawCount = 0;
|
|
327
330
|
#clearScrollbackOnNextRender = false;
|
|
331
|
+
#allowUnknownViewportMutationOnNextRender = false;
|
|
328
332
|
#hasEverRendered = false;
|
|
329
333
|
#stopped = false;
|
|
330
334
|
|
|
@@ -679,6 +683,7 @@ export class TUI extends Container {
|
|
|
679
683
|
}
|
|
680
684
|
|
|
681
685
|
requestRender(force = false, options?: RenderRequestOptions): void {
|
|
686
|
+
this.#allowUnknownViewportMutationOnNextRender ||= options?.allowUnknownViewportMutation === true;
|
|
682
687
|
if (force) {
|
|
683
688
|
this.#prepareForcedRender(options?.clearScrollback === true);
|
|
684
689
|
this.#renderRequested = true;
|
|
@@ -1147,11 +1152,19 @@ export class TUI extends Container {
|
|
|
1147
1152
|
const prevHardwareCursorRow = this.#hardwareCursorRow;
|
|
1148
1153
|
const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
|
|
1149
1154
|
const heightChanged = this.#previousHeight > 0 && this.#previousHeight !== height;
|
|
1155
|
+
const allowUnknownViewportMutation = this.#allowUnknownViewportMutationOnNextRender;
|
|
1156
|
+
this.#allowUnknownViewportMutationOnNextRender = false;
|
|
1150
1157
|
|
|
1151
1158
|
// 3. Classify intent.
|
|
1152
|
-
const intent = this.#planRender(
|
|
1159
|
+
const intent = this.#planRender(
|
|
1160
|
+
lines,
|
|
1161
|
+
widthChanged,
|
|
1162
|
+
heightChanged,
|
|
1163
|
+
prevViewportTop,
|
|
1164
|
+
height,
|
|
1165
|
+
allowUnknownViewportMutation,
|
|
1166
|
+
);
|
|
1153
1167
|
this.#logRedraw(intent, lines.length, height);
|
|
1154
|
-
|
|
1155
1168
|
// 4. Execute.
|
|
1156
1169
|
switch (intent.kind) {
|
|
1157
1170
|
case "noop":
|
|
@@ -1228,6 +1241,7 @@ export class TUI extends Container {
|
|
|
1228
1241
|
heightChanged: boolean,
|
|
1229
1242
|
prevViewportTop: number,
|
|
1230
1243
|
height: number,
|
|
1244
|
+
allowUnknownViewportMutation: boolean,
|
|
1231
1245
|
): RenderIntent {
|
|
1232
1246
|
// Initial paint after start(): scrollback must keep its prior shell
|
|
1233
1247
|
// content, but the viewport must be cleared so stale rows do not bleed
|
|
@@ -1262,14 +1276,14 @@ export class TUI extends Container {
|
|
|
1262
1276
|
!isMultiplexerSession()
|
|
1263
1277
|
) {
|
|
1264
1278
|
if (widthChanged || heightChanged) {
|
|
1265
|
-
if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom())) {
|
|
1279
|
+
if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation)) {
|
|
1266
1280
|
this.#markNativeScrollbackDirty();
|
|
1267
1281
|
return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
|
|
1268
1282
|
}
|
|
1269
1283
|
return { kind: "historyRebuild" };
|
|
1270
1284
|
}
|
|
1271
1285
|
this.#markNativeScrollbackDirty();
|
|
1272
|
-
if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom())) {
|
|
1286
|
+
if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation)) {
|
|
1273
1287
|
return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
|
|
1274
1288
|
}
|
|
1275
1289
|
return { kind: "viewportRepaint" };
|
|
@@ -1301,7 +1315,7 @@ export class TUI extends Container {
|
|
|
1301
1315
|
// through to the diff path so the append handler scrolls them into history.
|
|
1302
1316
|
if (widthChanged) {
|
|
1303
1317
|
if (diff.firstChanged < prevViewportTop) {
|
|
1304
|
-
if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom())) {
|
|
1318
|
+
if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation)) {
|
|
1305
1319
|
this.#markNativeScrollbackDirty();
|
|
1306
1320
|
return { kind: "viewportRepaint" };
|
|
1307
1321
|
}
|
|
@@ -1315,10 +1329,28 @@ export class TUI extends Container {
|
|
|
1315
1329
|
const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
|
|
1316
1330
|
const structuralMutation = newLines.length !== this.#previousLines.length || diff.firstChanged < prevViewportTop;
|
|
1317
1331
|
if (!pureAppend && structuralMutation && !isMultiplexerSession()) {
|
|
1318
|
-
|
|
1332
|
+
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
|
1333
|
+
if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
|
|
1319
1334
|
this.#markNativeScrollbackDirty();
|
|
1320
1335
|
return { kind: "deferredMutation" };
|
|
1321
1336
|
}
|
|
1337
|
+
// Expanding a collapsed offscreen cell inserts rows before an unchanged
|
|
1338
|
+
// suffix. A viewport-only repaint makes the live bottom look correct but
|
|
1339
|
+
// leaves native scrollback holding the old collapsed rows; scrolling up then
|
|
1340
|
+
// shows a splice of stale history and the new tail. Pure tail appends with an
|
|
1341
|
+
// offscreen status/header tick are still handled by the append-tail path.
|
|
1342
|
+
if (
|
|
1343
|
+
contentGrew &&
|
|
1344
|
+
diff.firstChanged < prevViewportTop &&
|
|
1345
|
+
this.#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom, false)
|
|
1346
|
+
) {
|
|
1347
|
+
const appendedTailStart = diff.appendedLines ? this.#findAppendedTailStart(newLines) : newLines.length;
|
|
1348
|
+
const tailAppendCount = newLines.length - appendedTailStart;
|
|
1349
|
+
const addedCount = newLines.length - this.#previousLines.length;
|
|
1350
|
+
if (addedCount > tailAppendCount) {
|
|
1351
|
+
return { kind: "historyRebuild" };
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1322
1354
|
}
|
|
1323
1355
|
|
|
1324
1356
|
// Height changes shift the visible window. Repaint when content didn't
|
|
@@ -1421,10 +1453,13 @@ export class TUI extends Container {
|
|
|
1421
1453
|
return this.terminal.isNativeViewportAtBottom?.();
|
|
1422
1454
|
}
|
|
1423
1455
|
|
|
1424
|
-
#nativeViewportIsScrolled(
|
|
1456
|
+
#nativeViewportIsScrolled(
|
|
1457
|
+
nativeViewportAtBottom: boolean | undefined,
|
|
1458
|
+
allowUnknownViewportMutation = false,
|
|
1459
|
+
): boolean {
|
|
1425
1460
|
return (
|
|
1426
1461
|
nativeViewportAtBottom === false ||
|
|
1427
|
-
(nativeViewportAtBottom === undefined &&
|
|
1462
|
+
(nativeViewportAtBottom === undefined && process.platform === "win32" && !allowUnknownViewportMutation)
|
|
1428
1463
|
);
|
|
1429
1464
|
}
|
|
1430
1465
|
|
|
@@ -1438,7 +1473,7 @@ export class TUI extends Container {
|
|
|
1438
1473
|
): boolean {
|
|
1439
1474
|
return (
|
|
1440
1475
|
nativeViewportAtBottom === true ||
|
|
1441
|
-
(nativeViewportAtBottom === undefined && (allowUnknownViewport ||
|
|
1476
|
+
(nativeViewportAtBottom === undefined && (allowUnknownViewport || process.platform !== "win32"))
|
|
1442
1477
|
);
|
|
1443
1478
|
}
|
|
1444
1479
|
|
package/src/utils.ts
CHANGED
|
@@ -85,6 +85,44 @@ export function padding(n: number): string {
|
|
|
85
85
|
// Grapheme segmenter (shared instance)
|
|
86
86
|
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
87
87
|
|
|
88
|
+
const EXTENDED_PICTOGRAPHIC_REGEX = /\p{Extended_Pictographic}/u;
|
|
89
|
+
|
|
90
|
+
// Matches CSI (`\x1b[…`) and OSC (`\x1b]…` terminated by BEL/ST) escape
|
|
91
|
+
// sequences. Mirrors the standard ansi-regex coverage so visible-span
|
|
92
|
+
// segmentation lines up with the native ANSI scanner.
|
|
93
|
+
const ANSI_ESCAPE_REGEX =
|
|
94
|
+
/[\u001b\u009b][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
|
|
95
|
+
|
|
96
|
+
function pictographicSpanWidth(span: string): number {
|
|
97
|
+
let width = 0;
|
|
98
|
+
for (const { segment } of segmenter.segment(span)) {
|
|
99
|
+
width += EXTENDED_PICTOGRAPHIC_REGEX.test(segment) ? 2 : nativeVisibleWidth(segment, getDefaultTabWidth());
|
|
100
|
+
}
|
|
101
|
+
return width;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Width fallback for strings that mix ANSI styling with ZWJ pictographic
|
|
105
|
+
// emoji. `Intl.Segmenter` would split an escape sequence into individual
|
|
106
|
+
// graphemes, so the native scanner (which only skips ANSI when handed the
|
|
107
|
+
// complete sequence) double-counts the printable SGR bytes. Excise the ANSI
|
|
108
|
+
// spans first — they contribute zero cells — and apply the pictographic
|
|
109
|
+
// grapheme override only to the visible spans, then sum.
|
|
110
|
+
function visibleWidthByGrapheme(str: string): number {
|
|
111
|
+
let width = 0;
|
|
112
|
+
let lastIndex = 0;
|
|
113
|
+
ANSI_ESCAPE_REGEX.lastIndex = 0;
|
|
114
|
+
for (let match = ANSI_ESCAPE_REGEX.exec(str); match !== null; match = ANSI_ESCAPE_REGEX.exec(str)) {
|
|
115
|
+
if (match.index > lastIndex) {
|
|
116
|
+
width += pictographicSpanWidth(str.slice(lastIndex, match.index));
|
|
117
|
+
}
|
|
118
|
+
lastIndex = ANSI_ESCAPE_REGEX.lastIndex;
|
|
119
|
+
}
|
|
120
|
+
if (lastIndex < str.length) {
|
|
121
|
+
width += lastIndex === 0 ? pictographicSpanWidth(str) : pictographicSpanWidth(str.slice(lastIndex));
|
|
122
|
+
}
|
|
123
|
+
return width;
|
|
124
|
+
}
|
|
125
|
+
|
|
88
126
|
/**
|
|
89
127
|
* Get the shared grapheme segmenter instance.
|
|
90
128
|
*/
|
|
@@ -104,7 +142,7 @@ export function visibleWidthRaw(str: string): number {
|
|
|
104
142
|
for (let i = 0; i < str.length; i++) {
|
|
105
143
|
const code = str.charCodeAt(i);
|
|
106
144
|
if (code < 0x20 || code > 0x7e) {
|
|
107
|
-
return nativeVisibleWidth(str, getDefaultTabWidth());
|
|
145
|
+
return str.includes("\u200d") ? visibleWidthByGrapheme(str) : nativeVisibleWidth(str, getDefaultTabWidth());
|
|
108
146
|
}
|
|
109
147
|
}
|
|
110
148
|
return str.length;
|