shellquest
Version:
Terminal-based procedurally generated dungeon crawler
301 lines (256 loc) • 11.7 kB
text/typescript
/**
* Keyboard patch for @opentui/core - adds keydown/keyup events with held state tracking
* Auto-detects protocol (win32/kitty/legacy) with graceful fallback
* MUST be imported before any @opentui/core imports!
*/
import { KeyHandler, KeyEvent } from "@opentui/core"
import * as fs from "fs"
export type KeyboardProtocol = "kitty" | "win32" | "legacy"
declare module "@opentui/core" {
interface KeyHandlerEventMap { keydown: [KeyEvent]; keyup: [KeyEvent] }
interface KeyHandler {
isDown(key: string): boolean
getHeldKeys(): string[]
clearHeldKeys(): void
getProtocol(): KeyboardProtocol
setProtocol(protocol: KeyboardProtocol): void
}
}
// Key mappings
const WIN32_VK: Record<number, string> = {
8: "backspace", 9: "tab", 13: "return", 16: "shift", 17: "ctrl", 18: "alt",
19: "pause", 20: "capslock", 27: "escape", 32: "space",
33: "pageup", 34: "pagedown", 35: "end", 36: "home",
37: "left", 38: "up", 39: "right", 40: "down", 45: "insert", 46: "delete",
...Object.fromEntries([...Array(10)].map((_, i) => [48 + i, String(i)])),
...Object.fromEntries([...Array(26)].map((_, i) => [65 + i, String.fromCharCode(97 + i)])),
91: "meta", 92: "meta",
...Object.fromEntries([...Array(12)].map((_, i) => [112 + i, `f${i + 1}`])),
160: "shift", 161: "shift", 162: "ctrl", 163: "ctrl", 164: "alt", 165: "alt",
}
const SPECIAL_KEYS: Record<number, string> = {
9: "tab", 13: "return", 27: "escape", 127: "backspace",
1: "home", 2: "insert", 3: "delete", 4: "end", 5: "pageup", 6: "pagedown", 7: "home", 8: "end",
11: "f1", 12: "f2", 13: "f3", 14: "f4", 15: "f5", 17: "f6", 18: "f7", 19: "f8",
20: "f9", 21: "f10", 23: "f11", 24: "f12",
57358: "capslock", 57359: "scrolllock", 57360: "numlock",
57441: "shift", 57442: "ctrl", 57443: "alt", 57444: "meta",
}
const CURSOR_KEYS: Record<string, string> = {
A: "up", B: "down", C: "right", D: "left", F: "end", H: "home",
P: "f1", Q: "f2", R: "f3", S: "f4",
}
const LEGACY_SEQ: Record<string, string> = {
"\x1b[A": "up", "\x1b[B": "down", "\x1b[C": "right", "\x1b[D": "left",
"\x1bOA": "up", "\x1bOB": "down", "\x1bOC": "right", "\x1bOD": "left",
"\x1b[1~": "home", "\x1b[2~": "insert", "\x1b[3~": "delete", "\x1b[4~": "end",
"\x1b[5~": "pageup", "\x1b[6~": "pagedown", "\x1b[H": "home", "\x1b[F": "end",
"\x1bOP": "f1", "\x1bOQ": "f2", "\x1bOR": "f3", "\x1bOS": "f4",
"\x1b[15~": "f5", "\x1b[17~": "f6", "\x1b[18~": "f7", "\x1b[19~": "f8",
"\x1b[20~": "f9", "\x1b[21~": "f10", "\x1b[23~": "f11", "\x1b[24~": "f12",
}
// State
const state = new WeakMap<KeyHandler, {
held: Map<string, KeyEvent>
timeouts: Map<string, Timer>
lastSeen: Map<string, number>
intervals: Map<string, number[]>
protocol: KeyboardProtocol
enabled: boolean
verified: boolean
keyCount: number
releaseCount: number
}>()
const getState = (h: KeyHandler) => {
if (!state.has(h)) state.set(h, {
held: new Map(), timeouts: new Map(), lastSeen: new Map(), intervals: new Map(),
protocol: detectProtocol(), enabled: false, verified: false, keyCount: 0, releaseCount: 0
})
return state.get(h)!
}
const write = (d: string) => { try { fs.writeSync(process.stdout.fd, d) } catch { process.stdout.write(d) } }
function detectProtocol(): KeyboardProtocol {
const env = process.env
const tp = (env.TERM_PROGRAM || "").toLowerCase()
const term = (env.TERM || "").toLowerCase()
// Windows Terminal (native or WSL)
if (env.WT_SESSION) return "win32"
if (process.platform === "win32") return "win32"
// Kitty-compatible terminals
const kitty = tp === "kitty" || term.includes("kitty") || env.KITTY_WINDOW_ID ||
tp === "wezterm" || env.WEZTERM_PANE || tp === "ghostty" || env.GHOSTTY_RESOURCES_DIR ||
tp === "foot" || term === "foot" || tp === "rio" || tp === "contour" ||
tp === "iterm.app" || env.LC_TERMINAL?.toLowerCase() === "iterm2" || env.ITERM_SESSION_ID ||
tp === "alacritty" || env.ALACRITTY_SOCKET || tp === "konsole"
return kitty ? "kitty" : "legacy"
}
function ensureEnabled(h: KeyHandler) {
const s = getState(h)
if (s.enabled) return
s.enabled = true
if (s.protocol === "legacy") return
const seq = s.protocol === "win32"
? { on: "\x1b[?9001h", off: "\x1b[?9001l" }
: { on: "\x1b[>1u", off: "\x1b[<u" }
write(seq.on)
const cleanup = () => write(seq.off)
process.on("exit", cleanup)
process.on("SIGINT", () => { cleanup(); process.exit(0) })
process.on("SIGTERM", () => { cleanup(); process.exit(0) })
}
function makeEvent(name: string, seq: string, mods: { ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }, release: boolean, source: string): KeyEvent {
return new KeyEvent({
name, sequence: seq, raw: seq, source,
ctrl: mods.ctrl || false, meta: mods.meta || false,
shift: mods.shift || false, option: mods.alt || false,
number: /^[0-9]$/.test(name), eventType: release ? "release" : "press",
})
}
function emitDown(h: KeyHandler, e: KeyEvent) {
const s = getState(h)
const k = e.name.toLowerCase()
if (!s.held.has(k)) { s.held.set(k, e); h.emit("keydown" as any, e) }
h.emit("keypress", e)
}
function emitUp(h: KeyHandler, e: KeyEvent) {
const s = getState(h)
const k = e.name.toLowerCase()
if (s.held.has(k)) { s.held.delete(k); h.emit("keyup" as any, e); h.emit("keyrelease", e) }
}
function handleLegacy(h: KeyHandler, e: KeyEvent) {
const s = getState(h), k = e.name.toLowerCase(), now = Date.now()
// Track timing for adaptive timeout
const last = s.lastSeen.get(k)
if (last && now - last < 500) {
const arr = s.intervals.get(k) || []; arr.push(now - last)
if (arr.length > 5) arr.shift()
s.intervals.set(k, arr)
} else s.intervals.delete(k)
s.lastSeen.set(k, now)
// Clear existing timeout
const existing = s.timeouts.get(k)
if (existing) clearTimeout(existing)
// Emit keydown if new
if (!s.held.has(k)) { s.held.set(k, e); h.emit("keydown" as any, e) }
h.emit("keypress", e)
// Adaptive timeout
const intervals = s.intervals.get(k)
const timeout = intervals?.length >= 2
? Math.min(500, Math.max(50, (intervals.reduce((a, b) => a + b) / intervals.length) * 2.5))
: 150
s.timeouts.set(k, setTimeout(() => {
s.timeouts.delete(k); s.lastSeen.delete(k); s.intervals.delete(k)
if (s.held.has(k)) { s.held.delete(k); h.emit("keyup" as any, e); h.emit("keyrelease", e) }
}, timeout))
}
function trackKitty(h: KeyHandler, release: boolean) {
const s = getState(h)
if (s.verified) return
if (release) { s.releaseCount++; s.verified = true }
else if (++s.keyCount >= 10 && !s.releaseCount && s.protocol === "kitty") {
write("\x1b[<u"); s.protocol = "legacy"; s.verified = true
}
}
function handleKittyEvent(h: KeyHandler, e: KeyEvent, release: boolean) {
const s = getState(h)
trackKitty(h, release)
if (release) emitUp(h, e)
else if (s.verified && s.releaseCount > 0) emitDown(h, e)
else handleLegacy(h, e)
}
// Parsers
function parseWin32(h: KeyHandler, str: string): boolean {
const m = str.match(/^\x1b\[(\d+);(\d+);(\d+);(\d+);(\d+);(\d+)_/)
if (!m) return false
const vk = +m[1], uc = +m[3], down = +m[4] === 1, cs = +m[5]
const name = WIN32_VK[vk] || (uc > 0 ? String.fromCharCode(uc).toLowerCase() : `vk-${vk}`)
const e = makeEvent(name, m[0], {
shift: !!(cs & 0x10), ctrl: !!(cs & 0x0c), alt: !!(cs & 0x03)
}, !down, "raw")
down ? emitDown(h, e) : emitUp(h, e)
return true
}
function parseKitty(h: KeyHandler, str: string): boolean {
// CSI codepoint ; modifiers : event-type [u~]
let m = str.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d))?([u~])/)
if (m) {
const cp = +m[1], mod = +(m[2] || 1), evt = +(m[3] || 1), term = m[4]
const mods = { shift: !!((mod-1)&1), alt: !!((mod-1)&2), ctrl: !!((mod-1)&4), meta: !!((mod-1)&8) }
const name = term === "~" ? (SPECIAL_KEYS[cp] || `key${cp}`)
: (SPECIAL_KEYS[cp] || String.fromCharCode(cp).toLowerCase() || `u${cp}`)
handleKittyEvent(h, makeEvent(name, m[0], mods, evt === 3, "kitty"), evt === 3)
return true
}
// CSI 1 ; modifiers : event-type [ABCDFHPQRS]
m = str.match(/^\x1b\[1;(\d+)(?::(\d))?([ABCDFHPQRS])/)
if (m) {
const mod = +m[1], evt = +(m[2] || 1), key = m[3]
const mods = { shift: !!((mod-1)&1), alt: !!((mod-1)&2), ctrl: !!((mod-1)&4), meta: !!((mod-1)&8) }
handleKittyEvent(h, makeEvent(CURSOR_KEYS[key] || key, m[0], mods, evt === 3, "kitty"), evt === 3)
return true
}
// CSI code ; modifiers : event-type ~
m = str.match(/^\x1b\[(\d+);(\d+)(?::(\d))?~/)
if (m) {
const code = +m[1], mod = +m[2], evt = +(m[3] || 1)
const mods = { shift: !!((mod-1)&1), alt: !!((mod-1)&2), ctrl: !!((mod-1)&4), meta: !!((mod-1)&8) }
handleKittyEvent(h, makeEvent(SPECIAL_KEYS[code] || `s${code}`, m[0], mods, evt === 3, "kitty"), evt === 3)
return true
}
return false
}
function parseLegacy(h: KeyHandler, str: string): boolean {
const name = LEGACY_SEQ[str]
if (name) { handleLegacy(h, makeEvent(name, str, {}, false, "raw")); return true }
if (str.startsWith("\x1b") && str.length === 2 && str[1] !== "[" && str[1] !== "O") {
const c = str[1]!, n = c.charCodeAt(0)
const key = n < 32 ? (n === 9 ? "tab" : n === 13 ? "return" : n === 27 ? "escape" : String.fromCharCode(n + 96))
: c === " " ? "space" : c.toLowerCase()
handleLegacy(h, makeEvent(key, str, { meta: true, alt: true, shift: c !== c.toLowerCase() }, false, "raw"))
return true
}
if (str === "\x1b") { handleLegacy(h, makeEvent("escape", str, {}, false, "raw")); return true }
return false
}
// Patch
const original = KeyHandler.prototype.processInput
KeyHandler.prototype.processInput = function(data: string): boolean {
ensureEnabled(this)
const s = getState(this)
if (s.protocol === "win32" && parseWin32(this, data)) return true
if (s.protocol === "kitty" && parseKitty(this, data)) return true
if (parseLegacy(this, data)) return true
// Fallback to original
const onPress = (e: KeyEvent) => {
if (s.protocol === "kitty" && s.verified && s.releaseCount > 0) {
if (!s.held.has(e.name.toLowerCase())) { s.held.set(e.name.toLowerCase(), e); this.emit("keydown" as any, e) }
} else handleLegacy(this, e)
}
const onRelease = (e: KeyEvent) => emitUp(this, e)
this.once("keypress", onPress)
this.once("keyrelease", onRelease)
const result = original.call(this, data)
this.off("keypress", onPress)
this.off("keyrelease", onRelease)
return result
}
KeyHandler.prototype.isDown = function(key) { return getState(this).held.has(key.toLowerCase()) }
KeyHandler.prototype.getHeldKeys = function() { return [...getState(this).held.keys()] }
KeyHandler.prototype.getProtocol = function() { return getState(this).protocol }
KeyHandler.prototype.clearHeldKeys = function() {
const s = getState(this)
for (const e of s.held.values()) { this.emit("keyup" as any, e); this.emit("keyrelease", e) }
s.held.clear()
for (const t of s.timeouts.values()) clearTimeout(t)
s.timeouts.clear(); s.lastSeen.clear(); s.intervals.clear()
s.keyCount = 0; s.releaseCount = 0
}
KeyHandler.prototype.setProtocol = function(protocol) {
const s = getState(this)
if (s.protocol !== "legacy" && s.enabled) {
write(s.protocol === "win32" ? "\x1b[?9001l" : "\x1b[<u")
}
s.protocol = protocol; s.enabled = false; s.verified = false
s.keyCount = 0; s.releaseCount = 0
}
export {}