mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 12:15:51 -04:00
291 lines
8.6 KiB
TypeScript
291 lines
8.6 KiB
TypeScript
import { InputRenderable, TextareaRenderable, type CliRenderer, type KeyEvent, type Renderable } from "@opentui/core"
|
|
import {
|
|
registerBackspacePopsPendingSequence,
|
|
registerBaseLayoutFallback,
|
|
registerCommaBindings,
|
|
registerEscapeClearsPendingSequence,
|
|
registerManagedTextareaLayer,
|
|
registerTimedLeader,
|
|
} from "@opentui/keymap/addons/opentui"
|
|
import { stringifyKeyStroke, type Binding } from "@opentui/keymap"
|
|
import {
|
|
formatCommandBindings as formatCommandBindingsExtra,
|
|
formatKeySequence as formatKeySequenceExtra,
|
|
} from "@opentui/keymap/extras"
|
|
import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@opentui/keymap/solid"
|
|
import { createMemo, type Accessor } from "solid-js"
|
|
import { useTuiConfig } from "./config"
|
|
import { TuiKeybind } from "./config/keybind"
|
|
|
|
export const LEADER_TOKEN = "leader"
|
|
export const OPENCODE_BASE_MODE = "base"
|
|
export const COMMAND_PALETTE_COMMAND = "command.palette.show"
|
|
|
|
const OPENCODE_MODE_KEY = "opencode.mode"
|
|
|
|
export const OpencodeKeymapProvider = KeymapProvider
|
|
export const useOpencodeKeymap = useKeymap
|
|
|
|
export { useBindings, useKeymapSelector }
|
|
|
|
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
|
|
type OpencodeModeStack = ReturnType<typeof createOpencodeModeStack>
|
|
type CommandSlashEntry = {
|
|
display: string
|
|
description?: string
|
|
aliases?: string[]
|
|
onSelect: () => void
|
|
}
|
|
type Command = ReturnType<OpenTuiKeymap["getCommands"]>[number]
|
|
type BindingLookup = {
|
|
get(command: string): readonly Binding<Renderable, KeyEvent>[]
|
|
gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
|
|
}
|
|
type FormatConfig = { keybinds: BindingLookup }
|
|
type ResolvedKeymapConfig = FormatConfig & { leader_timeout: number }
|
|
|
|
const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
|
|
|
|
function isVisiblePaletteCommand(command: Command) {
|
|
return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND
|
|
}
|
|
|
|
export function createOpencodeModeStack(keymap: OpenTuiKeymap) {
|
|
keymap.setData(OPENCODE_MODE_KEY, OPENCODE_BASE_MODE)
|
|
|
|
const offFields = keymap.registerLayerFields({
|
|
mode(value, ctx) {
|
|
ctx.require(OPENCODE_MODE_KEY, value)
|
|
},
|
|
})
|
|
|
|
const stack: { id: symbol; mode: string }[] = []
|
|
let disposed = false
|
|
|
|
const update = () => {
|
|
keymap.setData(OPENCODE_MODE_KEY, stack.at(-1)?.mode ?? OPENCODE_BASE_MODE)
|
|
}
|
|
|
|
const stackApi = {
|
|
current() {
|
|
return stack.at(-1)?.mode ?? OPENCODE_BASE_MODE
|
|
},
|
|
push(mode: string) {
|
|
if (disposed) return () => {}
|
|
const id = Symbol(mode)
|
|
let active = true
|
|
stack.push({ id, mode })
|
|
update()
|
|
|
|
return () => {
|
|
if (!active) return
|
|
active = false
|
|
const index = stack.findIndex((item) => item.id === id)
|
|
if (index !== -1) stack.splice(index, 1)
|
|
update()
|
|
}
|
|
},
|
|
dispose() {
|
|
if (disposed) return
|
|
disposed = true
|
|
stack.length = 0
|
|
offFields()
|
|
keymap.setData(OPENCODE_MODE_KEY, undefined)
|
|
modeStacks.delete(keymap)
|
|
},
|
|
}
|
|
|
|
modeStacks.set(keymap, stackApi)
|
|
return stackApi
|
|
}
|
|
|
|
export function useOpencodeModeStack() {
|
|
return getOpencodeModeStack(useOpencodeKeymap())
|
|
}
|
|
|
|
export function getOpencodeModeStack(keymap: OpenTuiKeymap) {
|
|
const value = modeStacks.get(keymap)
|
|
if (!value) throw new Error("Opencode mode stack is not registered for this keymap")
|
|
return value
|
|
}
|
|
|
|
const KEY_ALIASES = {
|
|
enter: "return",
|
|
esc: "escape",
|
|
pgdown: "pagedown",
|
|
pgup: "pageup",
|
|
} as const
|
|
|
|
function expandKeyAliases(input: string) {
|
|
const result = Object.entries(KEY_ALIASES).reduce(
|
|
(acc, [alias, key]) => acc.replace(new RegExp(`(^|[+,\\s>])${alias}(?=$|[+,\\s<])`, "gi"), `$1${key}`),
|
|
input,
|
|
)
|
|
if (result === input) return
|
|
return result
|
|
}
|
|
|
|
function registerKeyAliases(keymap: OpenTuiKeymap) {
|
|
return keymap.appendBindingExpander((ctx) => {
|
|
const key = expandKeyAliases(ctx.input)
|
|
if (!key) return
|
|
return [{ key, displays: ctx.displays }]
|
|
})
|
|
}
|
|
|
|
const inputCommands = [
|
|
"input.move.left",
|
|
"input.move.right",
|
|
"input.move.up",
|
|
"input.move.down",
|
|
"input.select.left",
|
|
"input.select.right",
|
|
"input.select.up",
|
|
"input.select.down",
|
|
"input.line.home",
|
|
"input.line.end",
|
|
"input.select.line.home",
|
|
"input.select.line.end",
|
|
"input.visual.line.home",
|
|
"input.visual.line.end",
|
|
"input.select.visual.line.home",
|
|
"input.select.visual.line.end",
|
|
"input.buffer.home",
|
|
"input.buffer.end",
|
|
"input.select.buffer.home",
|
|
"input.select.buffer.end",
|
|
"input.delete.line",
|
|
"input.delete.to.line.end",
|
|
"input.delete.to.line.start",
|
|
"input.backspace",
|
|
"input.delete",
|
|
"input.newline",
|
|
"input.undo",
|
|
"input.redo",
|
|
"input.word.forward",
|
|
"input.word.backward",
|
|
"input.select.word.forward",
|
|
"input.select.word.backward",
|
|
"input.delete.word.forward",
|
|
"input.delete.word.backward",
|
|
"input.select.all",
|
|
"input.submit",
|
|
] as const
|
|
|
|
function hasManagedTextareaFocus(renderer: CliRenderer) {
|
|
const editor = renderer.currentFocusedEditor
|
|
return editor instanceof TextareaRenderable && !(editor instanceof InputRenderable)
|
|
}
|
|
|
|
function leaderDisplay(config: FormatConfig) {
|
|
const key = config.keybinds.get(LEADER_TOKEN)?.[0]?.key
|
|
if (!key) return TuiKeybind.LeaderDefault
|
|
return typeof key === "string" ? key : stringifyKeyStroke(key)
|
|
}
|
|
|
|
function leaderKey(config: FormatConfig) {
|
|
return config.keybinds.get(LEADER_TOKEN)?.[0]?.key
|
|
}
|
|
|
|
function formatOptions(config: FormatConfig) {
|
|
return {
|
|
tokenDisplay: {
|
|
[LEADER_TOKEN]: leaderDisplay(config),
|
|
},
|
|
keyNameAliases: {
|
|
pageup: "pgup",
|
|
pagedown: "pgdn",
|
|
delete: "del",
|
|
},
|
|
modifierAliases: {
|
|
meta: "alt",
|
|
},
|
|
} as const
|
|
}
|
|
|
|
export function formatKeySequence(parts: Parameters<typeof formatKeySequenceExtra>[0], config: FormatConfig) {
|
|
return formatKeySequenceExtra(parts, formatOptions(config))
|
|
}
|
|
|
|
export function formatKeyBindings(bindings: Parameters<typeof formatCommandBindingsExtra>[0], config: FormatConfig) {
|
|
return formatCommandBindingsExtra(bindings, formatOptions(config))
|
|
}
|
|
|
|
export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: ResolvedKeymapConfig) {
|
|
const modeStack = createOpencodeModeStack(keymap)
|
|
const offCommaBindings = registerCommaBindings(keymap)
|
|
const offAliasExpander = registerKeyAliases(keymap)
|
|
const offBaseLayout = registerBaseLayoutFallback(keymap)
|
|
const leader = leaderKey(config)
|
|
const offLeader = leader
|
|
? registerTimedLeader(keymap, {
|
|
trigger: leader,
|
|
name: LEADER_TOKEN,
|
|
timeoutMs: config.leader_timeout,
|
|
})
|
|
: () => {}
|
|
const offEscape = registerEscapeClearsPendingSequence(keymap)
|
|
const offBackspace = registerBackspacePopsPendingSequence(keymap)
|
|
const offInputBindings = registerManagedTextareaLayer(keymap, renderer, {
|
|
enabled: () => hasManagedTextareaFocus(renderer),
|
|
bindings: config.keybinds.gather("input", inputCommands),
|
|
})
|
|
|
|
return () => {
|
|
offInputBindings()
|
|
offBackspace()
|
|
offEscape()
|
|
offLeader()
|
|
offAliasExpander()
|
|
offBaseLayout()
|
|
offCommaBindings()
|
|
modeStack.dispose()
|
|
}
|
|
}
|
|
|
|
export function useLeaderActive(): Accessor<boolean> {
|
|
return useKeymapSelector((keymap: OpenTuiKeymap) => keymap.getPendingSequence()[0]?.tokenName === LEADER_TOKEN)
|
|
}
|
|
|
|
export function useCommandShortcut(command: string): Accessor<string> {
|
|
const config = useTuiConfig()
|
|
return useKeymapSelector((keymap: OpenTuiKeymap) =>
|
|
formatKeySequence(
|
|
keymap.getCommandBindings({ visibility: "registered", commands: [command] }).get(command)?.[0]?.sequence,
|
|
config,
|
|
),
|
|
)
|
|
}
|
|
|
|
export function useCommandSlashes(): Accessor<readonly CommandSlashEntry[]> {
|
|
const keymap = useOpencodeKeymap()
|
|
const entries = useKeymapSelector((keymap: OpenTuiKeymap) =>
|
|
keymap.getCommandEntries({
|
|
visibility: "reachable",
|
|
namespace: "palette",
|
|
filter: isVisiblePaletteCommand,
|
|
}),
|
|
)
|
|
|
|
return createMemo<CommandSlashEntry[]>(() =>
|
|
entries().flatMap((entry) => {
|
|
const slashName = entry.command.slashName
|
|
if (typeof slashName !== "string" || !slashName) return []
|
|
const slashAliases = entry.command.slashAliases
|
|
return {
|
|
display: `/${slashName}`,
|
|
description:
|
|
typeof entry.command.desc === "string"
|
|
? entry.command.desc
|
|
: typeof entry.command.title === "string"
|
|
? entry.command.title
|
|
: undefined,
|
|
aliases: Array.isArray(slashAliases)
|
|
? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
|
|
: undefined,
|
|
onSelect: () => keymap.dispatchCommand(entry.command.name),
|
|
}
|
|
}),
|
|
)
|
|
}
|