Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions packages/core/src/lib/KeyHandler.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { test, expect } from "bun:test"
import { InternalKeyHandler, KeyEvent } from "./KeyHandler.js"
import { InternalKeyHandler, KeyEvent, type KeyHandlerOptions } from "./KeyHandler.js"
import { type ParseKeypressOptions, parseKeypress } from "./parse.keypress.js"
import { createTestRenderer } from "../testing/test-renderer.js"

const { renderer, mockInput } = await createTestRenderer({})

function createKeyHandler(): InternalKeyHandler {
return new InternalKeyHandler()
function createKeyHandler(options: KeyHandlerOptions = {}): InternalKeyHandler {
return new InternalKeyHandler(options)
}

function dispatchInput(handler: InternalKeyHandler, data: string, options: ParseKeypressOptions = {}): boolean {
Expand Down Expand Up @@ -41,6 +41,29 @@ test("KeyHandler - parsed input emits keypress events", () => {
})
})

test("KeyHandler - can normalize raw backspace to ctrl+backspace", () => {
const handler = createKeyHandler({ treatRawBackspaceAsCtrlBackspace: () => true })

let receivedKey: KeyEvent | undefined
handler.on("keypress", (key: KeyEvent) => {
receivedKey = key
})

dispatchInput(handler, "\b")

expect(receivedKey).toMatchObject({
name: "backspace",
ctrl: true,
meta: false,
shift: false,
option: false,
number: false,
sequence: "\b",
raw: "\b",
eventType: "press",
})
})

test("KeyHandler - emits keypress events", () => {
const handler = createKeyHandler()

Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/lib/KeyHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,19 @@ export type KeyHandlerEventMap = {
paste: [PasteEvent]
}

export interface KeyHandlerOptions {
treatRawBackspaceAsCtrlBackspace?: boolean | (() => boolean)
}

export class KeyHandler extends EventEmitter<KeyHandlerEventMap> {
constructor(private readonly options: KeyHandlerOptions = {}) {
super()
}

public processParsedKey(parsedKey: ParsedKey): boolean {
try {
parsedKey = this.normalizeParsedKey(parsedKey)

switch (parsedKey.eventType) {
case "press":
this.emit("keypress", new KeyEvent(parsedKey))
Expand All @@ -114,6 +124,27 @@ export class KeyHandler extends EventEmitter<KeyHandlerEventMap> {
return true
}

private normalizeParsedKey(parsedKey: ParsedKey): ParsedKey {
if (
parsedKey.raw === "\b" &&
parsedKey.name === "backspace" &&
!parsedKey.ctrl &&
this.shouldTreatRawBackspaceAsCtrlBackspace()
) {
return {
...parsedKey,
ctrl: true,
}
}

return parsedKey
}

private shouldTreatRawBackspaceAsCtrlBackspace(): boolean {
const value = this.options.treatRawBackspaceAsCtrlBackspace
return typeof value === "function" ? value() : !!value
}

public processPaste(data: string): void {
try {
const cleanedData = Bun.stripANSI(data)
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,9 @@ export class CliRenderer extends EventEmitter implements RenderContext {

const kittyConfig = config.useKittyKeyboard ?? {}
const useKittyForParsing = kittyConfig !== null
this._keyHandler = new InternalKeyHandler()
this._keyHandler = new InternalKeyHandler({
treatRawBackspaceAsCtrlBackspace: () => this.isWindowsTerminalSession(),
})
this._keyHandler.on("keypress", (event) => {
if (this.exitOnCtrlC && event.name === "c" && event.ctrl) {
process.nextTick(() => {
Expand Down Expand Up @@ -1214,6 +1216,15 @@ export class CliRenderer extends EventEmitter implements RenderContext {
}
}

private isWindowsTerminalSession(): boolean {
if (process.env.WT_SESSION) {
return true
}

const terminalName = this._capabilities?.terminal?.name?.toLowerCase()
return terminalName === "windows terminal" || terminalName === "windows_terminal"
}

private handleStdinParserFailure(error: unknown): void {
if (!this.hasLoggedStdinParserError) {
this.hasLoggedStdinParserError = true
Expand Down
83 changes: 83 additions & 0 deletions packages/core/src/tests/renderer.input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,89 @@ test("special keys via keyInput events", async () => {
})
})

test("raw \\b becomes ctrl+backspace in Windows Terminal", async () => {
const previousWtSession = process.env.WT_SESSION
process.env.WT_SESSION = "test-session"

try {
const resultBackspace = await triggerInput("\b")
expect(resultBackspace).toMatchObject({
eventType: "press",
name: "backspace",
ctrl: true,
meta: false,
shift: false,
option: false,
number: false,
sequence: "\b",
raw: "\b",
})
} finally {
if (previousWtSession === undefined) {
delete process.env.WT_SESSION
} else {
process.env.WT_SESSION = previousWtSession
}
}
})

test("raw DEL stays plain backspace in Windows Terminal", async () => {
const previousWtSession = process.env.WT_SESSION
process.env.WT_SESSION = "test-session"

try {
const resultBackspace = await triggerInput("\x7f")
expect(resultBackspace).toMatchObject({
eventType: "press",
name: "backspace",
ctrl: false,
meta: false,
shift: false,
option: false,
number: false,
sequence: "\x7f",
raw: "\x7f",
})
} finally {
if (previousWtSession === undefined) {
delete process.env.WT_SESSION
} else {
process.env.WT_SESSION = previousWtSession
}
}
})

test("raw \\b becomes ctrl+backspace from Windows Terminal capability fallback", async () => {
const previousWtSession = process.env.WT_SESSION
delete process.env.WT_SESSION

const rendererWithPrivateState = currentRenderer as any
const previousCaps = rendererWithPrivateState._capabilities
rendererWithPrivateState._capabilities = { terminal: { name: "Windows Terminal" } }

try {
const resultBackspace = await triggerInput("\b")
expect(resultBackspace).toMatchObject({
eventType: "press",
name: "backspace",
ctrl: true,
meta: false,
shift: false,
option: false,
number: false,
sequence: "\b",
raw: "\b",
})
} finally {
rendererWithPrivateState._capabilities = previousCaps
if (previousWtSession === undefined) {
delete process.env.WT_SESSION
} else {
process.env.WT_SESSION = previousWtSession
}
}
})

test("ctrl+letter combinations via keyInput events", async () => {
const resultCtrlA = await triggerInput("\x01")
expect(resultCtrlA).toMatchObject({
Expand Down
Loading