From c5181a266e20e8f5b4f678754a31bf10d8470fe9 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 23 May 2026 15:09:01 +0200 Subject: [PATCH] fix(tui): detect Apple Terminal Shift+Enter --- packages/coding-agent/docs/terminal-setup.md | 6 ++ packages/tui/CHANGELOG.md | 4 + .../darwin-arm64/darwin-modifiers.node | Bin 0 -> 50200 bytes .../darwin-x64/darwin-modifiers.node | Bin 0 -> 12776 bytes .../tui/native/darwin/src/darwin-modifiers.c | 70 ++++++++++++++++++ packages/tui/package.json | 1 + packages/tui/src/native-modifiers.ts | 60 +++++++++++++++ packages/tui/src/terminal.ts | 19 ++++- packages/tui/test/terminal.test.ts | 41 +++++++++- scripts/build-binaries.sh | 6 +- 10 files changed, 204 insertions(+), 3 deletions(-) create mode 100755 packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node create mode 100755 packages/tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node create mode 100644 packages/tui/native/darwin/src/darwin-modifiers.c create mode 100644 packages/tui/src/native-modifiers.ts diff --git a/packages/coding-agent/docs/terminal-setup.md b/packages/coding-agent/docs/terminal-setup.md index 74b0df2a9..5bf9f2b26 100644 --- a/packages/coding-agent/docs/terminal-setup.md +++ b/packages/coding-agent/docs/terminal-setup.md @@ -6,6 +6,12 @@ Pi uses the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-p Work out of the box. +## Apple Terminal + +Pi enables enhanced key reporting when available. If Terminal.app still sends plain Return for `Shift+Enter`, pi uses a local macOS modifier fallback to treat that Return as `Shift+Enter`. + +This fallback only works when pi runs on the same Mac as Terminal.app. It cannot detect the local keyboard over remote SSH. + ## Ghostty Add to your Ghostty config (`~/Library/Application Support/com.mitchellh.ghostty/config` on macOS, `~/.config/ghostty/config` on Linux): diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 2c5cebb66..f132efd19 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `Shift+Enter` in Apple Terminal by detecting local macOS modifier state when Terminal.app sends plain Return. + ## [0.75.5] - 2026-05-23 ### Changed diff --git a/packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node b/packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node new file mode 100755 index 0000000000000000000000000000000000000000..4614842699ca81944f15dff61a999878f841be28 GIT binary patch literal 50200 zcmeI5U2I%e5y$85`YW~Lrr05F6BOg3$_c@Cp%q4ImFqZm95rc@_0pK9llAVkeertN zxpz0Io1(6iwj!wl-YR@l6|jAXh^;s(bts?;a8(h3M1Y1IenPzvrxs?-Ul8)TN5 z`{Aw~S%?RuBK}93oilUh&YAh0d++X({f8S@{#iw&5swO%TUqv&57*HJNwS~KgKN^@!ix^Yx-W9AJTYNlPr`&xq ztQ*-DKGO5^Y)h?sUv}?zhLx}G~e^F{X6vq)|t-nI0O#eImbm2773;=RIic|HeNB$rcD=>#8*PR-pTcFGuP z-evY$5o@Hm(~1lkhm+Qz-Q1qEj2%{Fc)*O>O?|$<`60uy&1AxEZd($;d1c5xtt|dN zM7y|cjhfT8)f_i_Mcgf$wl&2@*pjP=?qZR8PfxiVHhH%2mGK!CS-;Ah8l{2%a1?^t z#rgf(*!4Yuv4tbSv4vMCdo8GqUk}naGaUV_-jJ=Q%+oodlk>jsr0aW@eT8z$zZswp zOX~C+dVs=z^JA_zWch4Iqb;{*-@cQwGr`*N0O#S?*z{Cj>!lf@d*3S8^IY=iHjb6` z%fXuQoUE~4IMU8FZ5x~4-!{fIW~YMcQsEWt;zugTz7k$5<$BA;7LID!d9L>w@5}e! zXoaTdMP}Dg-l_k-JDvK6xz@k?wPjr2Ao~{Uec$!NuB%ej8>&gb=!uUDT4ZN6~E+Nj+!_|57} zs9x)OL=RGUmgCI#PfU+ZOw4Wi{e`(>RWoxNwN>xs_)Pq@-<>!+xAOyDTkWp#4Ig&$ zeyQ!}@2>t<=y=nFleh88iO_FX=Pb$H_K!k%t(FUwaSdOkT!>#2wQB>p5T9G^ci86F zL#T$CZKSpDUM~O99nwSXjlxgaKRvrn&-48{x{1$2WUE&3-escj!jVoszYadf?PDLQ z_Ax$J=X^Q$$-NxECZOjjuJFDshg;?DPR%-y||y}IMqV=WT@aZFvlP*^9A0AE`- z&-0Dm{2I@{%=1KK*i=!=h@=eFmrg`e{K&da+eoPdKjbkrY$f?iIyFM3y*n8*`%J^y zYZ1aU6=o(+2-&knd!bPW82Z9NU{r#*IjV>;bbcMbYHYP$Us! zok&^9I3(100@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=9 z00@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p z2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?x zfB*=900@8p2!H?xfB*=900@ANBZsVR3v3^gjKODVPC|I zi+>IKV{vfXEwc|Svx97xu*mkZ zMLA2cubx)yXGL5~$UC5xc-*t_sNHlL%Cv=<*=I7mVau>>BSuO&al@8*NY0~Z-1uYF z6t~;JE}uy)`?&u=H3hh{fWsoe>pk9OtBGE|S|6Y)&;Bg=XO zGG@@^PhL1U^1<%s>!1A1`InA%9NVz_;Pm~Iubo=;mjh*6-*{kV;;}O?^d!%}(>GP} z+Qo~5fjvE?g-6dl`p6S+{PL-;V_SawzrKF7Q2&F;@4ocRT@~lQ@~bPa>aUNS&7VGX z_Cm?@{!{c+$H0HPzxl}@zB)NwHF3Q4*%wj8~?(zN$ D%;!fI literal 0 HcmV?d00001 diff --git a/packages/tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node b/packages/tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node new file mode 100755 index 0000000000000000000000000000000000000000..0c82b391329a43f4d241183f915217b51b8380f5 GIT binary patch literal 12776 zcmeHOZ)jUp6u;S8ZQa(SW0TEoEDWZbNV9B;Q$Lt&%l3lFoTU3By4}4bFYRM#UibcV z77>aygGUHdurLHc{M0W(aZ@)GNw-O>A9Mr7;=oVpDs_SkCL6N&JMT}IWb}*QlzZU3 zbI*C_o_p@^+?N;HlW*_bxceYufu)SG4saJZ*3MWNl4vb>HozF?y@?~ej;Z+)YF3%2 zBLwrAJUBO%*UWpqt?|al8FXxkD@kv2hwinFjWnv98>W>t7EG=2{=lY)R=R@ux~H-Z zFWv*5!HviJmExeH!`2$l0SET&3jrAOQgto&<(D%|T`l!_{;l!06WAcz70lN?wK+D7 zoLi++YAMgvQbBX{=6ZbY#XIf=AiXI+&+ZGV>lrZTd*Z!uzWc@f8MnHAJr|L5PKa?N zoKkb%r!|yy(RhA1nnd$`BK#Y5%4-hn=H+`qhP|;Ls73el^7qVnQRc?*KvpaAK~rBe z-Wo3~#c#}mj*oXF9S_d)vMKw{_vg0;^NGANFwz_2IX4?Fv`-1lc`CX8g~Xnu|7>mZ zocw)y`WyicBH;}keG_9Njtefdb_b+4#ks!|3avvZKYlxOw*35oj}jZcIQQ`CPI`XAU}q1(hY@La z+4eZ@g}^>7$Dkifv7DURKSXI_ERq>EOl2UFQnR|O4@dUu@_=$m(@z+Y-I}iK)8)Z_ zHD~k`e0$`Oq8qAKG9vLN4{sq_XJ^AYjHR&c0E<|L9x19>Vt2`&T|N0>Xzs*EAt>Jo z56`WhPWb*CIw;w{#1F?0#d|Z-=GE4r2QqVR8oIeMaMeb;(G|zbooYb{Gzv&tF)EZ5%qoS zbb^i%evqu>2k-2M^bRTN9Irb%tUg(qyWdICFt&i*Uf8Tr)eO*X9l4^G3f~*pZkFewscv z`ls1BWp0%!n0+Cq4GhSoJd>W8F*lG}GIgy;sbQ^_zS7cB*^b$V-Z%Pj1?=bP2Eq9T_V!EQ2w3~M@{8|x`w zP@}}5V)XqX21)F{`)S-r^x3u?#aIO<0=|LnWo?10N80IJ2@(hh1Ox&C0fB%(Kp-Fx z5C{ka1Ofs9fq+0jARrJB2nYlO0s;Ynz<(Bj5dLLAwBD0jJn34+RUjY`5C{ka1Ofs9 zfq+0jARrJB2nYlO0s?{m0|MQfScdkoGRCO|Ue2B1x&9Np&yw{#d+8(_i=o*y?=;)< zEc(yU96v_>I_r+{-TM+lO3BP&~! zFNpj8?+MmHhw9A5kUi<@Zo?nO)Cr553;N3~`tcT>dIsrt3A8-*MN^-6qb +#include +#include +#include +#include + +#define NAPI_AUTO_LENGTH ((size_t)-1) + +typedef void* napi_env; +typedef void* napi_value; +typedef void* napi_callback_info; +typedef napi_value (*napi_callback)(napi_env, napi_callback_info); +typedef int (*napi_create_function_fn)(napi_env, const char*, size_t, napi_callback, void*, napi_value*); +typedef int (*napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value); +typedef int (*napi_get_boolean_fn)(napi_env, bool, napi_value*); +typedef int (*napi_get_cb_info_fn)(napi_env, napi_callback_info, size_t*, napi_value*, napi_value*, void**); +typedef int (*napi_get_value_string_utf8_fn)(napi_env, napi_value, char*, size_t, size_t*); + +static void* node_symbol(const char* name) { + return dlsym(RTLD_DEFAULT, name); +} + +static CGEventFlags modifier_mask_for_name(const char* name) { + if (strcmp(name, "shift") == 0) return kCGEventFlagMaskShift; + if (strcmp(name, "command") == 0) return kCGEventFlagMaskCommand; + if (strcmp(name, "control") == 0) return kCGEventFlagMaskControl; + if (strcmp(name, "option") == 0) return kCGEventFlagMaskAlternate; + return 0; +} + +static napi_value is_modifier_pressed(napi_env env, napi_callback_info info) { + napi_get_cb_info_fn napi_get_cb_info = (napi_get_cb_info_fn)node_symbol("napi_get_cb_info"); + napi_get_value_string_utf8_fn napi_get_value_string_utf8 = (napi_get_value_string_utf8_fn)node_symbol("napi_get_value_string_utf8"); + napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean"); + + bool pressed = false; + if (napi_get_cb_info && napi_get_value_string_utf8) { + size_t argc = 1; + napi_value args[1] = {0}; + if (napi_get_cb_info(env, info, &argc, args, 0, 0) == 0 && argc >= 1 && args[0]) { + char name[16] = {0}; + size_t copied = 0; + if (napi_get_value_string_utf8(env, args[0], name, sizeof(name), &copied) == 0) { + CGEventFlags mask = modifier_mask_for_name(name); + if (mask != 0) { + CGEventFlags flags = CGEventSourceFlagsState(kCGEventSourceStateCombinedSessionState); + pressed = (flags & mask) != 0; + } + } + } + } + + napi_value result = 0; + if (napi_get_boolean) napi_get_boolean(env, pressed, &result); + return result; +} + +__attribute__((visibility("default"))) napi_value napi_register_module_v1(napi_env env, napi_value exports) { + napi_create_function_fn napi_create_function = (napi_create_function_fn)node_symbol("napi_create_function"); + napi_set_named_property_fn napi_set_named_property = (napi_set_named_property_fn)node_symbol("napi_set_named_property"); + + napi_value fn = 0; + if (napi_create_function && + napi_set_named_property && + napi_create_function(env, "isModifierPressed", NAPI_AUTO_LENGTH, is_modifier_pressed, 0, &fn) == 0) { + napi_set_named_property(env, exports, "isModifierPressed", fn); + } + + return exports; +} diff --git a/packages/tui/package.json b/packages/tui/package.json index 7d1b7f7a4..848b16b92 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -13,6 +13,7 @@ "files": [ "dist/**/*", "native/win32/prebuilds/**/*.node", + "native/darwin/prebuilds/**/*.node", "README.md" ], "keywords": [ diff --git a/packages/tui/src/native-modifiers.ts b/packages/tui/src/native-modifiers.ts new file mode 100644 index 000000000..3da0aeba8 --- /dev/null +++ b/packages/tui/src/native-modifiers.ts @@ -0,0 +1,60 @@ +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const cjsRequire = createRequire(import.meta.url); + +export type ModifierKey = "shift" | "command" | "control" | "option"; + +type NativeModifiersHelper = { + isModifierPressed: (name: ModifierKey) => boolean; +}; + +let nativeModifiersHelper: NativeModifiersHelper | null | undefined; + +function isNativeModifiersHelper(value: unknown): value is NativeModifiersHelper { + if (typeof value !== "object" || value === null) return false; + const candidate = (value as { isModifierPressed?: unknown }).isModifierPressed; + return typeof candidate === "function"; +} + +function loadNativeModifiersHelper(): NativeModifiersHelper | undefined { + if (nativeModifiersHelper !== undefined) return nativeModifiersHelper ?? undefined; + nativeModifiersHelper = null; + if (process.platform !== "darwin") return undefined; + const arch = process.arch; + if (arch !== "x64" && arch !== "arm64") return undefined; + + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const nativePath = path.join("native", "darwin", "prebuilds", `darwin-${arch}`, "darwin-modifiers.node"); + const candidates = [ + path.join(moduleDir, "..", nativePath), + path.join(moduleDir, nativePath), + path.join(path.dirname(process.execPath), nativePath), + ]; + + for (const modulePath of candidates) { + try { + const helper = cjsRequire(modulePath) as unknown; + if (isNativeModifiersHelper(helper)) { + nativeModifiersHelper = helper; + return helper; + } + } catch { + // Try the next possible packaging location. + } + } + + return undefined; +} + +export function isNativeModifierPressed(key: ModifierKey): boolean { + if (process.env.PI_TUI_DISABLE_NATIVE_MODIFIERS === "1") return false; + const helper = loadNativeModifiersHelper(); + if (!helper) return false; + try { + return helper.isModifierPressed(key) === true; + } catch { + return false; + } +} diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 94c56ff61..714f4d9ab 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -3,6 +3,7 @@ import { createRequire } from "node:module"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { setKittyProtocolActive } from "./keys.ts"; +import { isNativeModifierPressed } from "./native-modifiers.ts"; import { StdinBuffer } from "./stdin-buffer.ts"; const cjsRequire = createRequire(import.meta.url); @@ -10,6 +11,16 @@ const cjsRequire = createRequire(import.meta.url); const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000; const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07"; const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07"; +const APPLE_TERMINAL_SHIFT_ENTER_SEQUENCE = "\x1b[13;2u"; + +export function isAppleTerminalSession(): boolean { + return process.platform === "darwin" && process.env.TERM_PROGRAM === "Apple_Terminal"; +} + +export function normalizeAppleTerminalInput(data: string, isAppleTerminal: boolean, isShiftPressed: boolean): string { + if (isAppleTerminal && data === "\r" && isShiftPressed) return APPLE_TERMINAL_SHIFT_ENTER_SEQUENCE; + return data; +} /** * Minimal terminal interface for TUI @@ -159,7 +170,13 @@ export class ProcessTerminal implements Terminal { } if (this.inputHandler) { - this.inputHandler(sequence); + const isAppleTerminal = sequence === "\r" && isAppleTerminalSession(); + const input = normalizeAppleTerminalInput( + sequence, + isAppleTerminal, + isAppleTerminal && isNativeModifierPressed("shift"), + ); + this.inputHandler(input); } }); diff --git a/packages/tui/test/terminal.test.ts b/packages/tui/test/terminal.test.ts index ab2027dfa..cf9802514 100644 --- a/packages/tui/test/terminal.test.ts +++ b/packages/tui/test/terminal.test.ts @@ -1,6 +1,45 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { ProcessTerminal } from "../src/terminal.ts"; +import { isNativeModifierPressed } from "../src/native-modifiers.ts"; +import { normalizeAppleTerminalInput, ProcessTerminal } from "../src/terminal.ts"; + +function withEnv(name: string, value: string | undefined, fn: () => void): void { + const previous = process.env[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + try { + fn(); + } finally { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + } +} + +describe("normalizeAppleTerminalInput", () => { + it("rewrites Apple Terminal Return to CSI-u Shift+Enter when Shift is pressed", () => { + assert.equal(normalizeAppleTerminalInput("\r", true, true), "\x1b[13;2u"); + }); + + it("leaves Apple Terminal Return unchanged when Shift is not pressed", () => { + assert.equal(normalizeAppleTerminalInput("\r", true, false), "\r"); + }); + + it("leaves non-Apple Terminal Return unchanged when Shift is pressed", () => { + assert.equal(normalizeAppleTerminalInput("\r", false, true), "\r"); + }); + + it("leaves non-Return input unchanged", () => { + assert.equal(normalizeAppleTerminalInput("\x1b[13;2u", true, true), "\x1b[13;2u"); + assert.equal(normalizeAppleTerminalInput("a", true, true), "a"); + }); + + it("treats native helper failure as Shift not pressed", () => { + withEnv("PI_TUI_DISABLE_NATIVE_MODIFIERS", "1", () => { + assert.equal(isNativeModifierPressed("shift"), false); + assert.equal(normalizeAppleTerminalInput("\r", true, isNativeModifierPressed("shift")), "\r"); + }); + }); +}); describe("ProcessTerminal dimensions", () => { it("falls back to COLUMNS and LINES before default dimensions", () => { diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index 3fed7647e..628097a73 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -178,7 +178,11 @@ for platform in "${PLATFORMS[@]}"; do cp -r ../../node_modules/@mariozechner/clipboard "$OUTPUT_DIR/$platform/node_modules/@mariozechner/" cp -r ../../node_modules/@mariozechner/$clipboard_native_package "$OUTPUT_DIR/$platform/node_modules/@mariozechner/" - # Copy Windows VT input native helper next to compiled Windows binaries. + # Copy terminal input native helpers next to compiled binaries. + if [[ "$platform" == darwin-* ]]; then + mkdir -p "$OUTPUT_DIR/$platform/native/darwin/prebuilds/$platform" + cp ../tui/native/darwin/prebuilds/$platform/darwin-modifiers.node "$OUTPUT_DIR/$platform/native/darwin/prebuilds/$platform/" + fi if [[ "$platform" == windows-* ]]; then if [[ "$platform" == "windows-arm64" ]]; then win32_arch_dir="win32-arm64"