@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 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
@@ -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.5.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.5.15",
41
- "@oh-my-pi/pi-utils": "15.5.15",
42
- "lru-cache": "11.3.6",
43
- "marked": "^18.0.3"
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",
@@ -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(lines, widthChanged, heightChanged, prevViewportTop, height);
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
- if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom())) {
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(nativeViewportAtBottom: boolean | undefined): boolean {
1456
+ #nativeViewportIsScrolled(
1457
+ nativeViewportAtBottom: boolean | undefined,
1458
+ allowUnknownViewportMutation = false,
1459
+ ): boolean {
1425
1460
  return (
1426
1461
  nativeViewportAtBottom === false ||
1427
- (nativeViewportAtBottom === undefined && requiresNativeViewportProofForReplay())
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 || !requiresNativeViewportProofForReplay()))
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;