fix(tui): detect Apple Terminal Shift+Enter
This commit is contained in:
@@ -6,6 +6,12 @@ Pi uses the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-p
|
|||||||
|
|
||||||
Work out of the box.
|
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
|
## Ghostty
|
||||||
|
|
||||||
Add to your Ghostty config (`~/Library/Application Support/com.mitchellh.ghostty/config` on macOS, `~/.config/ghostty/config` on Linux):
|
Add to your Ghostty config (`~/Library/Application Support/com.mitchellh.ghostty/config` on macOS, `~/.config/ghostty/config` on Linux):
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.75.5] - 2026-05-23
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
BIN
packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node
Executable file
BIN
packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node
Executable file
Binary file not shown.
BIN
packages/tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node
Executable file
BIN
packages/tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node
Executable file
Binary file not shown.
70
packages/tui/native/darwin/src/darwin-modifiers.c
Normal file
70
packages/tui/native/darwin/src/darwin-modifiers.c
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
#include <CoreGraphics/CoreGraphics.h>
|
||||||
|
#include <dlfcn.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#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;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
"native/win32/prebuilds/**/*.node",
|
"native/win32/prebuilds/**/*.node",
|
||||||
|
"native/darwin/prebuilds/**/*.node",
|
||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
60
packages/tui/src/native-modifiers.ts
Normal file
60
packages/tui/src/native-modifiers.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { setKittyProtocolActive } from "./keys.ts";
|
import { setKittyProtocolActive } from "./keys.ts";
|
||||||
|
import { isNativeModifierPressed } from "./native-modifiers.ts";
|
||||||
import { StdinBuffer } from "./stdin-buffer.ts";
|
import { StdinBuffer } from "./stdin-buffer.ts";
|
||||||
|
|
||||||
const cjsRequire = createRequire(import.meta.url);
|
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_KEEPALIVE_MS = 1000;
|
||||||
const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
|
const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
|
||||||
const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\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
|
* Minimal terminal interface for TUI
|
||||||
@@ -159,7 +170,13 @@ export class ProcessTerminal implements Terminal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.inputHandler) {
|
if (this.inputHandler) {
|
||||||
this.inputHandler(sequence);
|
const isAppleTerminal = sequence === "\r" && isAppleTerminalSession();
|
||||||
|
const input = normalizeAppleTerminalInput(
|
||||||
|
sequence,
|
||||||
|
isAppleTerminal,
|
||||||
|
isAppleTerminal && isNativeModifierPressed("shift"),
|
||||||
|
);
|
||||||
|
this.inputHandler(input);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,45 @@
|
|||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
import { describe, it } from "node:test";
|
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", () => {
|
describe("ProcessTerminal dimensions", () => {
|
||||||
it("falls back to COLUMNS and LINES before default dimensions", () => {
|
it("falls back to COLUMNS and LINES before default dimensions", () => {
|
||||||
|
|||||||
@@ -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 "$OUTPUT_DIR/$platform/node_modules/@mariozechner/"
|
||||||
cp -r ../../node_modules/@mariozechner/$clipboard_native_package "$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-* ]]; then
|
||||||
if [[ "$platform" == "windows-arm64" ]]; then
|
if [[ "$platform" == "windows-arm64" ]]; then
|
||||||
win32_arch_dir="win32-arm64"
|
win32_arch_dir="win32-arm64"
|
||||||
|
|||||||
Reference in New Issue
Block a user