Compare commits

...
Author SHA1 Message Date
John Choi d644dbdd22 fix: prevent terminal corruption on exit and double word-delete
- Add disableEnhancedKeyboardMode() to signal handler and process.on('exit')
  to prevent terminal staying in modifyOtherKeys/Kitty mode after CLI exits
- Remove duplicate Option+Backspace handler from useInput to fix double
  word-delete when both useHomeEndKeys and useInput fire for \x1b\x7f
2026-04-01 17:34:22 -07:00
John Choi 172d630d72 fix(cli): stabilize option-backspace handling in enhanced stdin 2026-04-01 17:28:46 -07:00
John Choi 860354ec9d fix: add Option+Backspace (word delete) support to CLI input
Many terminals send identical bytes (\x7f) for Backspace and Option+Backspace.
This enables two keyboard protocols to distinguish modified keys:

1. xterm modifyOtherKeys (level 2) — used by iTerm2
   Alt+Backspace → \x1b[27;3;127~

2. Kitty keyboard protocol — used by VSCode terminal (xterm.js)
   Ctrl+Backspace → \x1b[127;5u, Option+Backspace → \x1b[127;3u

A stdin proxy (enhanced-stdin.ts) sits between real stdin and Ink to:
- Intercept Alt/Ctrl+Backspace sequences and emit them as word-delete events
- Translate other modified key sequences (Ctrl+C, Ctrl+D, etc.) back to
  their traditional byte encodings so Ink's input handling is preserved
- Intercept \x08 (Ctrl+Backspace on some terminals) as word-delete

Also fixes Option+Left/Right in terminals that send Meta prefix sequences
(\x1bb, \x1bf) which Ink parses as key.meta + input char rather than
key.meta + arrow key.

Terminal.app does not support any keyboard protocol — this is a known
limitation shared by Claude Code and Codex CLI. Ctrl+W works as the
word-delete alternative there.
2026-04-01 17:15:28 -07:00
7 changed files with 481 additions and 17 deletions
+14 -4
View File
@@ -373,6 +373,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
handleKeyboardSequence,
handleCtrlShortcut,
deleteCharBefore,
deleteWordBefore,
insertText: insertTextAtCursor,
} = useTextInput()
@@ -423,10 +424,12 @@ export const ChatView: React.FC<ChatViewProps> = ({
| null
>(null)
// Handle Home/End keys from raw stdin (Ink doesn't expose these in useInput)
// Handle Home/End keys and Option+Backspace from raw stdin
// (Ink doesn't properly expose these in useInput)
useHomeEndKeys({
onHome: useCallback(() => setCursorPos(0), [setCursorPos]),
onEnd: useCallback(() => setCursorPos(textInputRef.current.length), [setCursorPos]),
onOptionBackspace: useCallback(() => deleteWordBefore(), [deleteWordBefore]),
isActive: !activePanel, // Only active when no panel is open
})
@@ -1159,13 +1162,20 @@ export const ChatView: React.FC<ChatViewProps> = ({
return
}
// 3. Handle Option+arrow via key.meta (backup - Ink sometimes parses these instead of passing raw sequence)
// 3. Handle Option+arrow via key.meta
// Terminals send these as either:
// - CSI format: \x1b[1;3D → Ink sets key.meta + key.leftArrow
// - Meta prefix: \x1bb → Ink sets key.meta + input='b' (emacs: Meta-b = word left)
// We handle both formats here.
// Note: Option+Backspace is NOT handled here — it's handled exclusively by
// useHomeEndKeys to avoid double word-delete (both this handler and
// useHomeEndKeys fire for the same \x1b\x7f input).
if (key.meta) {
if (key.leftArrow) {
if (key.leftArrow || input === "b") {
setCursorPos(findWordStart(textInputRef.current, cursorPosRef.current))
return
}
if (key.rightArrow) {
if (key.rightArrow || input === "f") {
setCursorPos(findWordEnd(textInputRef.current, cursorPosRef.current))
return
}
+55
View File
@@ -38,3 +38,58 @@ export const OPTION_RIGHT_SEQUENCES = new Set([
"\x1bf", // Meta+f - emacs style
"\x1b[1;3C", // CSI 1;3 C - xterm with modifiers
])
// Option+Backspace / Ctrl+Backspace (delete word backwards) sequences
// Multiple paths cover different terminals:
// - \x1b\x7f: terminals with "Option as Meta/Esc+" configured
// - \x08: Ctrl+Backspace on macOS/Linux (detected in enhanced-stdin proxy)
// - \x1b[27;3;127~: modifyOtherKeys protocol (detected in enhanced-stdin proxy)
// - \x1b[127;3u: Kitty keyboard protocol
export const OPTION_BACKSPACE_SEQUENCES = new Set([
"\x1b\x7f", // Meta+DEL - terminals with "Option as Meta/Esc+" configured
"\x1b\x08", // Meta+BS - some terminals send BS (0x08) instead of DEL (0x7f)
"\x1b[27;3;127~", // xterm modifyOtherKeys format: Alt+DEL (modifier=3, key=127)
"\x1b[127;3u", // Kitty keyboard protocol format: DEL with Alt modifier
])
/**
* Enable xterm modifyOtherKeys mode for detecting modifier keys.
*
* By default, many terminals (especially iTerm2 without "Esc+" configured)
* send identical bytes for Option+Backspace and regular Backspace (\x7f).
* Enabling modifyOtherKeys level 2 makes the terminal encode modified keys
* in a distinct format. For example:
* Alt+Backspace → \x1b[27;3;127~ (instead of just \x7f)
*
* Level 2 is required because level 1 doesn't modify Alt+Backspace (it only
* modifies keys without existing encodings, and Backspace has one).
* However, level 2 also modifies Ctrl+C, Ctrl+D, Tab, etc. The stdin proxy
* in enhanced-stdin.ts translates these back to their original bytes so
* Ink's input handling and signal handlers continue to work.
*
* Terminals that don't support modifyOtherKeys silently ignore the sequence.
*
* Note: We also keep Kitty protocol sequences (\x1b[127;3u) in
* OPTION_BACKSPACE_SEQUENCES for terminals that natively use that protocol.
*/
export function enableEnhancedKeyboardMode(): void {
// xterm modifyOtherKeys level 2 — all modified keys get CSI encoding.
// The stdin proxy translates control keys (Ctrl+C, etc.) back to their
// original bytes so Ink and signal handlers aren't broken.
process.stdout.write("\x1b[>4;2m")
// Kitty keyboard protocol (progressive enhancement, flags=1).
// VSCode terminal (xterm.js) supports this but NOT modifyOtherKeys.
// With Kitty, modified keys use the format: \x1b[{keycode};{modifier}u
// e.g. Option+Backspace → \x1b[127;3u, Ctrl+Backspace → \x1b[127;5u
// Terminals that don't support it silently ignore the sequence.
process.stdout.write("\x1b[>1u")
}
/**
* Disable enhanced keyboard mode, restoring default terminal behavior.
*/
export function disableEnhancedKeyboardMode(): void {
process.stdout.write("\x1b[>4;0m") // disable modifyOtherKeys
process.stdout.write("\x1b[<u") // disable Kitty keyboard protocol
}
+43 -11
View File
@@ -1,36 +1,50 @@
/**
* Hook to detect Home/End keys from raw stdin.
* Hook to detect Home/End keys and Option+Backspace from raw stdin.
*
* Ink's useInput hook parses Home/End keys but doesn't expose them in the key object,
* and sets input to '' for these keys (because they're in nonAlphanumericKeys).
* This hook subscribes to raw stdin events to detect Home/End before Ink processes them.
*
* For Option+Backspace, many terminals (like iTerm2 without "Esc+" configured) send
* the same byte (\x7f) for both regular Backspace and Option+Backspace. To distinguish
* them, the CLI enables xterm's modifyOtherKeys mode at startup (in index.ts), which
* makes the terminal send \x1b[27;3;127~ for Alt+Backspace. A stdin proxy (enhanced-stdin)
* intercepts this sequence before Ink sees it and emits an "option-backspace" event.
*
* This hook listens for:
* - Raw stdin events (via internal_eventEmitter) for Home/End keys and \x1b\x7f
* - Enhanced key events (via enhancedKeyEvents) for protocol-detected Option+Backspace
*/
import { useStdin } from "ink"
import { useCallback, useEffect, useRef } from "react"
import { END_SEQUENCES, HOME_SEQUENCES } from "../constants/keyboard"
import { END_SEQUENCES, HOME_SEQUENCES, OPTION_BACKSPACE_SEQUENCES } from "../constants/keyboard"
import { enhancedKeyEvents } from "../utils/enhanced-stdin"
interface UseHomeEndKeysOptions {
onHome: () => void
onEnd: () => void
onOptionBackspace?: () => void
isActive?: boolean
}
/**
* Subscribe to raw stdin to detect Home/End keys.
* These keys are parsed by Ink but not exposed in useInput's key object.
* Subscribe to raw stdin to detect Home/End keys and Option+Backspace.
* These keys are parsed by Ink but not properly exposed in useInput's key object.
*/
export function useHomeEndKeys({ onHome, onEnd, isActive = true }: UseHomeEndKeysOptions): void {
export function useHomeEndKeys({ onHome, onEnd, onOptionBackspace, isActive = true }: UseHomeEndKeysOptions): void {
// Use refs to avoid stale closure issues
const onHomeRef = useRef(onHome)
const onEndRef = useRef(onEnd)
const onOptionBackspaceRef = useRef(onOptionBackspace)
onHomeRef.current = onHome
onEndRef.current = onEnd
onOptionBackspaceRef.current = onOptionBackspace
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { internal_eventEmitter } = useStdin() as any
// Handle raw stdin data for Home/End keys and traditional \x1b\x7f
const handleInput = useCallback((data: Buffer | string) => {
const s = typeof data === "string" ? data : data.toString()
@@ -38,17 +52,35 @@ export function useHomeEndKeys({ onHome, onEnd, isActive = true }: UseHomeEndKey
onHomeRef.current()
} else if (END_SEQUENCES.has(s)) {
onEndRef.current()
} else if (OPTION_BACKSPACE_SEQUENCES.has(s)) {
// Handles \x1b\x7f from terminals with "Esc+" configured
onOptionBackspaceRef.current?.()
}
}, [])
// Handle enhanced key events from stdin proxy (modifyOtherKeys protocol)
const handleOptionBackspace = useCallback(() => {
onOptionBackspaceRef.current?.()
}, [])
useEffect(() => {
if (!isActive || !internal_eventEmitter) {
if (!isActive) {
return
}
internal_eventEmitter.on("input", handleInput)
return () => {
internal_eventEmitter.removeListener("input", handleInput)
// Listen for protocol-detected Option+Backspace from stdin proxy
enhancedKeyEvents.on("option-backspace", handleOptionBackspace)
// Listen for raw stdin events (Home/End keys, traditional \x1b\x7f)
if (internal_eventEmitter) {
internal_eventEmitter.on("input", handleInput)
}
}, [isActive, internal_eventEmitter, handleInput])
return () => {
enhancedKeyEvents.removeListener("option-backspace", handleOptionBackspace)
if (internal_eventEmitter) {
internal_eventEmitter.removeListener("input", handleInput)
}
}
}, [isActive, internal_eventEmitter, handleInput, handleOptionBackspace])
}
+4 -1
View File
@@ -3,6 +3,7 @@
*
* Supports essential terminal shortcuts:
* - Option+Left/Right: move by word (via escape sequences)
* - Option+Backspace: delete word backwards (handled by useHomeEndKeys)
* - Ctrl+A/E: start/end of line
* - Ctrl+W: delete word backwards
* - Ctrl+U: delete to start of line
@@ -27,7 +28,7 @@ type KeyboardSequence =
/**
* Parse keyboard escape sequences for special key combinations.
* Only handles Option+arrow - Home/End are handled by useHomeEndKeys.
* Handles Option+arrow. Home/End and Option+Backspace are handled by useHomeEndKeys.
*/
function parseKeyboardSequence(input: string): KeyboardSequence {
if (OPTION_LEFT_SEQUENCES.has(input)) {
@@ -83,6 +84,7 @@ export interface UseTextInputReturn {
// Deletion
deleteCharBefore: () => void
deleteWordBefore: () => void
// Keyboard shortcut handlers
handleKeyboardSequence: (input: string) => boolean
@@ -219,6 +221,7 @@ export function useTextInput(): UseTextInputReturn {
insertText,
setCursorPos,
deleteCharBefore,
deleteWordBefore,
handleKeyboardSequence,
handleCtrlShortcut,
}
+31 -1
View File
@@ -29,6 +29,7 @@ import { version as CLI_VERSION } from "../package.json"
import { runAcpMode } from "./acp/index.js"
import { App } from "./components/App"
import { KanbanMigrationView } from "./components/KanbanMigrationView"
import { disableEnhancedKeyboardMode, enableEnhancedKeyboardMode } from "./constants/keyboard"
import { checkRawModeSupport } from "./context/StdinContext"
import { createCliHostBridgeProvider } from "./controllers"
import { CliCommentReviewController } from "./controllers/CliCommentReviewController"
@@ -36,6 +37,7 @@ import { CliWebviewProvider } from "./controllers/CliWebviewProvider"
import { isAuthConfigured } from "./utils/auth"
import { restoreConsole, suppressConsoleUnlessVerbose } from "./utils/console"
import { printInfo, printWarning } from "./utils/display"
import { createEnhancedStdin, destroyEnhancedStdin } from "./utils/enhanced-stdin"
import {
forwardSignalToKanbanProcess,
isKanbanCommandAvailable,
@@ -538,6 +540,12 @@ function setupSignalHandlers() {
}
isShuttingDown = true
// Restore terminal keyboard mode before any output or exit.
// Without this, if the process crashes or is killed, the terminal stays
// in modifyOtherKeys/Kitty mode, corrupting subsequent input in the shell.
disableEnhancedKeyboardMode()
destroyEnhancedStdin()
// Notify components to hide UI before shutdown
shutdownEvent.fire()
@@ -602,6 +610,14 @@ function setupSignalHandlers() {
process.on("uncaughtException", (reason: unknown) => {
onUnhandledException(reason, "uncaughtException")
})
// Safety net: restore terminal keyboard mode on any exit path.
// This is synchronous-only (Node.js constraint for 'exit' event) but
// disableEnhancedKeyboardMode() is just process.stdout.write() which is sync.
// Prevents terminal corruption if process.exit() is called from unexpected code paths.
process.on("exit", () => {
disableEnhancedKeyboardMode()
})
}
setupSignalHandlers()
@@ -690,11 +706,23 @@ async function runInkApp(element: React.ReactElement, cleanup: () => Promise<voi
// Clear terminal for clean UI - robot will render at row 1
process.stdout.write("\x1b[2J\x1b[3J\x1b[H")
// Enable xterm modifyOtherKeys mode so terminals send distinct sequences
// for modifier+key combos (e.g., Alt+Backspace → \x1b[27;3;127~).
// This must be enabled before creating the stdin proxy.
enableEnhancedKeyboardMode()
// Create a stdin proxy that intercepts modifier key sequences before Ink
// sees them. Without this, Ink would insert the raw escape sequences as text.
const enhancedStdin = createEnhancedStdin()
// Note: incrementalRendering is disabled because it causes UI glitches on terminal resize.
// Ink's incremental rendering tries to erase N lines based on previous output height,
// but when the terminal shrinks, this leaves artifacts. Gemini CLI only enables
// incrementalRendering when alternateBuffer is also enabled (which we don't use).
const { waitUntilExit, unmount } = render(element, { exitOnCtrlC: true })
const { waitUntilExit, unmount } = render(element, {
exitOnCtrlC: true,
stdin: enhancedStdin as any,
})
try {
await waitUntilExit()
@@ -704,6 +732,8 @@ async function runInkApp(element: React.ReactElement, cleanup: () => Promise<voi
} catch {
// Already unmounted
}
destroyEnhancedStdin()
disableEnhancedKeyboardMode()
restoreConsole()
await cleanup()
}
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest"
import { translateModifyOtherKeys } from "./enhanced-stdin"
describe("translateModifyOtherKeys", () => {
// Modifier encoding: value = bits + 1
// modifier 2 = Shift, 3 = Alt, 4 = Shift+Alt, 5 = Ctrl, 6 = Shift+Ctrl, 7 = Alt+Ctrl
describe("Alt+Backspace (word delete)", () => {
it("returns null for Alt+Backspace (modifyOtherKeys: mod=3, key=127)", () => {
// \x1b[27;3;127~ — iTerm2 with modifyOtherKeys level 2
expect(translateModifyOtherKeys(3, 127)).toBeNull()
})
it("returns null for Alt+Backspace (Kitty: key=127, mod=3)", () => {
// \x1b[127;3u — Kitty keyboard protocol
expect(translateModifyOtherKeys(3, 127)).toBeNull()
})
})
describe("Ctrl+Backspace (word delete)", () => {
it("returns null for Ctrl+Backspace (Kitty: key=127, mod=5)", () => {
// \x1b[127;5u — VSCode terminal via Kitty protocol
expect(translateModifyOtherKeys(5, 127)).toBeNull()
})
it("returns null for Ctrl+Alt+Backspace (mod=7, key=127)", () => {
expect(translateModifyOtherKeys(7, 127)).toBeNull()
})
})
describe("Ctrl+letter translation", () => {
it("translates Ctrl+C (mod=5, key=99) to \\x03", () => {
expect(translateModifyOtherKeys(5, 99)).toBe("\x03")
})
it("translates Ctrl+D (mod=5, key=100) to \\x04", () => {
expect(translateModifyOtherKeys(5, 100)).toBe("\x04")
})
it("translates Ctrl+Z (mod=5, key=122) to \\x1a", () => {
expect(translateModifyOtherKeys(5, 122)).toBe("\x1a")
})
it("translates Ctrl+A (mod=5, key=65) to \\x01", () => {
expect(translateModifyOtherKeys(5, 65)).toBe("\x01")
})
it("translates Ctrl+W (mod=5, key=119) to \\x17", () => {
// Ctrl+W = word delete (traditional)
expect(translateModifyOtherKeys(5, 119)).toBe("\x17")
})
})
describe("Alt+letter translation (Meta prefix)", () => {
it("translates Alt+b (mod=3, key=98) to ESC+b", () => {
// Option+Left sends \x1bb in many terminals
expect(translateModifyOtherKeys(3, 98)).toBe("\x1bb")
})
it("translates Alt+f (mod=3, key=102) to ESC+f", () => {
// Option+Right sends \x1bf in many terminals
expect(translateModifyOtherKeys(3, 102)).toBe("\x1bf")
})
it("translates Alt+d (mod=3, key=100) to ESC+d", () => {
expect(translateModifyOtherKeys(3, 100)).toBe("\x1bd")
})
})
describe("Alt+Ctrl+letter translation", () => {
it("translates Alt+Ctrl+C (mod=7, key=99) to ESC+\\x03", () => {
expect(translateModifyOtherKeys(7, 99)).toBe("\x1b\x03")
})
})
describe("Shift-only (passthrough)", () => {
it("translates Shift+A (mod=2, key=65) to 'A'", () => {
// Shift alone: no alt, no ctrl — just pass through keycode
expect(translateModifyOtherKeys(2, 65)).toBe("A")
})
})
describe("edge cases", () => {
it("handles Tab keycode (mod=5, key=9) — Ctrl+Tab", () => {
// keycode 9 < 64, so doesn't hit the Ctrl+letter branch
expect(translateModifyOtherKeys(5, 9)).toBe("\t")
})
it("handles Enter keycode (mod=3, key=13) — Alt+Enter", () => {
// keycode 13 < 64, alt+key where key isn't in letter range
expect(translateModifyOtherKeys(3, 13)).toBe("\x1b\r")
})
})
})
+239
View File
@@ -0,0 +1,239 @@
/**
* Enhanced stdin proxy for intercepting modified key sequences.
*
* When xterm modifyOtherKeys mode (level 2) is enabled, terminals send
* distinct escape sequences for ALL modifier+key combos. For example:
* Alt+Backspace → \x1b[27;3;127~
* Ctrl+C → \x1b[27;5;99~
*
* This is great for distinguishing Alt+Backspace from Backspace, but it
* breaks Ink's handling of Ctrl+C, Ctrl+D, Tab, etc. since Ink expects
* the traditional byte encodings (\x03, \x04, \x09).
*
* This module provides a PassThrough stream that sits between real stdin
* and Ink, translating modifyOtherKeys sequences back to their traditional
* encodings — except for Alt+Backspace which gets emitted as an event.
*/
import { EventEmitter } from "events"
import { PassThrough } from "stream"
/** Event emitter for enhanced key events that were intercepted from stdin */
export const enhancedKeyEvents = new EventEmitter()
/**
* Regex matching the xterm modifyOtherKeys CSI format: \x1b[27;{modifier};{keycode}~
* Groups: modifier (number), keycode (number)
*/
const MODIFY_OTHER_KEYS_RE = /\x1b\[27;(\d+);(\d+)~/g
/**
* Also match Kitty keyboard protocol format: \x1b[{keycode};{modifier}u
*/
const KITTY_KEY_RE = /\x1b\[(\d+);(\d+)u/g
const ESC = "\x1b"
let activeProxy: (PassThrough & { setRawMode?: (mode: boolean) => void; isTTY?: boolean }) | null = null
let teardownProxyListeners: (() => void) | null = null
/**
* Translate a modifyOtherKeys sequence back to its traditional encoding.
*
* Modifier values (from xterm):
* 2 = Shift, 3 = Alt, 4 = Shift+Alt, 5 = Ctrl, 6 = Shift+Ctrl,
* 7 = Alt+Ctrl, 8 = Shift+Alt+Ctrl
*
* @returns the translated bytes string, or null if this should be emitted as an event
*/
export function translateModifyOtherKeys(modifier: number, keycode: number): string | null {
// xterm modifier encoding is (bits + 1), so:
// modifier 2 = Shift (bit 0)
// modifier 3 = Alt (bit 1)
// modifier 5 = Ctrl (bit 2)
// modifier 7 = Alt+Ctrl (bit 1 + bit 2)
const bits = modifier - 1
const hasAlt = (bits & 0x02) !== 0
const hasCtrl = (bits & 0x04) !== 0
// Alt+Backspace (DEL=127) or Ctrl+Backspace (DEL=127) → emit as word-delete event
// Alt+Backspace: modifier=3 (hasAlt), Ctrl+Backspace: modifier=5 (hasCtrl)
// Both should trigger word deletion. Ctrl+Backspace is sent by VSCode terminal
// via Kitty protocol as \x1b[127;5u.
if (keycode === 127 && (hasAlt || hasCtrl)) {
return null // signal to emit event
}
// Ctrl+letter: translate back to control character
// Ctrl+C (99) → \x03, Ctrl+D (100) → \x04, etc.
if (hasCtrl && keycode >= 64 && keycode <= 127) {
const ctrlChar = String.fromCharCode(keycode & 0x1f)
if (hasAlt) {
return "\x1b" + ctrlChar // Alt+Ctrl+key → ESC + control char
}
return ctrlChar
}
// Alt+letter: translate back to ESC + character (Meta prefix)
if (hasAlt && !hasCtrl) {
return "\x1b" + String.fromCharCode(keycode)
}
// For other combinations, pass through the original keycode
return String.fromCharCode(keycode)
}
/**
* Create an enhanced stdin proxy that intercepts modifier key sequences.
*
* Returns a PassThrough stream that proxies stdin but translates modifyOtherKeys
* sequences back to traditional encodings. Alt+Backspace is emitted as an event
* on enhancedKeyEvents instead of being passed to Ink.
*/
export function createEnhancedStdin(): PassThrough & { setRawMode?: (mode: boolean) => void; isTTY?: boolean } {
if (activeProxy) {
return activeProxy
}
const proxy = new PassThrough() as PassThrough & {
setRawMode?: (mode: boolean) => void
isTTY?: boolean
fd?: number
}
// Proxy terminal properties and methods that Ink needs.
// Ink's useInput hook calls stdin.ref()/unref() and setRawMode(),
// which exist on process.stdin (a TTY Socket) but not on PassThrough.
proxy.isTTY = process.stdin.isTTY
;(proxy as any).fd = (process.stdin as any).fd
if (typeof process.stdin.setRawMode === "function") {
;(proxy as any).setRawMode = (mode: boolean) => {
process.stdin.setRawMode(mode)
}
}
;(proxy as any).ref = () => {
if (typeof (process.stdin as any).ref === "function") {
;(process.stdin as any).ref()
}
}
;(proxy as any).unref = () => {
if (typeof (process.stdin as any).unref === "function") {
;(process.stdin as any).unref()
}
}
const onData = (data: Buffer) => {
const str = data.toString()
// Ctrl+Backspace sends \x08 (BS) on macOS/Linux — distinct from regular
// Backspace (\x7f). Intercept it as word-delete since Option+Backspace
// is indistinguishable from Backspace in terminals without modifyOtherKeys.
// This matches Gemini CLI and Codex CLI behavior.
if (str === "\x08") {
enhancedKeyEvents.emit("option-backspace")
return
}
// Fast path: if no ESC character, pass through as-is
if (!str.includes(ESC)) {
proxy.write(data)
return
}
// Check for modifyOtherKeys or Kitty sequences
// Use a combined regex approach: find and replace all protocol sequences
let result = ""
let lastIndex = 0
// Process modifyOtherKeys format: \x1b[27;{mod};{key}~
const combined = str
const matches: Array<{ index: number; length: number; modifier: number; keycode: number; format: string }> = []
// Find all modifyOtherKeys matches
MODIFY_OTHER_KEYS_RE.lastIndex = 0
let match: RegExpExecArray | null
while ((match = MODIFY_OTHER_KEYS_RE.exec(combined)) !== null) {
matches.push({
index: match.index,
length: match[0].length,
modifier: Number.parseInt(match[1], 10),
keycode: Number.parseInt(match[2], 10),
format: "modifyOtherKeys",
})
}
// Find all Kitty protocol matches
KITTY_KEY_RE.lastIndex = 0
while ((match = KITTY_KEY_RE.exec(combined)) !== null) {
matches.push({
index: match.index,
length: match[0].length,
modifier: Number.parseInt(match[2], 10), // Note: Kitty format is [keycode;modifier]
keycode: Number.parseInt(match[1], 10),
format: "kitty",
})
}
// If no protocol sequences found, pass through as-is
if (matches.length === 0) {
proxy.write(data)
return
}
// Sort by index and process
matches.sort((a, b) => a.index - b.index)
for (const m of matches) {
// Add any text before this match
if (m.index > lastIndex) {
result += combined.slice(lastIndex, m.index)
}
const translated = translateModifyOtherKeys(m.modifier, m.keycode)
if (translated === null) {
// Emit as event (e.g., Alt+Backspace)
// Flush what we have so far
if (result.length > 0) {
proxy.write(Buffer.from(result))
result = ""
}
enhancedKeyEvents.emit("option-backspace")
} else {
result += translated
}
lastIndex = m.index + m.length
}
// Add remaining text after last match
if (lastIndex < combined.length) {
result += combined.slice(lastIndex)
}
if (result.length > 0) {
proxy.write(Buffer.from(result))
}
}
const onEnd = () => proxy.end()
const onError = (err: Error) => proxy.emit("error", err)
process.stdin.on("data", onData)
process.stdin.on("end", onEnd)
process.stdin.on("error", onError)
teardownProxyListeners = () => {
process.stdin.off("data", onData)
process.stdin.off("end", onEnd)
process.stdin.off("error", onError)
}
activeProxy = proxy
return proxy
}
export function destroyEnhancedStdin(): void {
teardownProxyListeners?.()
teardownProxyListeners = null
activeProxy = null
}