@oh-my-pi/pi-tui 15.4.3 → 15.5.1

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
+ ## [15.5.0] - 2026-05-26
6
+
7
+ ### Fixed
8
+
9
+ - Fixed `@` file mention autocomplete stalling for seconds when the query references something outside the project root (e.g. `@../`, `@~/`, `@/abs/`). `CombinedAutocompleteProvider` now short-circuits to plain immediate-directory prefix listing in those cases instead of dispatching a recursive `fuzzyFind` walk over a sibling directory full of unrelated projects. Inside-cwd queries keep the existing fuzzy-then-prefix behavior. ([#1395](https://github.com/can1357/oh-my-pi/issues/1395))
10
+ - Gated the Hangul Compatibility Jamo width correction (U+3131..U+318E → 1 cell, originally landed in 15.0.1 for the IME / hardware-cursor displacement bug) behind `process.platform === "darwin"` in the TS path and `cfg!(target_os = "macos")` in the `pi-natives` Rust path. macOS terminals (Ghostty / Terminal.app / iTerm2) render jamo as 1 cell despite UAX#11 classifying them as Wide, but WezTerm and most Linux terminals honor UAX#11 and render them as 2 cells. The unconditional correction therefore desynced the TUI's column bookkeeping from the terminal's actual rendering off-darwin, producing corrupted layout and broken Korean input on Linux. On non-darwin the helpers now defer entirely to `Bun.stringWidth` / `UnicodeWidthStr` (also a small perf win on the multi-char-grapheme path). ([#1410](https://github.com/can1357/oh-my-pi/issues/1410))
11
+
5
12
  ## [15.4.0] - 2026-05-26
6
13
 
7
14
  ### Fixed
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.3",
4
+ "version": "15.5.1",
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": "15.4.3",
41
- "@oh-my-pi/pi-utils": "15.4.3",
40
+ "@oh-my-pi/pi-natives": "15.5.1",
41
+ "@oh-my-pi/pi-utils": "15.5.1",
42
42
  "lru-cache": "11.3.6",
43
43
  "marked": "^18.0.3"
44
44
  },
@@ -237,6 +237,17 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
237
237
  const atPrefix = this.#extractAtPrefix(textBeforeCursor);
238
238
  if (atPrefix) {
239
239
  const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);
240
+ // Recursive fuzzy walks rooted outside the project (e.g. `@../`,
241
+ // `@~/`, `@/abs`) can be huge — a parent dir full of sibling
242
+ // projects blows past several seconds of latency. Outside cwd,
243
+ // fall back to plain prefix listing of the immediate directory
244
+ // (matches Claude Code's behavior). Inside cwd we keep the
245
+ // fuzzy-then-prefix flow.
246
+ if (rawPrefix.length > 0 && this.#isOutsideCwd(rawPrefix)) {
247
+ const items = await this.#getFileSuggestions(atPrefix);
248
+ if (items.length === 0) return null;
249
+ return { items, prefix: atPrefix };
250
+ }
240
251
  const suggestions =
241
252
  rawPrefix.length > 0
242
253
  ? await this.#getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix })
@@ -479,6 +490,28 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
479
490
  return filePath;
480
491
  }
481
492
 
493
+ // Resolve `rawPrefix` lexically (no I/O) and report whether it points
494
+ // somewhere outside `this.#basePath`. Used to skip recursive fuzzy walks
495
+ // rooted at parent / absolute / home paths — those routinely include
496
+ // thousands of unrelated files and stall the UI for seconds.
497
+ #isOutsideCwd(rawPrefix: string): boolean {
498
+ if (rawPrefix.length === 0) return false;
499
+ let target: string;
500
+ if (rawPrefix.startsWith("~")) {
501
+ target = this.#expandHomePath(rawPrefix);
502
+ } else if (path.isAbsolute(rawPrefix)) {
503
+ target = rawPrefix;
504
+ } else {
505
+ target = path.resolve(this.#basePath, rawPrefix);
506
+ }
507
+ const rel = path.relative(this.#basePath, target);
508
+ if (rel === "" || rel === ".") return false;
509
+ if (path.isAbsolute(rel)) return true;
510
+ const firstSep = rel.indexOf(path.sep);
511
+ const head = firstSep === -1 ? rel : rel.slice(0, firstSep);
512
+ return head === "..";
513
+ }
514
+
482
515
  async #resolveScopedFuzzyQuery(
483
516
  rawQuery: string,
484
517
  ): Promise<{ baseDir: string; query: string; displayBase: string } | null> {
@@ -67,6 +67,18 @@ function isCompleteSequence(data: string): "complete" | "incomplete" | "not-esca
67
67
  return afterEsc.length >= 2 ? "complete" : "incomplete";
68
68
  }
69
69
 
70
+ // ESC-prefixed sequences (terminals with metaSendsEscape):
71
+ // Only when the inner ESC starts a CSI ('[') or SS3 ('O') sequence.
72
+ // Bare double-ESC (e.g. \x1b\x1bX) remains complete to avoid 10ms timeout lag.
73
+ if (afterEsc.startsWith(ESC)) {
74
+ const inner = data.slice(1);
75
+ const third = inner.charCodeAt(1);
76
+ if (third === 0x5b || third === 0x4f) {
77
+ return isCompleteSequence(inner);
78
+ }
79
+ return "complete";
80
+ }
81
+
70
82
  // Meta key sequences: ESC followed by a single character
71
83
  if (afterEsc.length === 1) {
72
84
  return "complete";
package/src/terminal.ts CHANGED
@@ -128,7 +128,7 @@ export class ProcessTerminal implements Terminal {
128
128
  #osc11QueryQueued = false;
129
129
  #osc11ResponseBuffer = "";
130
130
  #privateCsiResponseBuffer = "";
131
- #pendingDa1Sentinels = 0;
131
+ #da1SentinelOwners: ("keyboard" | "osc11")[] = [];
132
132
  #osc11PollTimer?: Timer;
133
133
  #mode2031DebounceTimer?: Timer;
134
134
  #progressTimer?: ReturnType<typeof setInterval>;
@@ -293,7 +293,7 @@ export class ProcessTerminal implements Terminal {
293
293
  // events that would otherwise leak into the prompt as keystrokes. See #1238.
294
294
  if (
295
295
  this.#privateCsiResponseBuffer ||
296
- (privateCsiPartialPattern.test(sequence) && this.#pendingDa1Sentinels > 0)
296
+ (privateCsiPartialPattern.test(sequence) && this.#da1SentinelOwners.length > 0)
297
297
  ) {
298
298
  if (this.#privateCsiResponseBuffer && sequence.startsWith("\x1b")) {
299
299
  // New escape arrived mid-reassembly — abandon partial and re-process the new sequence.
@@ -324,41 +324,60 @@ export class ProcessTerminal implements Terminal {
324
324
  }
325
325
  }
326
326
 
327
- // Check for Kitty protocol response (only if not already enabled)
328
- if (!this.#kittyProtocolActive) {
329
- const match = sequence.match(kittyResponsePattern);
330
- if (match) {
331
- if (this.#modifyOtherKeysTimeout) {
327
+ // DA1 response: swallow our sentinel reply regardless of whether OSC 11
328
+ // already succeeded. Other terminal probes should never see these replies.
329
+ if (da1ResponsePattern.test(sequence) && this.#da1SentinelOwners.length > 0) {
330
+ const owner = this.#da1SentinelOwners.shift()!;
331
+ if (owner === "osc11") {
332
+ if (this.#osc11Pending) {
333
+ // DA1 arrived before the OSC 11 reply: terminal does not support OSC 11.
334
+ this.#osc11Pending = false;
335
+ this.#osc11ResponseBuffer = "";
336
+ }
337
+ // Start a queued OSC 11 query once the prior cycle is fully drained.
338
+ if (
339
+ this.#osc11QueryQueued &&
340
+ !this.#osc11Pending &&
341
+ !this.#da1SentinelOwners.includes("osc11") &&
342
+ !this.#dead
343
+ ) {
344
+ this.#osc11QueryQueued = false;
345
+ this.#startOsc11Query();
346
+ }
347
+ } else {
348
+ // Keyboard probe sentinel: kitty reply never arrived → fall back to modifyOtherKeys.
349
+ if (!this.#kittyProtocolActive && !this.#modifyOtherKeysActive && this.#modifyOtherKeysTimeout) {
332
350
  clearTimeout(this.#modifyOtherKeysTimeout);
333
351
  this.#modifyOtherKeysTimeout = undefined;
352
+ this.#safeWrite("\x1b[>4;2m");
353
+ this.#modifyOtherKeysActive = true;
334
354
  }
335
- this.#kittyProtocolActive = true;
336
- setKittyProtocolActive(true);
337
-
338
- // Enable Kitty keyboard protocol (push flags)
339
- // Flag 1 = disambiguate escape codes
340
- // Flag 2 = report event types (press/repeat/release)
341
- // Flag 4 = report alternate keys
342
- this.#safeWrite("\x1b[>7u");
343
- return; // Don't forward protocol response to TUI
344
355
  }
356
+ return;
345
357
  }
346
358
 
347
- // DA1 response: swallow our sentinel reply regardless of whether OSC 11
348
- // already succeeded. Other terminal probes should never see these replies.
349
- if (da1ResponsePattern.test(sequence) && this.#pendingDa1Sentinels > 0) {
350
- this.#pendingDa1Sentinels--;
351
- if (this.#osc11Pending) {
352
- // DA1 arrived before OSC 11 response: terminal does not support
353
- // OSC 11. Clear the pending state without starting a queued query
354
- // (queued query is started below, after sentinel is consumed).
355
- this.#osc11Pending = false;
356
- this.#osc11ResponseBuffer = "";
359
+ const match = sequence.match(kittyResponsePattern);
360
+ if (match && !this.#modifyOtherKeysActive) {
361
+ if (this.#modifyOtherKeysTimeout) {
362
+ clearTimeout(this.#modifyOtherKeysTimeout);
363
+ this.#modifyOtherKeysTimeout = undefined;
357
364
  }
358
- // Now that this DA1 cycle is complete, start any queued query.
359
- if (this.#osc11QueryQueued && !this.#dead) {
360
- this.#osc11QueryQueued = false;
361
- this.#startOsc11Query();
365
+ // Any reply to `\x1b[?u` means the terminal speaks the kitty keyboard
366
+ // protocol. The reported flag value is the *current* stack-top — fresh
367
+ // terminals report 0 — so support is implied by the reply itself, not by
368
+ // the flag value. Pick the level we want; `\x1b[>Nu` pushes one frame
369
+ // that shutdown's single `\x1b[<u` pop balances.
370
+ const reportedFlags = parseInt(match[1]!, 10);
371
+ this.#kittyProtocolActive = true;
372
+ setKittyProtocolActive(true);
373
+ if (reportedFlags >= 3) {
374
+ // Already enriched (Ghostty/foot may keep flags from a parent app).
375
+ // Push level-2 to lock in event reporting.
376
+ this.#safeWrite("\x1b[>7u");
377
+ } else {
378
+ // Level 1 (disambiguate escape codes) — enough for Shift+Enter
379
+ // without the modifyOtherKeys fallback that caused regression #3259.
380
+ this.#safeWrite("\x1b[>1u");
362
381
  }
363
382
  return;
364
383
  }
@@ -425,7 +444,7 @@ export class ProcessTerminal implements Terminal {
425
444
  // consumed yet. Starting a new query while a DA1 is outstanding would
426
445
  // increment the sentinel counter, and the old DA1 arrival would then
427
446
  // prematurely clear the new query's pending state.
428
- if (this.#osc11Pending || this.#pendingDa1Sentinels > 0) {
447
+ if (this.#osc11Pending || this.#da1SentinelOwners.includes("osc11")) {
429
448
  this.#osc11QueryQueued = true;
430
449
  return;
431
450
  }
@@ -435,7 +454,7 @@ export class ProcessTerminal implements Terminal {
435
454
  #startOsc11Query(): void {
436
455
  this.#osc11Pending = true;
437
456
  this.#osc11ResponseBuffer = "";
438
- this.#pendingDa1Sentinels++;
457
+ this.#da1SentinelOwners.push("osc11");
439
458
  this.#safeWrite("\x1b]11;?\x07"); // OSC 11 query (BEL terminated)
440
459
  this.#safeWrite("\x1b[c"); // DA1 sentinel
441
460
  }
@@ -498,7 +517,11 @@ export class ProcessTerminal implements Terminal {
498
517
  #queryAndEnableKittyProtocol(): void {
499
518
  this.#setupStdinBuffer();
500
519
  process.stdin.on("data", this.#stdinDataHandler!);
501
- this.#safeWrite("\x1b[?u");
520
+ // Progressive enhancement query: CSI ?u asks the terminal for its current
521
+ // kitty keyboard flags (no side effect on the stack); the DA1 sentinel
522
+ // guarantees a reply even from terminals that ignore CSI ?u.
523
+ this.#da1SentinelOwners.push("keyboard");
524
+ this.#safeWrite("\x1b[?u\x1b[c");
502
525
  this.#modifyOtherKeysTimeout = setTimeout(() => {
503
526
  this.#modifyOtherKeysTimeout = undefined;
504
527
  if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) {
@@ -576,7 +599,7 @@ export class ProcessTerminal implements Terminal {
576
599
  this.#osc11QueryQueued = false;
577
600
  this.#osc11ResponseBuffer = "";
578
601
  this.#privateCsiResponseBuffer = "";
579
- this.#pendingDa1Sentinels = 0;
602
+ this.#da1SentinelOwners.length = 0;
580
603
 
581
604
  // Disable Kitty keyboard protocol if not already done by drainInput()
582
605
  if (this.#kittyProtocolActive) {
package/src/utils.ts CHANGED
@@ -101,6 +101,7 @@ export function visibleWidthRaw(str: string): number {
101
101
  const tabWidth = getDefaultTabWidth();
102
102
  let isPureAscii = true;
103
103
  let jamoOvercount = 0;
104
+ const isMacOS = process.platform === "darwin";
104
105
  for (let i = 0; i < str.length; i++) {
105
106
  const code = str.charCodeAt(i);
106
107
  if (code === 9) {
@@ -108,18 +109,9 @@ export function visibleWidthRaw(str: string): number {
108
109
  } else if (code < 0x20 || code > 0x7e) {
109
110
  isPureAscii = false;
110
111
  // Hangul Compatibility Jamo (U+3131..U+318E) is EAW=W per UAX#11,
111
- // so `Bun.stringWidth` returns 2 for each but every macOS
112
- // terminal we ship to (Ghostty, Terminal.app, iTerm2) renders
113
- // them as a single cell in monospace fonts. Without this
114
- // correction every jamo a Korean IME emits during composition
115
- // adds 1 cell of drift to `#extractCursorPosition`, displacing
116
- // the hardware cursor (and therefore the IME candidate window)
117
- // `N_jamo` cells past the visible glyph. Hangul Syllables
118
- // (U+AC00..U+D7A3, e.g. `안`) are correctly 2 cells in both Bun
119
- // and the terminal — leave those alone. The Halfwidth Hangul
120
- // block (U+FFA0..U+FFDC) is already Narrow in Bun, so no
121
- // correction needed there.
122
- if (code >= 0x3131 && code <= 0x318e) {
112
+ // but macOS terminals render them as 1 cell. WezTerm and others
113
+ // follow UAX#11 at 2 cells. Only correct on macOS.
114
+ if (isMacOS && code >= 0x3131 && code <= 0x318e) {
123
115
  jamoOvercount++;
124
116
  }
125
117
  }