mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-13 01:14:53 +00:00
Compare commits
1 Commits
snapshot-n
...
oc-basecod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73a4f5a654 |
@@ -59,6 +59,7 @@ import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
|
||||
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||
// can't set raw mode if not a TTY
|
||||
@@ -310,7 +311,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
// - Ctrl+C copies and dismisses selection
|
||||
// - Esc dismisses selection
|
||||
// - Most other key input dismisses selection and is passed through
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
if (Keybind.matchParsedKey("ctrl+c", evt)) {
|
||||
if (!Selection.copy(renderer, toast)) {
|
||||
renderer.clearSelection()
|
||||
return
|
||||
|
||||
@@ -47,7 +47,7 @@ export function DialogMcp() {
|
||||
|
||||
const keybinds = createMemo(() => [
|
||||
{
|
||||
keybind: Keybind.parse("space")[0],
|
||||
keybind: Keybind.parseOne("space"),
|
||||
title: "toggle",
|
||||
onTrigger: async (option: DialogSelectOption<string>) => {
|
||||
// Prevent toggling while an operation is already in progress
|
||||
|
||||
@@ -162,7 +162,7 @@ export function DialogSessionList() {
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: Keybind.parse("ctrl+w")[0],
|
||||
keybind: Keybind.parseOne("ctrl+w"),
|
||||
title: "new workspace",
|
||||
side: "right",
|
||||
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createSignal } from "solid-js"
|
||||
import { Installation } from "@/installation"
|
||||
import { win32FlushInputBuffer } from "../win32"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
|
||||
export function ErrorComponent(props: {
|
||||
error: Error
|
||||
@@ -25,7 +26,7 @@ export function ErrorComponent(props: {
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
if (Keybind.matchParsedKey("ctrl+c", evt)) {
|
||||
handleExit()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -195,8 +195,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
useKeyboard((evt) => {
|
||||
setStore("input", "keyboard")
|
||||
|
||||
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
|
||||
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
|
||||
if (evt.name === "up" || Keybind.matchParsedKey("ctrl+p", evt)) move(-1)
|
||||
if (evt.name === "down" || Keybind.matchParsedKey("ctrl+n", evt)) move(1)
|
||||
if (evt.name === "pageup") move(-10)
|
||||
if (evt.name === "pagedown") move(10)
|
||||
if (evt.name === "home") moveTo(0)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createStore } from "solid-js/store"
|
||||
import { useToast } from "./toast"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Selection } from "@tui/util/selection"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
|
||||
export function Dialog(
|
||||
props: ParentProps<{
|
||||
@@ -72,12 +73,13 @@ function init() {
|
||||
})
|
||||
|
||||
const renderer = useRenderer()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (store.stack.length === 0) return
|
||||
if (evt.defaultPrevented) return
|
||||
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
|
||||
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
|
||||
const isCtrlC = Keybind.matchParsedKey("ctrl+c", evt)
|
||||
|
||||
if ((evt.name === "escape" || isCtrlC) && renderer.getSelection()?.getSelectedText()) return
|
||||
if (evt.name === "escape" || isCtrlC) {
|
||||
if (renderer.getSelection()) {
|
||||
renderer.clearSelection()
|
||||
}
|
||||
|
||||
@@ -6,15 +6,70 @@ export namespace Keybind {
|
||||
* 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"> & {
|
||||
export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super" | "baseCode"> & {
|
||||
leader: boolean // our custom field
|
||||
}
|
||||
|
||||
function getBaseCodeName(baseCode: number | undefined): string | undefined {
|
||||
if (baseCode === undefined || baseCode < 32 || baseCode === 127) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const name = String.fromCodePoint(baseCode)
|
||||
|
||||
if (name.length === 1 && name >= "A" && name <= "Z") {
|
||||
return name.toLowerCase()
|
||||
}
|
||||
|
||||
return name
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function match(a: Info | undefined, b: Info): boolean {
|
||||
if (!a) return false
|
||||
const normalizedA = { ...a, super: a.super ?? false }
|
||||
const normalizedB = { ...b, super: b.super ?? false }
|
||||
return isDeepEqual(normalizedA, normalizedB)
|
||||
if (isDeepEqual(normalizedA, normalizedB)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const modifiersA = {
|
||||
ctrl: normalizedA.ctrl,
|
||||
meta: normalizedA.meta,
|
||||
shift: normalizedA.shift,
|
||||
super: normalizedA.super,
|
||||
leader: normalizedA.leader,
|
||||
}
|
||||
const modifiersB = {
|
||||
ctrl: normalizedB.ctrl,
|
||||
meta: normalizedB.meta,
|
||||
shift: normalizedB.shift,
|
||||
super: normalizedB.super,
|
||||
leader: normalizedB.leader,
|
||||
}
|
||||
|
||||
if (!isDeepEqual(modifiersA, modifiersB)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
normalizedA.name === normalizedB.name ||
|
||||
getBaseCodeName(normalizedA.baseCode) === normalizedB.name ||
|
||||
getBaseCodeName(normalizedB.baseCode) === normalizedA.name
|
||||
)
|
||||
}
|
||||
|
||||
export function parseOne(key: string): Info {
|
||||
const parsed = parse(key)
|
||||
|
||||
if (parsed.length !== 1) {
|
||||
throw new Error(`Expected exactly one keybind, got ${parsed.length}: ${key}`)
|
||||
}
|
||||
|
||||
return parsed[0]!
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,10 +83,23 @@ export namespace Keybind {
|
||||
meta: key.meta,
|
||||
shift: key.shift,
|
||||
super: key.super ?? false,
|
||||
baseCode: key.baseCode,
|
||||
leader,
|
||||
}
|
||||
}
|
||||
|
||||
export function matchParsedKey(binding: Info | string | undefined, key: ParsedKey, leader = false): boolean {
|
||||
const bindings = typeof binding === "string" ? parse(binding) : binding ? [binding] : []
|
||||
|
||||
if (!bindings.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parsed = fromParsedKey(key, leader)
|
||||
|
||||
return bindings.some((item) => match(item, parsed))
|
||||
}
|
||||
|
||||
export function toString(info: Info | undefined): string {
|
||||
if (!info) return ""
|
||||
const parts: string[] = []
|
||||
|
||||
@@ -162,6 +162,24 @@ describe("Keybind.match", () => {
|
||||
expect(Keybind.match(a, b)).toBe(true)
|
||||
})
|
||||
|
||||
test("should match ctrl shortcuts by baseCode from alternate layouts", () => {
|
||||
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" }
|
||||
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ", baseCode: 99 }
|
||||
expect(Keybind.match(a, b)).toBe(true)
|
||||
})
|
||||
|
||||
test("should still match the reported character when baseCode is also present", () => {
|
||||
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ" }
|
||||
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ", baseCode: 99 }
|
||||
expect(Keybind.match(a, b)).toBe(true)
|
||||
})
|
||||
|
||||
test("should not match a different shortcut just because baseCode exists", () => {
|
||||
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
|
||||
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ", baseCode: 99 }
|
||||
expect(Keybind.match(a, b)).toBe(false)
|
||||
})
|
||||
|
||||
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" }
|
||||
@@ -419,3 +437,68 @@ describe("Keybind.parse", () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Keybind.parseOne", () => {
|
||||
test("should parse a single keybind", () => {
|
||||
expect(Keybind.parseOne("ctrl+x")).toEqual({
|
||||
ctrl: true,
|
||||
meta: false,
|
||||
shift: false,
|
||||
leader: false,
|
||||
name: "x",
|
||||
})
|
||||
})
|
||||
|
||||
test("should reject multiple keybinds", () => {
|
||||
expect(() => Keybind.parseOne("ctrl+x,ctrl+y")).toThrow("Expected exactly one keybind")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Keybind.fromParsedKey", () => {
|
||||
test("should preserve baseCode from ParsedKey", () => {
|
||||
const result = Keybind.fromParsedKey({
|
||||
name: "ㅊ",
|
||||
ctrl: true,
|
||||
meta: false,
|
||||
shift: false,
|
||||
option: false,
|
||||
number: false,
|
||||
sequence: "ㅊ",
|
||||
raw: "\x1b[12618::99;5u",
|
||||
eventType: "press",
|
||||
source: "kitty",
|
||||
baseCode: 99,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
name: "ㅊ",
|
||||
ctrl: true,
|
||||
meta: false,
|
||||
shift: false,
|
||||
super: false,
|
||||
leader: false,
|
||||
baseCode: 99,
|
||||
})
|
||||
})
|
||||
|
||||
test("should ignore leader unless explicitly requested", () => {
|
||||
const key = {
|
||||
name: "ㅊ",
|
||||
ctrl: true,
|
||||
meta: false,
|
||||
shift: false,
|
||||
option: false,
|
||||
number: false,
|
||||
sequence: "ㅊ",
|
||||
raw: "\x1b[12618::99;5u",
|
||||
eventType: "press" as const,
|
||||
source: "kitty" as const,
|
||||
baseCode: 99,
|
||||
}
|
||||
|
||||
expect(Keybind.matchParsedKey("ctrl+c", key)).toBe(true)
|
||||
expect(Keybind.matchParsedKey("ctrl+x,ctrl+c", key)).toBe(true)
|
||||
expect(Keybind.matchParsedKey("ctrl+x,ctrl+y", key)).toBe(false)
|
||||
expect(Keybind.matchParsedKey("ctrl+c", key, true)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user