mirror of
http://10.0.2.1:3031/sauer/claude-code.git
synced 2026-06-30 20:46:58 +10:00
206 lines
7.1 KiB
TypeScript
206 lines
7.1 KiB
TypeScript
|
|
import { nonAlphanumericKeys, type ParsedKey } from '../parse-keypress.js'
|
|||
|
|
import { Event } from './event.js'
|
|||
|
|
|
|||
|
|
export type Key = {
|
|||
|
|
upArrow: boolean
|
|||
|
|
downArrow: boolean
|
|||
|
|
leftArrow: boolean
|
|||
|
|
rightArrow: boolean
|
|||
|
|
pageDown: boolean
|
|||
|
|
pageUp: boolean
|
|||
|
|
wheelUp: boolean
|
|||
|
|
wheelDown: boolean
|
|||
|
|
home: boolean
|
|||
|
|
end: boolean
|
|||
|
|
return: boolean
|
|||
|
|
escape: boolean
|
|||
|
|
ctrl: boolean
|
|||
|
|
shift: boolean
|
|||
|
|
fn: boolean
|
|||
|
|
tab: boolean
|
|||
|
|
backspace: boolean
|
|||
|
|
delete: boolean
|
|||
|
|
meta: boolean
|
|||
|
|
super: boolean
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseKey(keypress: ParsedKey): [Key, string] {
|
|||
|
|
const key: Key = {
|
|||
|
|
upArrow: keypress.name === 'up',
|
|||
|
|
downArrow: keypress.name === 'down',
|
|||
|
|
leftArrow: keypress.name === 'left',
|
|||
|
|
rightArrow: keypress.name === 'right',
|
|||
|
|
pageDown: keypress.name === 'pagedown',
|
|||
|
|
pageUp: keypress.name === 'pageup',
|
|||
|
|
wheelUp: keypress.name === 'wheelup',
|
|||
|
|
wheelDown: keypress.name === 'wheeldown',
|
|||
|
|
home: keypress.name === 'home',
|
|||
|
|
end: keypress.name === 'end',
|
|||
|
|
return: keypress.name === 'return',
|
|||
|
|
escape: keypress.name === 'escape',
|
|||
|
|
fn: keypress.fn,
|
|||
|
|
ctrl: keypress.ctrl,
|
|||
|
|
shift: keypress.shift,
|
|||
|
|
tab: keypress.name === 'tab',
|
|||
|
|
backspace: keypress.name === 'backspace',
|
|||
|
|
delete: keypress.name === 'delete',
|
|||
|
|
// `parseKeypress` parses \u001B\u001B[A (meta + up arrow) as meta = false
|
|||
|
|
// but with option = true, so we need to take this into account here
|
|||
|
|
// to avoid breaking changes in Ink.
|
|||
|
|
// TODO(vadimdemedes): consider removing this in the next major version.
|
|||
|
|
meta: keypress.meta || keypress.name === 'escape' || keypress.option,
|
|||
|
|
// Super (Cmd on macOS / Win key) — only arrives via kitty keyboard
|
|||
|
|
// protocol CSI u sequences. Distinct from meta (Alt/Option) so
|
|||
|
|
// bindings like cmd+c can be expressed separately from opt+c.
|
|||
|
|
super: keypress.super,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let input = keypress.ctrl ? keypress.name : keypress.sequence
|
|||
|
|
|
|||
|
|
// Handle undefined input case
|
|||
|
|
if (input === undefined) {
|
|||
|
|
input = ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// When ctrl is set, keypress.name for space is the literal word "space".
|
|||
|
|
// Convert to actual space character for consistency with the CSI u branch
|
|||
|
|
// (which maps 'space' → ' '). Without this, ctrl+space leaks the literal
|
|||
|
|
// word "space" into text input.
|
|||
|
|
if (keypress.ctrl && input === 'space') {
|
|||
|
|
input = ' '
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Suppress unrecognized escape sequences that were parsed as function keys
|
|||
|
|
// (matched by FN_KEY_RE) but have no name in the keyName map.
|
|||
|
|
// Examples: ESC[25~ (F13/Right Alt on Windows), ESC[26~ (F14), etc.
|
|||
|
|
// Without this, the ESC prefix is stripped below and the remainder (e.g.,
|
|||
|
|
// "[25~") leaks into the input as literal text.
|
|||
|
|
if (keypress.code && !keypress.name) {
|
|||
|
|
input = ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Suppress ESC-less SGR mouse fragments. When a heavy React commit blocks
|
|||
|
|
// the event loop past App's 50ms NORMAL_TIMEOUT flush, a CSI split across
|
|||
|
|
// stdin chunks gets its buffered ESC flushed as a lone Escape key, and the
|
|||
|
|
// continuation arrives as a text token with name='' — which falls through
|
|||
|
|
// all of parseKeypress's ESC-anchored regexes and the nonAlphanumericKeys
|
|||
|
|
// clear below (name is falsy). The fragment then leaks into the prompt as
|
|||
|
|
// literal `[<64;74;16M`. This is the same defensive sink as the F13 guard
|
|||
|
|
// above; the underlying tokenizer-flush race is upstream of this layer.
|
|||
|
|
if (!keypress.name && /^\[<\d+;\d+;\d+[Mm]/.test(input)) {
|
|||
|
|
input = ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Strip meta if it's still remaining after `parseKeypress`
|
|||
|
|
// TODO(vadimdemedes): remove this in the next major version.
|
|||
|
|
if (input.startsWith('\u001B')) {
|
|||
|
|
input = input.slice(1)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Track whether we've already processed this as a special sequence
|
|||
|
|
// that converted input to the key name (CSI u or application keypad mode).
|
|||
|
|
// For these, we don't want to clear input with nonAlphanumericKeys check.
|
|||
|
|
let processedAsSpecialSequence = false
|
|||
|
|
|
|||
|
|
// Handle CSI u sequences (Kitty keyboard protocol): after stripping ESC,
|
|||
|
|
// we're left with "[codepoint;modifieru" (e.g., "[98;3u" for Alt+b).
|
|||
|
|
// Use the parsed key name instead for input handling. Require a digit
|
|||
|
|
// after [ — real CSI u is always [<digits>…u, and a bare startsWith('[')
|
|||
|
|
// false-matches X10 mouse at row 85 (Cy = 85+32 = 'u'), leaking the
|
|||
|
|
// literal text "mouse" into the prompt via processedAsSpecialSequence.
|
|||
|
|
if (/^\[\d/.test(input) && input.endsWith('u')) {
|
|||
|
|
if (!keypress.name) {
|
|||
|
|
// Unmapped Kitty functional key (Caps Lock 57358, F13–F35, KP nav,
|
|||
|
|
// bare modifiers, etc.) — keycodeToName() returned undefined. Swallow
|
|||
|
|
// so the raw "[57358u" doesn't leak into the prompt. See #38781.
|
|||
|
|
input = ''
|
|||
|
|
} else {
|
|||
|
|
// 'space' → ' '; 'escape' → '' (key.escape carries it;
|
|||
|
|
// processedAsSpecialSequence bypasses the nonAlphanumericKeys
|
|||
|
|
// clear below, so we must handle it explicitly here);
|
|||
|
|
// otherwise use key name.
|
|||
|
|
input =
|
|||
|
|
keypress.name === 'space'
|
|||
|
|
? ' '
|
|||
|
|
: keypress.name === 'escape'
|
|||
|
|
? ''
|
|||
|
|
: keypress.name
|
|||
|
|
}
|
|||
|
|
processedAsSpecialSequence = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Handle xterm modifyOtherKeys sequences: after stripping ESC, we're left
|
|||
|
|
// with "[27;modifier;keycode~" (e.g., "[27;3;98~" for Alt+b). Same
|
|||
|
|
// extraction as CSI u — without this, printable-char keycodes (single-letter
|
|||
|
|
// names) skip the nonAlphanumericKeys clear and leak "[27;..." as input.
|
|||
|
|
if (input.startsWith('[27;') && input.endsWith('~')) {
|
|||
|
|
if (!keypress.name) {
|
|||
|
|
// Unmapped modifyOtherKeys keycode — swallow for consistency with
|
|||
|
|
// the CSI u handler above. Practically untriggerable today (xterm
|
|||
|
|
// modifyOtherKeys only sends ASCII keycodes, all mapped), but
|
|||
|
|
// guards against future terminal behavior.
|
|||
|
|
input = ''
|
|||
|
|
} else {
|
|||
|
|
input =
|
|||
|
|
keypress.name === 'space'
|
|||
|
|
? ' '
|
|||
|
|
: keypress.name === 'escape'
|
|||
|
|
? ''
|
|||
|
|
: keypress.name
|
|||
|
|
}
|
|||
|
|
processedAsSpecialSequence = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Handle application keypad mode sequences: after stripping ESC,
|
|||
|
|
// we're left with "O<letter>" (e.g., "Op" for numpad 0, "Oy" for numpad 9).
|
|||
|
|
// Use the parsed key name (the digit character) for input handling.
|
|||
|
|
if (
|
|||
|
|
input.startsWith('O') &&
|
|||
|
|
input.length === 2 &&
|
|||
|
|
keypress.name &&
|
|||
|
|
keypress.name.length === 1
|
|||
|
|
) {
|
|||
|
|
input = keypress.name
|
|||
|
|
processedAsSpecialSequence = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Clear input for non-alphanumeric keys (arrows, function keys, etc.)
|
|||
|
|
// Skip this for CSI u and application keypad mode sequences since
|
|||
|
|
// those were already converted to their proper input characters.
|
|||
|
|
if (
|
|||
|
|
!processedAsSpecialSequence &&
|
|||
|
|
keypress.name &&
|
|||
|
|
nonAlphanumericKeys.includes(keypress.name)
|
|||
|
|
) {
|
|||
|
|
input = ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Set shift=true for uppercase letters (A-Z)
|
|||
|
|
// Must check it's actually a letter, not just any char unchanged by toUpperCase
|
|||
|
|
if (
|
|||
|
|
input.length === 1 &&
|
|||
|
|
typeof input[0] === 'string' &&
|
|||
|
|
input[0] >= 'A' &&
|
|||
|
|
input[0] <= 'Z'
|
|||
|
|
) {
|
|||
|
|
key.shift = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return [key, input]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export class InputEvent extends Event {
|
|||
|
|
readonly keypress: ParsedKey
|
|||
|
|
readonly key: Key
|
|||
|
|
readonly input: string
|
|||
|
|
|
|||
|
|
constructor(keypress: ParsedKey) {
|
|||
|
|
super()
|
|||
|
|
const [key, input] = parseKey(keypress)
|
|||
|
|
|
|||
|
|
this.keypress = keypress
|
|||
|
|
this.key = key
|
|||
|
|
this.input = input
|
|||
|
|
}
|
|||
|
|
}
|