@oh-my-pi/pi-tui 15.2.4 → 15.3.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
@@ -33,6 +33,19 @@
33
33
  ### Breaking Changes
34
34
 
35
35
  - Increased the minimum required Bun version for the TUI package from >=1.3.7 to >=1.3.14
36
+ - Fixed `TerminalInfo.sendNotification` not delivering desktop notifications on macOS. macOS requires per-app notification permission, which terminal emulators (kitty, ghostty, alacritty, …) almost never have, so OSC 9/99 sequences were silently dropped at the OS layer. `sendNotification` now shells out to `alerter` or `terminal-notifier` when either is on `$PATH` (both register their own LSApplication and ship a "Terminal" / `>_` icon). When neither is installed the dispatch is a deliberate no-op + a single `logger.warn` line on the first miss (subsequent dispatches stay silent) so the user can spot the missing binary in `~/.omp/logs/omp.YYYY-MM-DD.log` and `brew install alerter`. Linux/Windows still go through the OSC/Bell path.
37
+ - Fixed `TerminalInfo.formatNotification` losing OSC 9/99 desktop notifications when running inside tmux. The OSC sequence is now wrapped in tmux's DCS passthrough envelope (`\ePtmux;…\e\\` with embedded ESC bytes doubled) when `TMUX` is set, so notifications reach the parent terminal. `set -g allow-passthrough on` is still required on the tmux side for the wrapped sequence to be forwarded. Bell-only terminals are unchanged.
38
+ - Fixed alerter desktop notifications staying on screen indefinitely. `scripts/mac-alerter.sh` previously passed `--timeout 30` (which makes alerter call `removeDeliveredNotification` after 30 s, also purging the Notification Center entry) and forced Alert-style via `--actions "Open"` (persistent until user click). It now ships Banner-style argv (no `--actions`, no `--timeout`): macOS auto-dismisses the toast after ~10 s and archives the entry to Notification Center for later review. Click-to-focus is preserved through `@CONTENTCLICKED` body clicks. NC archival also requires "Show in Notification Center" enabled for Terminal under macOS System Settings → Notifications.
39
+ - Fixed `composeNotificationSubtitle` showing a stale tmux `pane_title` (typically `π: kitty & tmux` or the cwd prefix written before auto-naming runs) instead of the live OMP session name. The OMP-supplied `fallback` is now consulted first for the pane component; the cached tmux pane title is only used when no session name is available. Window name handling is unchanged.
40
+ - Fixed `sendDesktopNotification` always routing through `alerter` / `terminal-notifier` on darwin, even for terminals (ghostty / iTerm2 / wezterm) that surface OSC 9 / OSC 99 as native notifications through their own bundle. The dispatch now prefers the OSC path on darwin when the terminal advertises native macOS notification capability; the fallback only kicks in for kitty / alacritty / vscode / unknown shells whose host app isn't a notification-capable bundle. This unblocks the user-controlled per-app notification settings flow for ghostty / iTerm2 / wezterm — toast style, NC archival, and click-to-focus all attach to the terminal app's own System Settings entry rather than to `com.apple.Terminal` (which `alerter` would post under).
41
+ - Fixed Korean IME composition leaving a growing horizontal gap between typed jamo and the cursor inside the OMP prompt under tmux + ghostty (and other macOS terminals). `Bun.stringWidth` and the underlying UAX#11 East Asian Width tables classify Hangul Compatibility Jamo (U+3131..U+318E — ㄱ ㄴ ㄷ ㄹ ㅁ ㅂ ㅅ ㅇ ㅈ ㅊ ㅋ ㅌ ㅍ ㅎ + filler) as Wide (2 cells), but every macOS terminal we ship to (Ghostty / Terminal.app / iTerm2) actually renders them as a single cell in monospace fonts. `#extractCursorPosition` was computing `col = visibleWidth(beforeMarker)` and feeding the doubled value to `\x1b[(col+1)G`, placing the hardware cursor (and therefore the IME candidate window) `N_jamo` cells past the visible glyph — exactly the gap the user saw growing as they typed. `visibleWidthRaw` now subtracts 1 cell for each Compatibility Jamo character, returning the column count macOS terminals actually use. Hangul Syllables (U+AC00..U+D7A3, e.g. `안`) stay at 2 cells in both Bun and the terminal — unaffected. Other CJK widths (Chinese / Japanese / Halfwidth Hangul) are unchanged. NOTE: the Rust `pi-natives` width tables (used by `sliceWithWidth` / `truncateToWidth` / `wrapTextWithAnsi`) also count Compatibility Jamo as 2 cells; truncation and word-wrap on jamo-heavy lines will still be slightly aggressive. The defect is invisible in normal use because the AI composes Korean as syllables, not jamo, and users type syllables once IME composition completes. A follow-up will reconcile the Rust side.
42
+ - Fixed a brief black-flash flicker in the TUI when streaming long markdown responses inside tmux (especially noticeable in ghostty with multiple panes open). Root cause: when a markdown fence line above the viewport changed between two streaming tokens (e.g. `` ``` `` → `` ```python ``), `#doRender()` would take the `firstChanged < prevViewportTop` branch and emit `\x1b[2J\x1b[H` (full screen clear + cursor home) wrapped in BSU. The BSU envelope can split across PTY reads, leaving tmux briefly displaying a blank pane before the rest of the buffer arrives — multiplied across panes during repaint. The viewport-above branch now calls a new `viewportRefresh()` helper that does cursor-home + per-line `\x1b[2K` + line content (no `\x1b[2J`), so the visible viewport content is repainted without ever clearing the screen. Scrollback above the viewport may briefly show stale rendering, but only of the SAME lines that just changed — invisible during streaming when the user isn't scrolled up. Other full-redraw paths (resize, first render, etc.) keep the hard `fullRender(true)` behavior unchanged.
43
+
44
+ ### Tests
45
+
46
+ - Added `test/no-2k-anywhere.test.ts` — lint guard that scans `packages/tui/src/` for `\x1b[2K` string literals outside comments. The earlier streaming-flicker fix re-introduced the BSU-split flash bug by moving `\x1b[2K`-before-content from `fullRender` to `viewportRefresh` (same anti-pattern in a new location). This test catches that class of regression at CI time so future changes can't silently revive it.
47
+ - Added `test/render-emit-snapshot.test.ts` — four scenario-based byte-snapshot guards (single-line mutation, streaming append, above-viewport mutation triggering `viewportRefresh`, trailing-line clear on shrink). Asserts structural invariants on the EMITTED BYTES from `terminal.write(…)`: no `\x1b[2K`, no `\x1b[2J`, the new content appears, the BSU close `\x1b[?2026l` is present. Catches render-path changes that achieve the right final viewport state via a transient blank frame (which is exactly how the typing-flicker bug slipped past `render-regressions.test.ts`).
48
+ - Added `test/ime-jamo-cursor.test.ts` — six cases asserting the Input component's hardware cursor marker column does not grow at 2× per typed Korean compatibility jamo. Before commit `79e3170c6` typing 14 jamo produced a 14-cell gap between the visible text and the IME candidate window; the test caps the cursor column at `PROMPT_WIDTH + N_jamo` and asserts the per-keystroke delta is at most 1. NOTE: the Rust `pi-natives` `sliceWithWidth` still treats jamo as 2 cells (binary package, follow-up); the test guard accepts a small residual offset but flags the doubling regression.
36
49
 
37
50
  ## [14.9.8] - 2026-05-12
38
51
 
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.2.4",
4
+ "version": "15.3.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.2.4",
41
- "@oh-my-pi/pi-utils": "15.2.4",
40
+ "@oh-my-pi/pi-natives": "15.3.1",
41
+ "@oh-my-pi/pi-utils": "15.3.1",
42
42
  "lru-cache": "11.3.6",
43
43
  "marked": "^18.0.3"
44
44
  },
@@ -1582,8 +1582,14 @@ export class Editor implements Component, Focusable {
1582
1582
  return match;
1583
1583
  });
1584
1584
 
1585
- // Clean the pasted text
1586
- const cleanText = decodedText.replace(/\r\n?/g, "\n");
1585
+ // Clean the pasted text. NFC-normalize so macOS Finder drag-drops of
1586
+ // Korean filenames (which arrive as NFD: e.g. `ᄒ`+`ᅪ` instead of `화`)
1587
+ // land in the buffer as the same precomposed syllables a terminal
1588
+ // renders — without this, cursor column accounting drifts by
1589
+ // `(NFD cells − NFC cells)` and the visible glyph desyncs from the
1590
+ // hardware cursor. Matches the `Input` component's prior fix; this
1591
+ // is the same fix on the real OMP prompt component (`Editor`).
1592
+ const cleanText = decodedText.replace(/\r\n?/g, "\n").normalize("NFC");
1587
1593
 
1588
1594
  // Convert tabs to spaces (4 spaces per tab)
1589
1595
  const tabExpandedText = cleanText.replace(/\t/g, " ");
@@ -357,8 +357,21 @@ export class Input implements Component, Focusable {
357
357
  this.#lastAction = null;
358
358
  this.#pushUndo();
359
359
 
360
- // Clean the pasted text - remove newlines and carriage returns, then normalize tabs.
361
- const cleanText = replaceTabs(pastedText.replace(/[\r\n]/g, ""));
360
+ // Clean the pasted text remove newlines and carriage returns, normalize
361
+ // tabs, AND normalize Unicode to NFC.
362
+ //
363
+ // NFC normalization rationale: macOS Finder drag-drops file paths in NFD
364
+ // (Conjoining Jamo, U+1100..U+11FF). `Bun.stringWidth` counts each
365
+ // conjoining jamo as a separate cell — a Korean syllable like `화` is
366
+ // 1 char and 2 cells in NFC, but 2 chars and 3 cells in NFD (ᄒ=2 cells
367
+ // + ᅪ=1 cell). The terminal renders the NFD sequence as a single
368
+ // combined syllable (2 cells visible), so the width mismatch shows up
369
+ // as cursor drift past the visible filename — N×~1.5 cells for a path
370
+ // with N Korean syllables. NFC normalization at paste time stores the
371
+ // value in the same form everything else in the codebase assumes.
372
+ const cleanText = replaceTabs(pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "")).normalize(
373
+ "NFC",
374
+ );
362
375
 
363
376
  // Insert at cursor position
364
377
  this.#value = this.#value.slice(0, this.#cursor) + cleanText + this.#value.slice(this.#cursor);
package/src/utils.ts CHANGED
@@ -100,18 +100,34 @@ export function visibleWidthRaw(str: string): number {
100
100
  let tabLength = 0;
101
101
  const tabWidth = getDefaultTabWidth();
102
102
  let isPureAscii = true;
103
+ let jamoOvercount = 0;
103
104
  for (let i = 0; i < str.length; i++) {
104
105
  const code = str.charCodeAt(i);
105
106
  if (code === 9) {
106
107
  tabLength += tabWidth;
107
108
  } else if (code < 0x20 || code > 0x7e) {
108
109
  isPureAscii = false;
110
+ // 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) {
123
+ jamoOvercount++;
124
+ }
109
125
  }
110
126
  }
111
127
  if (isPureAscii) {
112
128
  return str.length + tabLength;
113
129
  }
114
- return Bun.stringWidth(str) + tabLength;
130
+ return Bun.stringWidth(str) - jamoOvercount + tabLength;
115
131
  }
116
132
 
117
133
  /**