From 80e04be84fafd7e504a6f2fdfb08cb4961c93d0a Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Thu, 11 Dec 2025 19:18:27 +0100 Subject: [PATCH] fix super modifier parsing --- .../cli/cmd/tui/component/dialog-model.tsx | 2 +- .../cli/cmd/tui/component/prompt/index.tsx | 4 +- .../src/cli/cmd/tui/context/keybind.tsx | 18 +-- packages/opencode/src/config/config.ts | 4 +- packages/opencode/src/util/keybind.ts | 37 +++++-- packages/opencode/test/keybind.test.ts | 104 ++++++++++++++++++ packages/web/src/content/docs/keybinds.mdx | 4 +- 7 files changed, 146 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 0ea4cbd68..38fd57458 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -199,7 +199,7 @@ export function DialogModel(props: { providerID?: string }) { ) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index a5b6051ed..2e1ec3e42 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -10,6 +10,7 @@ import { useSync } from "@tui/context/sync" import { Identifier } from "@/id/id" import { createStore, produce } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" +import { Keybind } from "@/util/keybind" import { usePromptHistory, type PromptInfo } from "./history" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useCommandDialog } from "../dialog-command" @@ -85,7 +86,7 @@ const TEXTAREA_ACTIONS = [ ] as const function mapTextareaKeybindings( - keybinds: Record, + keybinds: Record, action: (typeof TEXTAREA_ACTIONS)[number], ): KeyBinding[] { const configKey = `input_${action.replace(/-/g, "_")}` @@ -96,6 +97,7 @@ function mapTextareaKeybindings( ctrl: binding.ctrl || undefined, meta: binding.meta || undefined, shift: binding.shift || undefined, + super: binding.super || undefined, action, })) } diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index 50a29d2c5..4c82e594c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx @@ -73,21 +73,11 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex return store.leader }, parse(evt: ParsedKey): Keybind.Info { - if (evt.name === "\x1F") - return { - ctrl: true, - name: "_", - shift: false, - leader: false, - meta: false, - } - return { - ctrl: evt.ctrl, - name: evt.name, - shift: evt.shift, - leader: store.leader, - meta: evt.meta, + // Handle special case for Ctrl+Underscore (represented as \x1F) + if (evt.name === "\x1F") { + return Keybind.fromParsedKey({ ...evt, name: "_", ctrl: true }, store.leader) } + return Keybind.fromParsedKey(evt, store.leader) }, match(key: keyof KeybindsConfig, evt: ParsedKey) { const keybind = keybinds()[key] diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 25e9589f8..be1949c3b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -512,8 +512,8 @@ export namespace Config { input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"), input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"), input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"), - input_undo: z.string().optional().default("ctrl+-").describe("Undo in input"), - input_redo: z.string().optional().default("ctrl+.").describe("Redo in input"), + input_undo: z.string().optional().default("ctrl+-,super+z").describe("Undo in input"), + input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"), input_word_forward: z .string() .optional() diff --git a/packages/opencode/src/util/keybind.ts b/packages/opencode/src/util/keybind.ts index 5beaf9aab..69fef28f0 100644 --- a/packages/opencode/src/util/keybind.ts +++ b/packages/opencode/src/util/keybind.ts @@ -1,16 +1,35 @@ import { isDeepEqual } from "remeda" +import type { ParsedKey } from "@opentui/core" export namespace Keybind { - export type Info = { - ctrl: boolean - meta: boolean - shift: boolean - leader: boolean - name: string + /** + * Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field. + * This ensures type compatibility and catches missing fields at compile time. + */ + export type Info = Pick & { + leader: boolean // our custom field } export function match(a: Info, b: Info): boolean { - return isDeepEqual(a, b) + // Normalize super field (undefined and false are equivalent) + const normalizedA = { ...a, super: a.super ?? false } + const normalizedB = { ...b, super: b.super ?? false } + return isDeepEqual(normalizedA, normalizedB) + } + + /** + * Convert OpenTUI's ParsedKey to our Keybind.Info format. + * This helper ensures all required fields are present and avoids manual object creation. + */ + export function fromParsedKey(key: ParsedKey, leader = false): Info { + return { + name: key.name, + ctrl: key.ctrl, + meta: key.meta, + shift: key.shift, + super: key.super ?? false, + leader, + } } export function toString(info: Info): string { @@ -18,6 +37,7 @@ export namespace Keybind { if (info.ctrl) parts.push("ctrl") if (info.meta) parts.push("alt") + if (info.super) parts.push("super") if (info.shift) parts.push("shift") if (info.name) { if (info.name === "delete") parts.push("del") @@ -58,6 +78,9 @@ export namespace Keybind { case "option": info.meta = true break + case "super": + info.super = true + break case "shift": info.shift = true break diff --git a/packages/opencode/test/keybind.test.ts b/packages/opencode/test/keybind.test.ts index c09d6cbd3..4ca1f1697 100644 --- a/packages/opencode/test/keybind.test.ts +++ b/packages/opencode/test/keybind.test.ts @@ -68,6 +68,31 @@ describe("Keybind.toString", () => { const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "" } expect(Keybind.toString(info)).toBe("") }) + + test("should convert super modifier to string", () => { + const info: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" } + expect(Keybind.toString(info)).toBe("super+z") + }) + + test("should convert super+shift modifier to string", () => { + const info: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" } + expect(Keybind.toString(info)).toBe("super+shift+z") + }) + + test("should handle super with ctrl modifier", () => { + const info: Keybind.Info = { ctrl: true, meta: false, shift: false, super: true, leader: false, name: "a" } + expect(Keybind.toString(info)).toBe("ctrl+super+a") + }) + + test("should handle super with all modifiers", () => { + const info: Keybind.Info = { ctrl: true, meta: true, shift: true, super: true, leader: false, name: "x" } + expect(Keybind.toString(info)).toBe("ctrl+alt+super+shift+x") + }) + + test("should handle undefined super field (omitted)", () => { + const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" } + expect(Keybind.toString(info)).toBe("ctrl+c") + }) }) describe("Keybind.match", () => { @@ -118,6 +143,36 @@ describe("Keybind.match", () => { const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "a" } expect(Keybind.match(a, b)).toBe(true) }) + + test("should match super modifier keybinds", () => { + const a: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" } + const b: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" } + expect(Keybind.match(a, b)).toBe(true) + }) + + test("should not match super vs non-super", () => { + const a: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" } + const b: Keybind.Info = { ctrl: false, meta: false, shift: false, super: false, leader: false, name: "z" } + expect(Keybind.match(a, b)).toBe(false) + }) + + test("should match undefined super with false super", () => { + const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" } + const b: Keybind.Info = { ctrl: true, meta: false, shift: false, super: false, leader: false, name: "c" } + expect(Keybind.match(a, b)).toBe(true) + }) + + test("should match super+shift combination", () => { + const a: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" } + const b: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" } + expect(Keybind.match(a, b)).toBe(true) + }) + + test("should not match when only super differs", () => { + const a: Keybind.Info = { ctrl: true, meta: true, shift: true, super: true, leader: false, name: "a" } + const b: Keybind.Info = { ctrl: true, meta: true, shift: true, super: false, leader: false, name: "a" } + expect(Keybind.match(a, b)).toBe(false) + }) }) describe("Keybind.parse", () => { @@ -314,4 +369,53 @@ describe("Keybind.parse", () => { }, ]) }) + + test("should parse super modifier", () => { + const result = Keybind.parse("super+z") + expect(result).toEqual([ + { + ctrl: false, + meta: false, + shift: false, + super: true, + leader: false, + name: "z", + }, + ]) + }) + + test("should parse super with shift modifier", () => { + const result = Keybind.parse("super+shift+z") + expect(result).toEqual([ + { + ctrl: false, + meta: false, + shift: true, + super: true, + leader: false, + name: "z", + }, + ]) + }) + + test("should parse multiple keybinds with super", () => { + const result = Keybind.parse("ctrl+-,super+z") + expect(result).toEqual([ + { + ctrl: true, + meta: false, + shift: false, + leader: false, + name: "-", + }, + { + ctrl: false, + meta: false, + shift: false, + super: true, + leader: false, + name: "z", + }, + ]) + }) }) diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 273ecf524..d9e99cd03 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -73,8 +73,8 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf "input_delete_to_line_start": "ctrl+u", "input_backspace": "backspace,shift+backspace", "input_delete": "ctrl+d,delete,shift+delete", - "input_undo": "ctrl+-,cmd+z", - "input_redo": "ctrl+.,cmd+shift+z", + "input_undo": "ctrl+-,super+z", + "input_redo": "ctrl+.,super+shift+z", "input_word_forward": "alt+f,alt+right,ctrl+right", "input_word_backward": "alt+b,alt+left,ctrl+left", "input_select_word_forward": "alt+shift+f,alt+shift+right",