| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290 |
- 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 KIRINCODE_BASE_MODE = "base"
- export const COMMAND_PALETTE_COMMAND = "command.palette.show"
- const KIRINCODE_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(KIRINCODE_MODE_KEY, KIRINCODE_BASE_MODE)
- const offFields = keymap.registerLayerFields({
- mode(value, ctx) {
- ctx.require(KIRINCODE_MODE_KEY, value)
- },
- })
- const stack: { id: symbol; mode: string }[] = []
- let disposed = false
- const update = () => {
- keymap.setData(KIRINCODE_MODE_KEY, stack.at(-1)?.mode ?? KIRINCODE_BASE_MODE)
- }
- const stackApi = {
- current() {
- return stack.at(-1)?.mode ?? KIRINCODE_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(KIRINCODE_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),
- }
- }),
- )
- }
|