From 1ab929ea7f8ca8a97403946af610cd617da16070 Mon Sep 17 00:00:00 2001 From: nightcityblade Date: Sun, 16 Aug 2026 23:19:11 +0800 Subject: [PATCH 1/2] fix(commands): construct complete synthetic key events --- .changeset/complete-synthetic-key-events.md | 5 +++++ src/lib/commandKeys.test.ts | 20 ++++++++++++++++++++ src/lib/commandKeys.ts | 10 +++++----- 3 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 .changeset/complete-synthetic-key-events.md diff --git a/.changeset/complete-synthetic-key-events.md b/.changeset/complete-synthetic-key-events.md new file mode 100644 index 000000000..e9c53bfb0 --- /dev/null +++ b/.changeset/complete-synthetic-key-events.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Provide complete key event data to command matchers and programmatically invoked handlers. diff --git a/src/lib/commandKeys.test.ts b/src/lib/commandKeys.test.ts index 121d849f0..5ca3b68bb 100644 --- a/src/lib/commandKeys.test.ts +++ b/src/lib/commandKeys.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { KeyEvent } from "@opentui/core"; import { matchesKeyChord, parseKeyChord, synthesizeKeyEvent, toKeyChordList } from "./commandKeys"; /** @@ -23,6 +24,25 @@ describe("synthesizeKeyEvent", () => { expect(matchesKeyChord(parsed(chord), synthesizeKeyEvent(parsed(chord)))).toBe(true); } }); + + test("returns a complete key event", () => { + const event = synthesizeKeyEvent(parsed("ctrl+r")); + + expect(event).toBeInstanceOf(KeyEvent); + expect({ eventType: event.eventType, source: event.source }).toEqual({ + eventType: "press", + source: "raw", + }); + event.preventDefault(); + event.stopPropagation(); + expect({ + defaultPrevented: event.defaultPrevented, + propagationStopped: event.propagationStopped, + }).toEqual({ + defaultPrevented: true, + propagationStopped: true, + }); + }); }); describe("toKeyChordList", () => { diff --git a/src/lib/commandKeys.ts b/src/lib/commandKeys.ts index 65c9143f7..f01665651 100644 --- a/src/lib/commandKeys.ts +++ b/src/lib/commandKeys.ts @@ -1,4 +1,4 @@ -import type { KeyEvent } from "@opentui/core"; +import { KeyEvent } from "@opentui/core"; import { matchesKeyChord, parseKeyChord, type ParsedKeyChord } from "../extension-api/keys"; /** @@ -39,7 +39,7 @@ export function synthesizeKeyEvent(parsed: ParsedKeyChord): KeyEvent { ? parsed.base.toUpperCase() : parsed.base; - return { + return new KeyEvent({ name: isNamed || isLetter ? parsed.base : sequence, sequence, raw: sequence, @@ -48,9 +48,9 @@ export function synthesizeKeyEvent(parsed: ParsedKeyChord): KeyEvent { option: parsed.option, shift: parsed.shift, number: false, - preventDefault: () => {}, - stopPropagation: () => {}, - } as unknown as KeyEvent; + eventType: "press", + source: "raw", + }); } /** From 633ba60785dc76cf1ecb1873a486f1e1c1c19227 Mon Sep 17 00:00:00 2001 From: nightcityblade Date: Tue, 25 Aug 2026 11:06:25 +0800 Subject: [PATCH 2/2] fix(commands): keep headless key parsing OpenTUI-free --- src/lib/commandKeys.test.ts | 3 ++- src/lib/commandKeys.ts | 35 +--------------------------- src/ui/lib/appCommands.ts | 7 ++---- src/ui/lib/extensionCommands.test.ts | 3 ++- src/ui/lib/extensionCommands.ts | 8 ++----- src/ui/lib/syntheticKeyEvent.ts | 26 +++++++++++++++++++++ 6 files changed, 35 insertions(+), 47 deletions(-) create mode 100644 src/ui/lib/syntheticKeyEvent.ts diff --git a/src/lib/commandKeys.test.ts b/src/lib/commandKeys.test.ts index 5ca3b68bb..54c451581 100644 --- a/src/lib/commandKeys.test.ts +++ b/src/lib/commandKeys.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { KeyEvent } from "@opentui/core"; -import { matchesKeyChord, parseKeyChord, synthesizeKeyEvent, toKeyChordList } from "./commandKeys"; +import { matchesKeyChord, parseKeyChord, toKeyChordList } from "./commandKeys"; +import { synthesizeKeyEvent } from "../ui/lib/syntheticKeyEvent"; /** * The internal-only pieces of chord handling. diff --git a/src/lib/commandKeys.ts b/src/lib/commandKeys.ts index f01665651..f9de7322f 100644 --- a/src/lib/commandKeys.ts +++ b/src/lib/commandKeys.ts @@ -1,4 +1,4 @@ -import { KeyEvent } from "@opentui/core"; +import type { KeyEvent } from "@opentui/core"; import { matchesKeyChord, parseKeyChord, type ParsedKeyChord } from "../extension-api/keys"; /** @@ -20,39 +20,6 @@ import { matchesKeyChord, parseKeyChord, type ParsedKeyChord } from "../extensio export { matchesKeyChord, parseKeyChord } from "../extension-api/keys"; export type { ParsedKeyChord } from "../extension-api/keys"; -/** - * Build a synthetic key event that would satisfy the parsed chord. - * - * This exists for conflict detection: a command may match with a predicate - * rather than chords, so the only way to ask "would this chord collide with an - * existing binding?" is to synthesize the event the chord describes and run it - * through every matcher. - */ -export function synthesizeKeyEvent(parsed: ParsedKeyChord): KeyEvent { - // Chord bases are either one literal character or a validated named key, so - // length alone separates the two without re-consulting the named-key table. - const isNamed = parsed.base.length > 1; - const isLetter = /^[a-z]$/.test(parsed.base); - const sequence = isNamed - ? "" - : parsed.shift && isLetter - ? parsed.base.toUpperCase() - : parsed.base; - - return new KeyEvent({ - name: isNamed || isLetter ? parsed.base : sequence, - sequence, - raw: sequence, - ctrl: parsed.ctrl, - meta: parsed.meta, - option: parsed.option, - shift: parsed.shift, - number: false, - eventType: "press", - source: "raw", - }); -} - /** * Normalize one declared binding into the list of chords it names. * diff --git a/src/ui/lib/appCommands.ts b/src/ui/lib/appCommands.ts index 036d8f7bb..5590e0f3b 100644 --- a/src/ui/lib/appCommands.ts +++ b/src/ui/lib/appCommands.ts @@ -8,12 +8,9 @@ import { import type { ReviewSelectionScope } from "../../core/review/navigation"; import type { CursorLine, LayoutMode } from "../../core/types"; import type { ExtensionCommandExecutionOptions } from "../../extension-api/types"; -import { - matchesAnyKeyChord, - parseKeyChordOrUndefined, - synthesizeKeyEvent, -} from "../../lib/commandKeys"; +import { matchesAnyKeyChord, parseKeyChordOrUndefined } from "../../lib/commandKeys"; import { formatKeyChord, type CommandKeyDefaults } from "./keymap"; +import { synthesizeKeyEvent } from "./syntheticKeyEvent"; type ScrollUnit = "step" | "viewport" | "content" | "half"; diff --git a/src/ui/lib/extensionCommands.test.ts b/src/ui/lib/extensionCommands.test.ts index bca467243..4f3caef36 100644 --- a/src/ui/lib/extensionCommands.test.ts +++ b/src/ui/lib/extensionCommands.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { RegisteredCommand } from "../../extensions/types"; -import { synthesizeKeyEvent, parseKeyChord } from "../../lib/commandKeys"; +import { parseKeyChord } from "../../lib/commandKeys"; +import { synthesizeKeyEvent } from "./syntheticKeyEvent"; import { builtinCommandMatchProbes, dispatchAppCommand } from "./appCommands"; import { buildExtensionAppCommands } from "./extensionCommands"; diff --git a/src/ui/lib/extensionCommands.ts b/src/ui/lib/extensionCommands.ts index 2208c532e..0c0306ade 100644 --- a/src/ui/lib/extensionCommands.ts +++ b/src/ui/lib/extensionCommands.ts @@ -1,13 +1,9 @@ import type { KeyEvent } from "@opentui/core"; import type { RegisteredCommand } from "../../extensions/types"; -import { - matchesKeyChord, - parseKeyChordOrUndefined, - synthesizeKeyEvent, - toKeyChordList, -} from "../../lib/commandKeys"; +import { matchesKeyChord, parseKeyChordOrUndefined, toKeyChordList } from "../../lib/commandKeys"; import type { AppCommand, ResolvedCommandKeys } from "./appCommands"; import { formatKeyChord } from "./keymap"; +import { synthesizeKeyEvent } from "./syntheticKeyEvent"; /** One extension binding refused because its chord is already taken. */ export interface ExtensionCommandConflict { diff --git a/src/ui/lib/syntheticKeyEvent.ts b/src/ui/lib/syntheticKeyEvent.ts new file mode 100644 index 000000000..dee75af75 --- /dev/null +++ b/src/ui/lib/syntheticKeyEvent.ts @@ -0,0 +1,26 @@ +import { KeyEvent } from "@opentui/core"; +import type { ParsedKeyChord } from "../../lib/commandKeys"; + +/** Build a synthetic key event for interactive command conflict detection. */ +export function synthesizeKeyEvent(parsed: ParsedKeyChord): KeyEvent { + const isNamed = parsed.base.length > 1; + const isLetter = /^[a-z]$/.test(parsed.base); + const sequence = isNamed + ? "" + : parsed.shift && isLetter + ? parsed.base.toUpperCase() + : parsed.base; + + return new KeyEvent({ + name: isNamed || isLetter ? parsed.base : sequence, + sequence, + raw: sequence, + ctrl: parsed.ctrl, + meta: parsed.meta, + option: parsed.option, + shift: parsed.shift, + number: false, + eventType: "press", + source: "raw", + }); +}