@oh-my-pi/pi-tui 16.0.7 → 16.0.8

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,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.0.8] - 2026-06-18
6
+
7
+ ### Fixed
8
+
9
+ - Fixed bracketed paste under kitty+tmux leaking `[27;5;106~` escape tails throughout the pasted text (newlines became visible garbage instead of line breaks). tmux's default `extended-keys-format=xterm` re-encodes paste control bytes as `modifyOtherKeys` sequences (`ESC[27;5;<code>~`), which the paste sanitizer did not decode — only the sibling `csi-u` form (`ESC[<code>;5u`) was handled. Both forms are now decoded back to their literal control byte (Ctrl+J → "\n") before control-character stripping, and the decoder is shared by the multi-line editor and the single-line modal input.
10
+
5
11
  ## [16.0.5] - 2026-06-17
6
12
 
7
13
  ### Added
@@ -5,6 +5,13 @@ export type PasteResult = {
5
5
  pasteContent?: string;
6
6
  remaining: string;
7
7
  };
8
+ /**
9
+ * Decode tmux's re-encoded control bytes (both `extended-keys-format` variants) inside a
10
+ * bracketed-paste payload back to their literal byte (e.g. Ctrl+J → "\n"). Leaves the rest of
11
+ * the text untouched. Call before any control-character stripping so newlines/tabs survive
12
+ * instead of leaking the printable escape tail into the buffer.
13
+ */
14
+ export declare function decodeReencodedPasteControls(text: string): string;
8
15
  /**
9
16
  * Handles bracketed paste mode buffering for terminal input components.
10
17
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "16.0.7",
4
+ "version": "16.0.8",
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": "16.0.7",
41
- "@oh-my-pi/pi-utils": "16.0.7",
40
+ "@oh-my-pi/pi-natives": "16.0.8",
41
+ "@oh-my-pi/pi-utils": "16.0.8",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
@@ -3,6 +3,43 @@ const PASTE_END = "\x1b[201~";
3
3
 
4
4
  export type PasteResult = { handled: false } | { handled: true; pasteContent?: string; remaining: string };
5
5
 
6
+ // Some terminals re-encode the control bytes inside a bracketed paste as key-event
7
+ // escape sequences (observed with tmux extended-keys passthrough under kitty). tmux
8
+ // emits one of two formats depending on `extended-keys-format`:
9
+ // - csi-u: ESC [ <codepoint> ; 5 u (Ctrl+J → ESC [ 106 ; 5 u)
10
+ // - xterm: ESC [ 27 ; 5 ; <codepoint> ~ (Ctrl+J → ESC [ 27 ; 5 ; 106 ~)
11
+ // Callers must decode these back to the literal control byte (Ctrl+J → "\n") before
12
+ // stripping control chars; otherwise ESC is dropped and the printable tail
13
+ // ("[106;5u" / "[27;5;106~") leaks into the editor.
14
+ //
15
+ // Only Ctrl+<letter> is decoded (codepoint a-z/A-Z → 0x01..0x1A). That is the set tmux
16
+ // actually re-encodes from paste content in practice — TAB (Ctrl+I), LF (Ctrl+J), CR
17
+ // (Ctrl+M), VT (Ctrl+K), FF (Ctrl+L), … Non-letter Ctrl combos (NUL, ESC, FS-US, DEL)
18
+ // never appear as re-encoded paste bytes, so they are left untouched rather than
19
+ // synthesized into raw control bytes. Callers still strip leftover control characters
20
+ // after decoding (the editor keeps "\n"; the single-line input strips all of them).
21
+ const REENCODED_CTRL_CSI_U = /\x1b\[(\d+);5u/g;
22
+ const REENCODED_CTRL_XTERM = /\x1b\[27;5;(\d+)~/g;
23
+
24
+ function decodeReencodedCtrlByte(match: string, code: string): string {
25
+ const cp = Number(code);
26
+ if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96); // a-z → Ctrl+A..Ctrl+Z
27
+ if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64); // A-Z → Ctrl+A..Ctrl+Z
28
+ return match;
29
+ }
30
+
31
+ /**
32
+ * Decode tmux's re-encoded control bytes (both `extended-keys-format` variants) inside a
33
+ * bracketed-paste payload back to their literal byte (e.g. Ctrl+J → "\n"). Leaves the rest of
34
+ * the text untouched. Call before any control-character stripping so newlines/tabs survive
35
+ * instead of leaking the printable escape tail into the buffer.
36
+ */
37
+ export function decodeReencodedPasteControls(text: string): string {
38
+ return text
39
+ .replace(REENCODED_CTRL_CSI_U, decodeReencodedCtrlByte)
40
+ .replace(REENCODED_CTRL_XTERM, decodeReencodedCtrlByte);
41
+ }
42
+
6
43
  /**
7
44
  * Handles bracketed paste mode buffering for terminal input components.
8
45
  *
@@ -1,6 +1,6 @@
1
1
  import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
2
2
  import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete";
3
- import { BracketedPasteHandler } from "../bracketed-paste";
3
+ import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste";
4
4
  import { getKeybindings, type KeybindingsManager } from "../keybindings";
5
5
  import { extractPrintableText, matchesKey } from "../keys";
6
6
  import { KillRing } from "../kill-ring";
@@ -1843,20 +1843,14 @@ export class Editor implements Component, Focusable {
1843
1843
  });
1844
1844
  }
1845
1845
 
1846
- /** Normalize raw pasted text: decode tmux CSI-u re-encoded control bytes, normalize CRLF and
1846
+ /** Normalize raw pasted text: decode tmux re-encoded control bytes (both extended-keys formats),
1847
+ * normalize CRLF and
1847
1848
  * NFC (macOS NFD filename drag-drops), expand tabs, and strip control characters except newline. */
1848
1849
  #sanitizePastedText(pastedText: string): string {
1849
- // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
1850
- // control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
1851
- // (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
1852
- // per-char filter below preserves newlines instead of stripping ESC and
1853
- // leaking the printable tail (e.g. "[106;5u") into the editor.
1854
- const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
1855
- const cp = Number(code);
1856
- if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96);
1857
- if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64);
1858
- return match;
1859
- });
1850
+ // Decode tmux's re-encoded control bytes (both extended-keys formats) back to
1851
+ // their literal byte so the per-char filter below preserves newlines instead of
1852
+ // stripping ESC and leaking the printable tail into the editor. See the decoder.
1853
+ const decodedText = decodeReencodedPasteControls(pastedText);
1860
1854
 
1861
1855
  // Clean the pasted text. NFC-normalize so macOS Finder drag-drops of
1862
1856
  // Korean filenames (which arrive as NFD: e.g. `ᄒ`+`ᅪ` instead of `화`)
@@ -1,4 +1,4 @@
1
- import { BracketedPasteHandler } from "../bracketed-paste";
1
+ import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste";
2
2
  import { getKeybindings } from "../keybindings";
3
3
  import { extractPrintableText } from "../keys";
4
4
  import { KillRing } from "../kill-ring";
@@ -375,8 +375,12 @@ export class Input implements Component, Focusable {
375
375
  this.#lastAction = null;
376
376
  this.#pushUndo();
377
377
 
378
- // Clean the pasted text — remove newlines and carriage returns, normalize
379
- // tabs, AND normalize Unicode to NFC.
378
+ // Clean the pasted text — decode tmux's re-encoded control bytes (both
379
+ // extended-keys formats, e.g. Ctrl+J "\n") back to literal bytes so the escape
380
+ // tail does not leak in, remove newlines/carriage returns, expand tabs, NFC-normalize,
381
+ // then strip any remaining control bytes. The decoder can synthesize Ctrl+A..Ctrl+Z
382
+ // (0x01..0x1A) from a paste, and a single-line value must hold none of them — newlines
383
+ // are already gone and tabs are already spaces by the time the C0/DEL strip runs.
380
384
  //
381
385
  // NFC normalization rationale: macOS Finder drag-drops file paths in NFD
382
386
  // (Conjoining Jamo, U+1100..U+11FF). `Bun.stringWidth` counts each
@@ -387,9 +391,11 @@ export class Input implements Component, Focusable {
387
391
  // as cursor drift past the visible filename — N×~1.5 cells for a path
388
392
  // with N Korean syllables. NFC normalization at paste time stores the
389
393
  // value in the same form everything else in the codebase assumes.
390
- const cleanText = replaceTabs(pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "")).normalize(
391
- "NFC",
392
- );
394
+ const cleanText = replaceTabs(
395
+ decodeReencodedPasteControls(pastedText).replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, ""),
396
+ )
397
+ .normalize("NFC")
398
+ .replace(/[\x00-\x1F\x7F]/g, "");
393
399
 
394
400
  // Insert at cursor position
395
401
  this.#value = this.#value.slice(0, this.#cursor) + cleanText + this.#value.slice(this.#cursor);