mirror of
https://github.com/cline/cline.git
synced 2026-09-12 17:19:32 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d644dbdd22 | ||
|
|
172d630d72 | ||
|
|
860354ec9d |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user