summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorSebastian Herrlinger <[email protected]>2025-12-11 19:18:27 +0100
committerSebastian Herrlinger <[email protected]>2025-12-11 19:20:23 +0100
commit80e04be84fafd7e504a6f2fdfb08cb4961c93d0a (patch)
tree7bff8bc6b527599facdaa781c13561ba634176d1 /packages
parent639320b3e1aaae9d4e76a49f943c6b6039d04cc6 (diff)
downloadopencode-80e04be84fafd7e504a6f2fdfb08cb4961c93d0a.tar.gz
opencode-80e04be84fafd7e504a6f2fdfb08cb4961c93d0a.zip
fix super modifier parsing
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx2
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx4
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/keybind.tsx18
-rw-r--r--packages/opencode/src/config/config.ts4
-rw-r--r--packages/opencode/src/util/keybind.ts37
-rw-r--r--packages/opencode/test/keybind.test.ts104
-rw-r--r--packages/web/src/content/docs/keybinds.mdx4
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 }) {
<DialogSelect
keybind={[
{
- keybind: { ctrl: true, name: "a", meta: false, shift: false, leader: false },
+ keybind: Keybind.parse("ctrl+a")[0],
title: connected() ? "Connect provider" : "View all providers",
onTrigger() {
dialog.replace(() => <DialogProvider />)
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<string, { ctrl: boolean; meta: boolean; shift: boolean; leader: boolean; name: string }[]>,
+ keybinds: Record<string, Keybind.Info[]>,
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<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super"> & {
+ 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("<leader>")
})
+
+ 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",