Compare commits

..

1 Commits

Author SHA1 Message Date
Simon Klee 75666b271b opencode: lazy-load top-level CLI commands
The CLI imports every top-level command before argument parsing has
decided which handler will run. This makes simple invocations pay for
the full command graph up front and slows down the default startup path.

Parse the root argv first and load only the command module that matches
the selected top-level command. Keep falling back to the default TUI
path for non-command positionals, and preserve root help, version and
completion handling
2026-04-12 11:25:35 +02:00
12 changed files with 259 additions and 235 deletions
-1
View File
@@ -26,7 +26,6 @@ kommander
r44vc0rp r44vc0rp
rekram1-node rekram1-node
-robinmordasiewicz -robinmordasiewicz
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team -spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr thdxr
-toastythebot -toastythebot
+1 -2
View File
@@ -59,7 +59,6 @@ import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/config/tui" import { TuiConfig } from "@/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error" import { FormatError, FormatUnknownError } from "@/cli/error"
import { Keybind } from "@/util/keybind"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY // can't set raw mode if not a TTY
@@ -311,7 +310,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
// - Ctrl+C copies and dismisses selection // - Ctrl+C copies and dismisses selection
// - Esc dismisses selection // - Esc dismisses selection
// - Most other key input dismisses selection and is passed through // - Most other key input dismisses selection and is passed through
if (Keybind.matchParsedKey("ctrl+c", evt)) { if (evt.ctrl && evt.name === "c") {
if (!Selection.copy(renderer, toast)) { if (!Selection.copy(renderer, toast)) {
renderer.clearSelection() renderer.clearSelection()
return return
@@ -47,7 +47,7 @@ export function DialogMcp() {
const keybinds = createMemo(() => [ const keybinds = createMemo(() => [
{ {
keybind: Keybind.parseOne("space"), keybind: Keybind.parse("space")[0],
title: "toggle", title: "toggle",
onTrigger: async (option: DialogSelectOption<string>) => { onTrigger: async (option: DialogSelectOption<string>) => {
// Prevent toggling while an operation is already in progress // Prevent toggling while an operation is already in progress
@@ -162,7 +162,7 @@ export function DialogSessionList() {
}, },
}, },
{ {
keybind: Keybind.parseOne("ctrl+w"), keybind: Keybind.parse("ctrl+w")[0],
title: "new workspace", title: "new workspace",
side: "right", side: "right",
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
@@ -5,7 +5,6 @@ import { createSignal } from "solid-js"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { win32FlushInputBuffer } from "../win32" import { win32FlushInputBuffer } from "../win32"
import { getScrollAcceleration } from "../util/scroll" import { getScrollAcceleration } from "../util/scroll"
import { Keybind } from "@/util/keybind"
export function ErrorComponent(props: { export function ErrorComponent(props: {
error: Error error: Error
@@ -26,7 +25,7 @@ export function ErrorComponent(props: {
} }
useKeyboard((evt) => { useKeyboard((evt) => {
if (Keybind.matchParsedKey("ctrl+c", evt)) { if (evt.ctrl && evt.name === "c") {
handleExit() handleExit()
} }
}) })
@@ -195,8 +195,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
useKeyboard((evt) => { useKeyboard((evt) => {
setStore("input", "keyboard") setStore("input", "keyboard")
if (evt.name === "up" || Keybind.matchParsedKey("ctrl+p", evt)) move(-1) if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
if (evt.name === "down" || Keybind.matchParsedKey("ctrl+n", evt)) move(1) if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
if (evt.name === "pageup") move(-10) if (evt.name === "pageup") move(-10)
if (evt.name === "pagedown") move(10) if (evt.name === "pagedown") move(10)
if (evt.name === "home") moveTo(0) if (evt.name === "home") moveTo(0)
@@ -6,7 +6,6 @@ import { createStore } from "solid-js/store"
import { useToast } from "./toast" import { useToast } from "./toast"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Selection } from "@tui/util/selection" import { Selection } from "@tui/util/selection"
import { Keybind } from "@/util/keybind"
export function Dialog( export function Dialog(
props: ParentProps<{ props: ParentProps<{
@@ -73,13 +72,12 @@ function init() {
}) })
const renderer = useRenderer() const renderer = useRenderer()
useKeyboard((evt) => { useKeyboard((evt) => {
if (store.stack.length === 0) return if (store.stack.length === 0) return
if (evt.defaultPrevented) return if (evt.defaultPrevented) return
const isCtrlC = Keybind.matchParsedKey("ctrl+c", evt) if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
if ((evt.name === "escape" || isCtrlC) && renderer.getSelection()?.getSelectedText()) return
if (evt.name === "escape" || isCtrlC) {
if (renderer.getSelection()) { if (renderer.getSelection()) {
renderer.clearSelection() renderer.clearSelection()
} }
+244 -46
View File
@@ -1,40 +1,17 @@
import yargs from "yargs" import yargs from "yargs"
import { hideBin } from "yargs/helpers" import { hideBin } from "yargs/helpers"
import { RunCommand } from "./cli/cmd/run"
import { GenerateCommand } from "./cli/cmd/generate"
import { Log } from "./util/log" import { Log } from "./util/log"
import { ConsoleCommand } from "./cli/cmd/account"
import { ProvidersCommand } from "./cli/cmd/providers"
import { AgentCommand } from "./cli/cmd/agent"
import { UpgradeCommand } from "./cli/cmd/upgrade"
import { UninstallCommand } from "./cli/cmd/uninstall"
import { ModelsCommand } from "./cli/cmd/models"
import { UI } from "./cli/ui" import { UI } from "./cli/ui"
import { Installation } from "./installation" import { Installation } from "./installation"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { FormatError } from "./cli/error" import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve"
import { Filesystem } from "./util/filesystem" import { Filesystem } from "./util/filesystem"
import { DebugCommand } from "./cli/cmd/debug"
import { StatsCommand } from "./cli/cmd/stats"
import { McpCommand } from "./cli/cmd/mcp"
import { GithubCommand } from "./cli/cmd/github"
import { ExportCommand } from "./cli/cmd/export"
import { ImportCommand } from "./cli/cmd/import"
import { AttachCommand } from "./cli/cmd/tui/attach"
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
import { AcpCommand } from "./cli/cmd/acp"
import { EOL } from "os" import { EOL } from "os"
import { WebCommand } from "./cli/cmd/web"
import { PrCommand } from "./cli/cmd/pr"
import { SessionCommand } from "./cli/cmd/session"
import { DbCommand } from "./cli/cmd/db"
import path from "path" import path from "path"
import { Global } from "./global" import { Global } from "./global"
import { JsonMigration } from "./storage/json-migration" import { JsonMigration } from "./storage/json-migration"
import { Database } from "./storage/db" import { Database } from "./storage/db"
import { errorMessage } from "./util/error" import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug"
import { Heap } from "./cli/heap" import { Heap } from "./cli/heap"
import { drizzle } from "drizzle-orm/bun-sqlite" import { drizzle } from "drizzle-orm/bun-sqlite"
@@ -52,6 +29,156 @@ process.on("uncaughtException", (e) => {
const args = hideBin(process.argv) const args = hideBin(process.argv)
type Mode =
| "all"
| "none"
| "tui"
| "attach"
| "run"
| "acp"
| "mcp"
| "generate"
| "debug"
| "console"
| "providers"
| "agent"
| "upgrade"
| "uninstall"
| "serve"
| "web"
| "models"
| "stats"
| "export"
| "import"
| "github"
| "pr"
| "session"
| "plugin"
| "db"
const map = new Map<string, Mode>([
["attach", "attach"],
["run", "run"],
["acp", "acp"],
["mcp", "mcp"],
["generate", "generate"],
["debug", "debug"],
["console", "console"],
["providers", "providers"],
["auth", "providers"],
["agent", "agent"],
["upgrade", "upgrade"],
["uninstall", "uninstall"],
["serve", "serve"],
["web", "web"],
["models", "models"],
["stats", "stats"],
["export", "export"],
["import", "import"],
["github", "github"],
["pr", "pr"],
["session", "session"],
["plugin", "plugin"],
["plug", "plugin"],
["db", "db"],
])
function flag(arg: string, name: string) {
return arg === `--${name}` || arg === `--no-${name}` || arg.startsWith(`--${name}=`)
}
function value(arg: string, name: string) {
return arg === `--${name}` || arg.startsWith(`--${name}=`)
}
// Match the root parser closely enough to decide which top-level module to load.
function pick(argv: string[]): Mode {
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (!arg) continue
if (arg === "--") return "tui"
if (arg === "completion") return "all"
if (arg === "--help" || arg === "-h") return "all"
if (arg === "--version" || arg === "-v") return "none"
if (flag(arg, "print-logs") || flag(arg, "pure")) continue
if (value(arg, "log-level")) {
if (arg === "--log-level") i += 1
continue
}
if (arg.startsWith("-") && !arg.startsWith("--")) {
if (arg.includes("h")) return "all"
if (arg.includes("v")) return "none"
return "tui"
}
if (arg.startsWith("-")) return "tui"
return map.get(arg) ?? "tui"
}
return "tui"
}
const mode = pick(args)
const all = mode === "all"
const none = mode === "none"
function load<T>(on: boolean, get: () => Promise<T>): Promise<T | undefined> {
if (!on) {
return Promise.resolve(undefined)
}
return get()
}
const [
TuiThreadCommand,
AttachCommand,
RunCommand,
AcpCommand,
McpCommand,
GenerateCommand,
DebugCommand,
ConsoleCommand,
ProvidersCommand,
AgentCommand,
UpgradeCommand,
UninstallCommand,
ServeCommand,
WebCommand,
ModelsCommand,
StatsCommand,
ExportCommand,
ImportCommand,
GithubCommand,
PrCommand,
SessionCommand,
PluginCommand,
DbCommand,
] = await Promise.all([
load(!none && (all || mode === "tui"), () => import("./cli/cmd/tui/thread").then((x) => x.TuiThreadCommand)),
load(!none && (all || mode === "attach"), () => import("./cli/cmd/tui/attach").then((x) => x.AttachCommand)),
load(!none && (all || mode === "run"), () => import("./cli/cmd/run").then((x) => x.RunCommand)),
load(!none && (all || mode === "acp"), () => import("./cli/cmd/acp").then((x) => x.AcpCommand)),
load(!none && (all || mode === "mcp"), () => import("./cli/cmd/mcp").then((x) => x.McpCommand)),
load(!none && (all || mode === "generate"), () => import("./cli/cmd/generate").then((x) => x.GenerateCommand)),
load(!none && (all || mode === "debug"), () => import("./cli/cmd/debug").then((x) => x.DebugCommand)),
load(!none && (all || mode === "console"), () => import("./cli/cmd/account").then((x) => x.ConsoleCommand)),
load(!none && (all || mode === "providers"), () => import("./cli/cmd/providers").then((x) => x.ProvidersCommand)),
load(!none && (all || mode === "agent"), () => import("./cli/cmd/agent").then((x) => x.AgentCommand)),
load(!none && (all || mode === "upgrade"), () => import("./cli/cmd/upgrade").then((x) => x.UpgradeCommand)),
load(!none && (all || mode === "uninstall"), () => import("./cli/cmd/uninstall").then((x) => x.UninstallCommand)),
load(!none && (all || mode === "serve"), () => import("./cli/cmd/serve").then((x) => x.ServeCommand)),
load(!none && (all || mode === "web"), () => import("./cli/cmd/web").then((x) => x.WebCommand)),
load(!none && (all || mode === "models"), () => import("./cli/cmd/models").then((x) => x.ModelsCommand)),
load(!none && (all || mode === "stats"), () => import("./cli/cmd/stats").then((x) => x.StatsCommand)),
load(!none && (all || mode === "export"), () => import("./cli/cmd/export").then((x) => x.ExportCommand)),
load(!none && (all || mode === "import"), () => import("./cli/cmd/import").then((x) => x.ImportCommand)),
load(!none && (all || mode === "github"), () => import("./cli/cmd/github").then((x) => x.GithubCommand)),
load(!none && (all || mode === "pr"), () => import("./cli/cmd/pr").then((x) => x.PrCommand)),
load(!none && (all || mode === "session"), () => import("./cli/cmd/session").then((x) => x.SessionCommand)),
load(!none && (all || mode === "plugin"), () => import("./cli/cmd/plug").then((x) => x.PluginCommand)),
load(!none && (all || mode === "db"), () => import("./cli/cmd/db").then((x) => x.DbCommand)),
])
function show(out: string) { function show(out: string) {
const text = out.trimStart() const text = out.trimStart()
if (!text.startsWith("opencode ")) { if (!text.startsWith("opencode ")) {
@@ -148,29 +275,100 @@ const cli = yargs(args)
}) })
.usage("") .usage("")
.completion("completion", "generate shell completion script") .completion("completion", "generate shell completion script")
.command(AcpCommand)
.command(McpCommand) if (TuiThreadCommand) {
.command(TuiThreadCommand) cli.command(TuiThreadCommand)
.command(AttachCommand) }
.command(RunCommand)
.command(GenerateCommand) if (AttachCommand) {
.command(DebugCommand) cli.command(AttachCommand)
.command(ConsoleCommand) }
.command(ProvidersCommand)
.command(AgentCommand) if (AcpCommand) {
.command(UpgradeCommand) cli.command(AcpCommand)
.command(UninstallCommand) }
.command(ServeCommand)
.command(WebCommand) if (McpCommand) {
.command(ModelsCommand) cli.command(McpCommand)
.command(StatsCommand) }
.command(ExportCommand)
.command(ImportCommand) if (RunCommand) {
.command(GithubCommand) cli.command(RunCommand)
.command(PrCommand) }
.command(SessionCommand)
.command(PluginCommand) if (GenerateCommand) {
.command(DbCommand) cli.command(GenerateCommand)
}
if (DebugCommand) {
cli.command(DebugCommand)
}
if (ConsoleCommand) {
cli.command(ConsoleCommand)
}
if (ProvidersCommand) {
cli.command(ProvidersCommand)
}
if (AgentCommand) {
cli.command(AgentCommand)
}
if (UpgradeCommand) {
cli.command(UpgradeCommand)
}
if (UninstallCommand) {
cli.command(UninstallCommand)
}
if (ServeCommand) {
cli.command(ServeCommand)
}
if (WebCommand) {
cli.command(WebCommand)
}
if (ModelsCommand) {
cli.command(ModelsCommand)
}
if (StatsCommand) {
cli.command(StatsCommand)
}
if (ExportCommand) {
cli.command(ExportCommand)
}
if (ImportCommand) {
cli.command(ImportCommand)
}
if (GithubCommand) {
cli.command(GithubCommand)
}
if (PrCommand) {
cli.command(PrCommand)
}
if (SessionCommand) {
cli.command(SessionCommand)
}
if (PluginCommand) {
cli.command(PluginCommand)
}
if (DbCommand) {
cli.command(DbCommand)
}
cli
.fail((msg, err) => { .fail((msg, err) => {
if ( if (
msg?.startsWith("Unknown argument") || msg?.startsWith("Unknown argument") ||
@@ -5,7 +5,6 @@ import { iife } from "@/util/iife"
import { Log } from "../../util/log" import { Log } from "../../util/log"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { CopilotModels } from "./models" import { CopilotModels } from "./models"
import { MessageV2 } from "@/session/message-v2"
const log = Log.create({ service: "plugin.copilot" }) const log = Log.create({ service: "plugin.copilot" })
@@ -28,21 +27,6 @@ function base(enterpriseUrl?: string) {
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com" return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"
} }
// Check if a message is a synthetic user msg used to attach an image from a tool call
function imgMsg(msg: any): boolean {
if (msg?.role !== "user") return false
// Handle the 3 api formats
const content = msg.content
if (typeof content === "string") return content === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT
if (!Array.isArray(content)) return false
return content.some(
(part: any) =>
(part?.type === "text" || part?.type === "input_text") && part.text === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT,
)
}
function fix(model: Model, url: string): Model { function fix(model: Model, url: string): Model {
return { return {
...model, ...model,
@@ -106,7 +90,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
(msg: any) => (msg: any) =>
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"), Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
), ),
isAgent: last?.role !== "user" || imgMsg(last), isAgent: last?.role !== "user",
} }
} }
@@ -118,7 +102,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
(item: any) => (item: any) =>
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"), Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
), ),
isAgent: last?.role !== "user" || imgMsg(last), isAgent: last?.role !== "user",
} }
} }
@@ -140,7 +124,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
part.content.some((nested: any) => nested?.type === "image")), part.content.some((nested: any) => nested?.type === "image")),
), ),
), ),
isAgent: !(last?.role === "user" && hasNonToolCalls) || imgMsg(last), isAgent: !(last?.role === "user" && hasNonToolCalls),
} }
} }
} catch {} } catch {}
+1 -3
View File
@@ -25,8 +25,6 @@ interface FetchDecompressionError extends Error {
} }
export namespace MessageV2 { export namespace MessageV2 {
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
export function isMedia(mime: string) { export function isMedia(mime: string) {
return mime.startsWith("image/") || mime === "application/pdf" return mime.startsWith("image/") || mime === "application/pdf"
} }
@@ -810,7 +808,7 @@ export namespace MessageV2 {
parts: [ parts: [
{ {
type: "text" as const, type: "text" as const,
text: SYNTHETIC_ATTACHMENT_PROMPT, text: "Attached image(s) from tool result:",
}, },
...media.map((attachment) => ({ ...media.map((attachment) => ({
type: "file" as const, type: "file" as const,
+2 -70
View File
@@ -6,70 +6,15 @@ export namespace Keybind {
* Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field. * Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field.
* This ensures type compatibility and catches missing fields at compile time. * This ensures type compatibility and catches missing fields at compile time.
*/ */
export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super" | "baseCode"> & { export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super"> & {
leader: boolean // our custom field 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 { export function match(a: Info | undefined, b: Info): boolean {
if (!a) return false if (!a) return false
const normalizedA = { ...a, super: a.super ?? false } const normalizedA = { ...a, super: a.super ?? false }
const normalizedB = { ...b, super: b.super ?? false } const normalizedB = { ...b, super: b.super ?? false }
if (isDeepEqual(normalizedA, normalizedB)) { return 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]!
} }
/** /**
@@ -83,23 +28,10 @@ export namespace Keybind {
meta: key.meta, meta: key.meta,
shift: key.shift, shift: key.shift,
super: key.super ?? false, super: key.super ?? false,
baseCode: key.baseCode,
leader, 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 { export function toString(info: Info | undefined): string {
if (!info) return "" if (!info) return ""
const parts: string[] = [] const parts: string[] = []
-83
View File
@@ -162,24 +162,6 @@ describe("Keybind.match", () => {
expect(Keybind.match(a, b)).toBe(true) 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", () => { test("should match super+shift combination", () => {
const a: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" } 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" } const b: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
@@ -437,68 +419,3 @@ 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)
})
})