introduce opentui keymap as sole key/cmd engine (#26053)

This commit is contained in:
Sebastian
2026-05-07 20:35:31 +02:00
committed by GitHub
parent 474e311f6f
commit 98f5e6e713
67 changed files with 3858 additions and 2977 deletions
@@ -1,172 +0,0 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select"
import {
createContext,
createMemo,
createSignal,
getOwner,
onCleanup,
runWithOwner,
useContext,
type Accessor,
type ParentProps,
} from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useKeybind } from "@tui/context/keybind"
type Context = ReturnType<typeof init>
const ctx = createContext<Context>()
export type Slash = {
name: string
aliases?: string[]
}
export type CommandOption = DialogSelectOption<string> & {
keybind?: string
suggested?: boolean
slash?: Slash
hidden?: boolean
enabled?: boolean
}
function init() {
const root = getOwner()
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const [suspendCount, setSuspendCount] = createSignal(0)
const dialog = useDialog()
const keybind = useKeybind()
const entries = createMemo(() => {
const all = registrations().flatMap((x) => x())
return all.map((x) => ({
...x,
footer: x.keybind ? keybind.print(x.keybind) : undefined,
}))
})
const isEnabled = (option: CommandOption) => option.enabled !== false
const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden
const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option)))
const suggestedOptions = createMemo(() =>
visibleOptions()
.filter((option) => option.suggested)
.map((option) => ({
...option,
value: `suggested:${option.value}`,
category: "Suggested",
})),
)
const suspended = () => suspendCount() > 0
useKeyboard((evt) => {
if (suspended()) return
if (dialog.stack.length > 0) return
if (evt.defaultPrevented) return
for (const option of entries()) {
if (!isEnabled(option)) continue
if (option.keybind && keybind.match(option.keybind, evt)) {
evt.preventDefault()
option.onSelect?.(dialog)
return
}
}
})
const result = {
trigger(name: string) {
for (const option of entries()) {
if (option.value === name) {
if (!isEnabled(option)) return
option.onSelect?.(dialog)
return
}
}
},
slashes() {
return visibleOptions().flatMap((option) => {
const slash = option.slash
if (!slash) return []
return {
display: "/" + slash.name,
description: option.description ?? option.title,
aliases: slash.aliases?.map((alias) => "/" + alias),
onSelect: () => result.trigger(option.value),
}
})
},
keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1))
},
suspended,
show() {
dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
},
register(cb: () => CommandOption[]) {
const owner = getOwner() ?? root
if (!owner) return () => {}
let list: Accessor<CommandOption[]> | undefined
// TUI plugins now register commands via an async store that runs outside an active reactive scope.
// runWithOwner attaches createMemo/onCleanup to this owner so plugin registrations stay reactive and dispose correctly.
runWithOwner(owner, () => {
list = createMemo(cb)
const ref = list
if (!ref) return
setRegistrations((arr) => [ref, ...arr])
onCleanup(() => {
setRegistrations((arr) => arr.filter((x) => x !== ref))
})
})
if (!list) return () => {}
let done = false
return () => {
if (done) return
done = true
const ref = list
if (!ref) return
setRegistrations((arr) => arr.filter((x) => x !== ref))
}
},
}
return result
}
export function useCommandDialog() {
const value = useContext(ctx)
if (!value) {
throw new Error("useCommandDialog must be used within a CommandProvider")
}
return value
}
export function CommandProvider(props: ParentProps) {
const value = init()
const dialog = useDialog()
const keybind = useKeybind()
useKeyboard((evt) => {
if (value.suspended()) return
if (dialog.stack.length > 0) return
if (evt.defaultPrevented) return
if (keybind.match("command_list", evt)) {
evt.preventDefault()
value.show()
return
}
})
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) {
let ref: DialogSelectRef<string>
const list = () => {
if (ref?.filter) return props.options
return [...props.suggestedOptions, ...props.options]
}
return <DialogSelect ref={(r) => (ref = r)} title="Commands" options={list()} />
}
@@ -1,5 +1,4 @@
import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import open from "open"
import { createSignal, onCleanup, onMount } from "solid-js"
import { selectedForeground, useTheme } from "@tui/context/theme"
@@ -7,6 +6,7 @@ import { useDialog, type DialogContext } from "@tui/ui/dialog"
import { Link } from "@tui/ui/link"
import { GoLogo } from "./logo"
import { BgPulse, type BgPulseMask } from "./bg-pulse"
import { useBindings } from "../keymap"
const GO_URL = "https://opencode.ai/go"
const PAD_X = 3
@@ -71,18 +71,29 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync)
})
useKeyboard((evt) => {
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe"))
return
}
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
if (selected() === "subscribe") subscribe(props, dialog)
else dismiss(props, dialog)
}
})
useBindings(() => ({
bindings: [
{
key: "left",
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
},
{
key: "right",
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
},
{
key: "tab",
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
},
{
key: "return",
cmd: () => {
if (selected() === "subscribe") subscribe(props, dialog)
else dismiss(props, dialog)
},
},
],
}))
return (
<box ref={(item: BoxRenderable) => (content = item)}>
@@ -4,7 +4,6 @@ import { useSync } from "@tui/context/sync"
import { map, pipe, entries, sortBy } from "remeda"
import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useTheme } from "../context/theme"
import { Keybind } from "@/util/keybind"
import { TextAttributes } from "@opentui/core"
import { useSDK } from "@tui/context/sdk"
@@ -45,9 +44,9 @@ export function DialogMcp() {
)
})
const keybinds = createMemo(() => [
const actions = createMemo(() => [
{
keybind: Keybind.parse("space")[0],
command: "dialog.action.toggle",
title: "toggle",
onTrigger: async (option: DialogSelectOption<string>) => {
// Prevent toggling while an operation is already in progress
@@ -77,7 +76,7 @@ export function DialogMcp() {
ref={setRef}
title="MCPs"
options={options()}
keybind={keybinds()}
actions={actions()}
onSelect={(_option) => {
// Don't close on select, only on escape
}}
@@ -6,15 +6,15 @@ import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogVariant } from "./dialog-variant"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
import { useTuiConfig } from "../context/tui-config"
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const sync = useSync()
const dialog = useDialog()
const keybind = useKeybind()
const tuiConfig = useTuiConfig()
const [query, setQuery] = createSignal("")
const connected = useConnected()
@@ -150,16 +150,16 @@ export function DialogModel(props: { providerID?: string }) {
return (
<DialogSelect<ReturnType<typeof options>[number]["value"]>
options={options()}
keybind={[
actions={[
{
keybind: keybind.all.model_provider_list?.[0],
command: "model.dialog.provider",
title: connected() ? "Connect provider" : "View all providers",
onTrigger() {
dialog.replace(() => <DialogProvider />)
},
},
{
keybind: keybind.all.model_favorite_toggle?.[0],
command: "model.dialog.favorite",
title: "Favorite",
disabled: !connected(),
onTrigger: (option) => {
@@ -167,6 +167,7 @@ export function DialogModel(props: { providerID?: string }) {
},
},
]}
bindings={tuiConfig.keymap.sections.model}
onFilter={setQuery}
flat={true}
skipFilter={true}
@@ -10,11 +10,11 @@ import { useTheme } from "../context/theme"
import { TextAttributes } from "@opentui/core"
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid"
import * as Clipboard from "@tui/util/clipboard"
import { useToast } from "../ui/toast"
import { isConsoleManagedProvider } from "@tui/util/provider-origin"
import { useConnected } from "./use-connected"
import { useBindings } from "../keymap"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
@@ -239,14 +239,19 @@ function AutoMethod(props: AutoMethodProps) {
const sync = useSync()
const toast = useToast()
useKeyboard((evt) => {
if (evt.name === "c" && !evt.ctrl && !evt.meta) {
const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
Clipboard.copy(code)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
}
})
useBindings(() => ({
bindings: [
{
key: "c",
cmd: () => {
const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
Clipboard.copy(code)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
},
},
],
}))
onMount(async () => {
const result = await sdk.client.provider.oauth.callback({
@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useBindings } from "../keymap"
export function DialogSessionDeleteFailed(props: {
session: string
@@ -40,19 +40,15 @@ export function DialogSessionDeleteFailed(props: {
if (!props.onDone) dialog.clear()
}
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
void confirm()
}
if (evt.name === "left" || evt.name === "up") {
setStore("active", "delete")
}
if (evt.name === "right" || evt.name === "down") {
setStore("active", "restore")
}
})
useBindings(() => ({
bindings: [
{ key: "return", cmd: () => void confirm() },
{ key: "left", cmd: () => setStore("active", "delete") },
{ key: "up", cmd: () => setStore("active", "delete") },
{ key: "right", cmd: () => setStore("active", "restore") },
{ key: "down", cmd: () => setStore("active", "restore") },
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
@@ -5,7 +5,6 @@ import { useSync } from "@tui/context/sync"
import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js"
import { Locale } from "@/util/locale"
import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -17,18 +16,19 @@ import { Spinner } from "./spinner"
import { errorMessage } from "@/util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
import { WorkspaceLabel } from "./workspace-label"
import { useCommandShortcut } from "../keymap"
export function DialogSessionList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const project = useProject()
const keybind = useKeybind()
const { theme } = useTheme()
const sdk = useSDK()
const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const deleteHint = useCommandShortcut("dialog.action.delete")
const [searchResults, { refetch }] = createResource(
() => ({ query: search(), filter: sync.session.query() }),
@@ -156,7 +156,7 @@ export function DialogSessionList() {
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy"
return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
@@ -187,9 +187,9 @@ export function DialogSessionList() {
})
dialog.clear()
}}
keybind={[
actions={[
{
keybind: keybind.all.session_delete?.[0],
command: "dialog.action.delete",
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
@@ -237,7 +237,7 @@ export function DialogSessionList() {
},
},
{
keybind: keybind.all.session_rename?.[0],
command: "dialog.action.rename",
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
@@ -3,8 +3,8 @@ import { DialogSelect } from "@tui/ui/dialog-select"
import { createMemo, createSignal } from "solid-js"
import { Locale } from "@/util/locale"
import { useTheme } from "../context/theme"
import { useKeybind } from "../context/keybind"
import { usePromptStash, type StashEntry } from "./prompt/stash"
import { useCommandShortcut } from "../keymap"
function getRelativeTime(timestamp: number): string {
const now = Date.now()
@@ -30,9 +30,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const dialog = useDialog()
const stash = usePromptStash()
const { theme } = useTheme()
const keybind = useKeybind()
const [toDelete, setToDelete] = createSignal<number>()
const deleteHint = useCommandShortcut("dialog.action.delete")
const options = createMemo(() => {
const entries = stash.list()
@@ -42,7 +42,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const isDeleting = toDelete() === index
const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
return {
title: isDeleting ? `Press ${keybind.print("stash_delete")} again to confirm` : getStashPreview(entry.input),
title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.input),
bg: isDeleting ? theme.error : undefined,
value: index,
description: getRelativeTime(entry.timestamp),
@@ -68,9 +68,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
}
dialog.clear()
}}
keybind={[
actions={[
{
keybind: keybind.all.stash_delete?.[0],
command: "dialog.action.delete",
title: "delete",
onTrigger: (option) => {
if (toDelete() === option.value) {
@@ -1,9 +1,9 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { useBindings } from "../keymap"
export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
const dialog = useDialog()
@@ -23,25 +23,13 @@ export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean |
if (result === false) return
}
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
void confirm()
return
}
if (evt.name === "left") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "cancel")
return
}
if (evt.name === "right") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "restore")
}
})
useBindings(() => ({
bindings: [
{ key: "return", cmd: () => void confirm() },
{ key: "left", cmd: () => setStore("active", "cancel") },
{ key: "right", cmd: () => setStore("active", "restore") },
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
@@ -1,4 +1,4 @@
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
import type { BoxRenderable, TextareaRenderable, ScrollBoxRenderable } from "@opentui/core"
import { pathToFileURL } from "bun"
import fuzzysort from "fuzzysort"
import path from "path"
@@ -12,11 +12,12 @@ import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
import { useTheme, selectedForeground } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useCommandPalette } from "../../context/command-palette"
import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "@/util/locale"
import type { PromptInfo } from "./history"
import { useFrecency } from "./frecency"
import { useBindings } from "../../keymap"
function removeLineRange(input: string) {
const hashIndex = input.lastIndexOf("#")
@@ -52,7 +53,6 @@ function extractLineRange(input: string) {
export type AutocompleteRef = {
onInput: (value: string) => void
onKeyDown: (e: KeyEvent) => void
visible: false | "@" | "/"
}
@@ -82,12 +82,14 @@ export function Autocomplete(props: {
const editor = useEditorContext()
const sdk = useSDK()
const sync = useSync()
const command = useCommandDialog()
const command = useCommandPalette()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const frecency = useFrecency()
const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const [store, setStore] = createStore({
index: 0,
selected: 0,
@@ -282,7 +284,7 @@ export function Autocomplete(props: {
const { filename, part } = createFilePart(item, lineRange)
const index = store.visible === "@" ? store.index : props.input().cursorOffset
command.keybinds(true)
command.suspend(false)
setStore("visible", false)
setStore("index", index)
insertPart(filename, part)
@@ -520,8 +522,54 @@ export function Autocomplete(props: {
setStore("selected", 0)
}
useBindings(() => ({
target: props.input,
enabled: () => Boolean(store.visible),
commands: [
{
name: "prompt.autocomplete.prev",
run() {
setStore("input", "keyboard")
move(-1)
},
},
{
name: "prompt.autocomplete.next",
run() {
setStore("input", "keyboard")
move(1)
},
},
{
name: "prompt.autocomplete.hide",
run() {
hide()
},
},
{
name: "prompt.autocomplete.select",
run() {
select()
},
},
{
name: "prompt.autocomplete.complete",
run() {
const selected = options()[store.selected]
if (selected?.isDirectory) {
expandDirectory()
return
}
select()
},
},
],
bindings: sections.autocomplete,
}))
function show(mode: "@" | "/") {
command.keybinds(false)
command.suspend(true)
setStore({
visible: mode,
index: props.input().cursorOffset,
@@ -538,7 +586,7 @@ export function Autocomplete(props: {
draft.input = props.input().plainText
})
}
command.keybinds(true)
command.suspend(false)
setStore("visible", false)
}
@@ -593,60 +641,6 @@ export function Autocomplete(props: {
setStore("index", idx)
}
},
onKeyDown(e: KeyEvent) {
if (store.visible) {
const name = e.name?.toLowerCase()
const ctrlOnly = e.ctrl && !e.meta && !e.shift
const isNavUp = name === "up" || (ctrlOnly && name === "p")
const isNavDown = name === "down" || (ctrlOnly && name === "n")
if (isNavUp) {
setStore("input", "keyboard")
move(-1)
e.preventDefault()
return
}
if (isNavDown) {
setStore("input", "keyboard")
move(1)
e.preventDefault()
return
}
if (name === "escape") {
hide()
e.preventDefault()
return
}
if (name === "return") {
select()
e.preventDefault()
return
}
if (name === "tab") {
const selected = options()[store.selected]
if (selected?.isDirectory) {
expandDirectory()
} else {
select()
}
e.preventDefault()
return
}
}
if (!store.visible) {
if (e.name === "@") {
const cursorOffset = props.input().cursorOffset
const charBeforeCursor =
cursorOffset === 0 ? undefined : props.input().getTextRange(cursorOffset - 1, cursorOffset)
const canTrigger = charBeforeCursor === undefined || charBeforeCursor === "" || /\s/.test(charBeforeCursor)
if (canTrigger) show("@")
}
if (e.name === "/") {
if (props.input().cursorOffset === 0) show("/")
}
}
},
})
})
@@ -1,4 +1,14 @@
import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
import {
BoxRenderable,
RGBA,
TextareaRenderable,
MouseEvent,
PasteEvent,
decodePasteBytes,
type KeyEvent,
type Renderable,
} from "@opentui/core"
import type { CommandContext } from "@opentui/keymap"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid"
import path from "path"
@@ -16,14 +26,12 @@ import { useEvent } from "@tui/context/event"
import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { computePromptTraits } from "./traits"
import { assign } from "./part"
import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import * as Editor from "@tui/util/editor"
import { useExit } from "../../context/exit"
@@ -40,7 +48,6 @@ import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import {
confirmWorkspaceFileChanges,
@@ -51,7 +58,15 @@ import {
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
import { Flag } from "@opencode-ai/core/flag/flag"
import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
import { type WorkspaceStatus } from "../workspace-label"
import { useCommandPalette } from "../../context/command-palette"
import {
useBindings,
useCommandShortcut,
useLeaderActive,
useOpencodeKeymap,
} from "../../keymap"
import { useTuiConfig } from "../../context/tui-config"
export type PromptProps = {
sessionID?: string
@@ -124,9 +139,9 @@ let stashed: { prompt: PromptInfo; cursor: number } | undefined
export function Prompt(props: PromptProps) {
let input: TextareaRenderable
let anchor: BoxRenderable
let autocomplete: AutocompleteRef
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
const keybind = useKeybind()
const leader = useLeaderActive()
const local = useLocal()
const args = useArgs()
const sdk = useSDK()
@@ -134,12 +149,17 @@ export function Prompt(props: PromptProps) {
const route = useRoute()
const project = useProject()
const sync = useSync()
const tuiConfig = useTuiConfig()
const keymapConfig = tuiConfig.keymap
const dialog = useDialog()
const toast = useToast()
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
const history = usePromptHistory()
const stash = usePromptStash()
const command = useCommandDialog()
const command = useCommandPalette()
const keymap = useOpencodeKeymap()
const agentShortcut = useCommandShortcut("agent.cycle")
const paletteShortcut = useCommandShortcut("command.palette.show")
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const { theme, syntax } = useTheme()
@@ -184,6 +204,7 @@ export function Prompt(props: PromptProps) {
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
const [warpNotice, setWarpNotice] = createSignal<string>()
const [cursorVersion, setCursorVersion] = createSignal(0)
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
const defaultWorkspaceID = createMemo(() => props.workspaceID ?? project.workspace.current())
@@ -287,9 +308,6 @@ export function Prompt(props: PromptProps) {
setDismissedEditorSelectionKey(editorSelectionKey(editorContext()))
editor.clearSelection()
}
const textareaKeybindings = useTextareaKeybindings()
const fileStyleId = syntax().getStyleId("extmark.file")!
const agentStyleId = syntax().getStyleId("extmark.agent")!
const pasteStyleId = syntax().getStyleId("extmark.paste")!
@@ -391,26 +409,30 @@ export function Prompt(props: PromptProps) {
}
})
command.register(() => {
return [
const promptCommands = createMemo(() =>
[
{
title: "Clear prompt",
value: "prompt.clear",
name: "prompt.clear",
category: "Prompt",
hidden: true,
onSelect: (dialog) => {
input.extmarks.clear()
run: () => {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
dialog.clear()
},
},
{
title: "Submit prompt",
value: "prompt.submit",
keybind: "input_submit",
name: "prompt.submit",
category: "Prompt",
hidden: true,
onSelect: async (dialog) => {
run: async () => {
if (!input.focused) return
const handled = await submit()
if (!handled) return
@@ -420,21 +442,22 @@ export function Prompt(props: PromptProps) {
},
{
title: "Remove editor context",
value: "prompt.editor_context.clear",
name: "prompt.editor_context.clear",
category: "Prompt",
enabled: Boolean(editorContext()),
onSelect: (dialog) => {
run: () => {
dismissEditorContext()
dialog.clear()
},
},
{
title: "Paste",
value: "prompt.paste",
keybind: "input_paste",
name: "prompt.paste",
category: "Prompt",
hidden: true,
onSelect: async () => {
run: async (ctx: CommandContext<Renderable, KeyEvent>) => {
ctx.event.preventDefault()
ctx.event.stopPropagation()
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
await pasteAttachment({
@@ -442,18 +465,21 @@ export function Prompt(props: PromptProps) {
mime: content.mime,
content: content.data,
})
return
}
if (content?.mime === "text/plain") {
await pasteInputText(content.data)
}
},
},
{
title: "Interrupt session",
value: "session.interrupt",
keybind: "session_interrupt",
name: "session.interrupt",
category: "Session",
hidden: true,
enabled: status().type !== "idle",
onSelect: (dialog) => {
if (autocomplete.visible) return
run: () => {
if (auto()?.visible) return
if (!input.focused) return
// TODO: this should be its own command
if (store.mode === "shell") {
@@ -480,12 +506,9 @@ export function Prompt(props: PromptProps) {
{
title: "Open editor",
category: "Session",
keybind: "editor_open",
value: "prompt.editor",
slash: {
name: "editor",
},
onSelect: async (dialog) => {
name: "prompt.editor",
slashName: "editor",
run: async () => {
dialog.clear()
// replace summarized text parts with the actual text
@@ -566,12 +589,10 @@ export function Prompt(props: PromptProps) {
},
{
title: "Skills",
value: "prompt.skills",
name: "prompt.skills",
category: "Prompt",
slash: {
name: "skills",
},
onSelect: () => {
slashName: "skills",
run: () => {
dialog.replace(() => (
<DialogSkill
onSelect={(skill) => {
@@ -588,14 +609,12 @@ export function Prompt(props: PromptProps) {
},
{
title: "Warp",
description: "Change the workspace for the session",
value: "workspace.set",
desc: "Change the workspace for the session",
name: "workspace.set",
category: "Session",
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
slash: {
name: "warp",
},
onSelect: (dialog) => {
slashName: "warp",
run: () => {
void openWorkspaceSelect({
dialog,
sdk,
@@ -607,8 +626,29 @@ export function Prompt(props: PromptProps) {
})
},
},
]
})
].map((entry) => ({
namespace: "palette",
...entry,
})),
)
useBindings(() => ({
commands: promptCommands(),
}))
useBindings(() => ({
enabled: command.matcher,
bindings: keymapConfig.pick("prompt", [
"prompt.submit",
"prompt.editor",
"prompt.editor_context.clear",
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
"session.interrupt",
"workspace.set",
]),
}))
const ref: PromptRef = {
get focused() {
@@ -659,6 +699,7 @@ export function Prompt(props: PromptProps) {
if (store.prompt.input) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
setInputTarget(undefined)
props.ref?.(undefined)
})
@@ -676,11 +717,14 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
input.traits = computePromptTraits({
mode: store.mode,
disabled: !!props.disabled,
autocompleteVisible: !!auto()?.visible,
})
input.traits = {
...input.traits,
...computePromptTraits({
mode: store.mode,
disabled: !!props.disabled,
autocompleteVisible: !!auto()?.visible,
}),
}
})
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
@@ -761,60 +805,195 @@ export function Prompt(props: PromptProps) {
)
}
command.register(() => [
{
title: "Stash prompt",
value: "prompt.stash",
category: "Prompt",
enabled: !!store.prompt.input,
onSelect: (dialog) => {
if (!store.prompt.input) return
stash.push({
input: store.prompt.input,
parts: store.prompt.parts,
})
input.extmarks.clear()
input.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
dialog.clear()
const stashCommands = createMemo(() =>
[
{
title: "Stash prompt",
name: "prompt.stash",
category: "Prompt",
enabled: !!store.prompt.input,
run: () => {
if (!store.prompt.input) return
stash.push({
input: store.prompt.input,
parts: store.prompt.parts,
})
input.extmarks.clear()
input.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
dialog.clear()
},
},
},
{
title: "Stash pop",
value: "prompt.stash.pop",
category: "Prompt",
enabled: stash.list().length > 0,
onSelect: (dialog) => {
const entry = stash.pop()
if (entry) {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
}
dialog.clear()
{
title: "Stash pop",
name: "prompt.stash.pop",
category: "Prompt",
enabled: stash.list().length > 0,
run: () => {
const entry = stash.pop()
if (entry) {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
}
dialog.clear()
},
},
},
{
title: "Stash list",
value: "prompt.stash.list",
category: "Prompt",
enabled: stash.list().length > 0,
onSelect: (dialog) => {
dialog.replace(() => (
<DialogStash
onSelect={(entry) => {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
}}
/>
))
{
title: "Stash list",
name: "prompt.stash.list",
category: "Prompt",
enabled: stash.list().length > 0,
run: () => {
dialog.replace(() => (
<DialogStash
onSelect={(entry) => {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
}}
/>
))
},
},
},
])
].map((entry) => ({
namespace: "palette",
...entry,
})),
)
useBindings(() => ({
commands: stashCommands(),
}))
useBindings(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled,
bindings: keymapConfig.pick("prompt", ["prompt.paste"]),
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "",
bindings: keymapConfig.pick("prompt", ["prompt.clear"]),
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && !props.disabled && store.mode === "normal" && !auto()?.visible && input?.visualCursor.offset === 0
})(),
bindings: [
{
key: "!",
cmd: () => {
setStore("placeholder", randomIndex(shell().length))
setStore("mode", "shell")
},
},
],
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && store.mode === "shell",
bindings: [{ key: "escape", cmd: () => setStore("mode", "normal") }],
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
})(),
bindings: [{ key: "backspace", cmd: () => setStore("mode", "normal") }],
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: (() => {
cursorVersion()
return (
inputTarget() !== undefined &&
!props.disabled &&
!auto()?.visible &&
input !== undefined &&
(input.cursorOffset === 0 || input.visualCursor.visualRow === 0)
)
})(),
commands: [
{
name: "prompt.history.previous",
run() {
if (input.cursorOffset !== 0) {
input.cursorOffset = 0
return
}
const item = history.move(-1, input.plainText)
if (!item) return
input.setText(item.input)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromParts(item.parts)
input.cursorOffset = 0
},
},
],
bindings: keymapConfig.pick("prompt", ["prompt.history.previous"]),
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: (() => {
cursorVersion()
return (
inputTarget() !== undefined &&
!props.disabled &&
!auto()?.visible &&
input !== undefined &&
(input.cursorOffset === input.plainText.length || input.visualCursor.visualRow === input.height - 1)
)
})(),
commands: [
{
name: "prompt.history.next",
run() {
if (input.cursorOffset !== input.plainText.length) {
input.cursorOffset = input.plainText.length
return
}
const item = history.move(1, input.plainText)
if (!item) return
input.setText(item.input)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromParts(item.parts)
input.cursorOffset = input.plainText.length
},
},
],
bindings: keymapConfig.pick("prompt", ["prompt.history.next"]),
}
})
async function submit() {
setWarpNotice(undefined)
@@ -828,7 +1007,7 @@ export function Prompt(props: PromptProps) {
}
if (props.disabled) return false
if (workspaceCreating()) return false
if (autocomplete?.visible) return false
if (auto()?.visible) return false
if (!store.prompt.input) return false
const agent = local.agent.current()
if (!agent) return false
@@ -1068,6 +1247,66 @@ export function Prompt(props: PromptProps) {
)
}
async function pasteInputText(text: string) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
const filepath = iife(() => {
const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
if (raw.startsWith("file://")) {
try {
return fileURLToPath(raw)
} catch {}
}
if (process.platform === "win32") return raw
return raw.replace(/\\(.)/g, "$1")
})
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
try {
const mime = await Filesystem.mimeType(filepath)
const filename = path.basename(filepath)
if (mime === "image/svg+xml") {
const content = await Filesystem.readText(filepath).catch(() => {})
if (content) {
pasteText(content, `[SVG: ${filename ?? "image"}]`)
return
}
}
if (mime.startsWith("image/") || mime === "application/pdf") {
const content = await Filesystem.readArrayBuffer(filepath)
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
if (content) {
await pasteAttachment({
filename,
filepath,
mime,
content,
})
return
}
}
} catch {}
}
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (
(lineCount >= 3 || pastedContent.length > 150) &&
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
) {
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
}
input.insertText(normalizedText)
setTimeout(() => {
if (!input || input.isDestroyed) return
input.getLayoutNode().markDirty()
renderer.requestRender()
}, 0)
}
async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) {
const currentOffset = input.visualCursor.offset
const extmarkStart = currentOffset
@@ -1117,7 +1356,7 @@ export function Prompt(props: PromptProps) {
}
const highlight = createMemo(() => {
if (keybind.leader) return theme.border
if (leader()) return theme.border
if (store.mode === "shell") return theme.primary
const agent = local.agent.current()
if (!agent) return theme.border
@@ -1206,30 +1445,7 @@ export function Prompt(props: PromptProps) {
return (
<>
<Autocomplete
sessionID={props.sessionID}
ref={(r) => {
autocomplete = r
setAuto(() => r)
}}
anchor={() => anchor}
input={() => input}
setPrompt={(cb) => {
setStore("prompt", produce(cb))
}}
setExtmark={(partIndex, extmarkId) => {
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
const newMap = new Map(map)
newMap.set(extmarkId, partIndex)
return newMap
})
}}
value={store.prompt.input}
fileStyleId={fileStyleId}
agentStyleId={agentStyleId}
promptPartTypeId={() => promptPartTypeId}
/>
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
<box ref={(r: BoxRenderable) => (anchor = r)} visible={props.visible !== false}>
<box
border={["left"]}
borderColor={borderHighlight()}
@@ -1249,94 +1465,23 @@ export function Prompt(props: PromptProps) {
<textarea
placeholder={placeholderText()}
placeholderColor={theme.textMuted}
textColor={keybind.leader ? theme.textMuted : theme.text}
focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
textColor={leader() ? theme.textMuted : theme.text}
focusedTextColor={leader() ? theme.textMuted : theme.text}
minHeight={1}
maxHeight={6}
onContentChange={() => {
const value = input.plainText
setStore("prompt", "input", value)
autocomplete.onInput(value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
setCursorVersion((value) => value + 1)
}}
keyBindings={textareaKeybindings()}
onKeyDown={async (e) => {
onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={(e: { preventDefault(): void }) => {
if (props.disabled) {
e.preventDefault()
return
}
// Check clipboard for images before terminal-handled paste runs.
// This helps terminals that forward Ctrl+V to the app; Windows
// Terminal 1.25+ usually handles Ctrl+V before this path.
if (keybind.match("input_paste", e)) {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
e.preventDefault()
await pasteAttachment({
filename: "clipboard",
mime: content.mime,
content: content.data,
})
return
}
// If no image, let the default paste behavior continue
}
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
return
}
if (keybind.match("app_exit", e)) {
if (store.prompt.input === "") {
await exit()
// Don't preventDefault - let textarea potentially handle the event
e.preventDefault()
return
}
}
if (e.name === "!" && input.visualCursor.offset === 0) {
setStore("placeholder", randomIndex(shell().length))
setStore("mode", "shell")
e.preventDefault()
return
}
if (store.mode === "shell") {
if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
setStore("mode", "normal")
e.preventDefault()
return
}
}
if (store.mode === "normal") autocomplete.onKeyDown(e)
if (!autocomplete.visible) {
if (
(keybind.match("history_previous", e) && input.cursorOffset === 0) ||
(keybind.match("history_next", e) && input.cursorOffset === input.plainText.length)
) {
const direction = keybind.match("history_previous", e) ? -1 : 1
const item = history.move(direction, input.plainText)
if (item) {
input.setText(item.input)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromParts(item.parts)
e.preventDefault()
if (direction === -1) input.cursorOffset = 0
if (direction === 1) input.cursorOffset = input.plainText.length
}
return
}
if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0
if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1)
input.cursorOffset = input.plainText.length
}
}}
onSubmit={() => {
// IME: double-defer so the last composed character (e.g. Korean
@@ -1358,7 +1503,7 @@ export function Prompt(props: PromptProps) {
// Windows Terminal <1.25 can surface image-only clipboard as an
// empty bracketed paste. Windows Terminal 1.25+ does not.
if (!pastedContent) {
command.trigger("prompt.paste")
keymap.dispatchCommand("prompt.paste")
return
}
@@ -1366,67 +1511,11 @@ export function Prompt(props: PromptProps) {
// default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault()
const filepath = iife(() => {
const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
if (raw.startsWith("file://")) {
try {
return fileURLToPath(raw)
} catch {}
}
if (process.platform === "win32") return raw
return raw.replace(/\\(.)/g, "$1")
})
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
try {
const mime = await Filesystem.mimeType(filepath)
const filename = path.basename(filepath)
// Handle SVG as raw text content, not as base64 image
if (mime === "image/svg+xml") {
const content = await Filesystem.readText(filepath).catch(() => {})
if (content) {
pasteText(content, `[SVG: ${filename ?? "image"}]`)
return
}
}
if (mime.startsWith("image/") || mime === "application/pdf") {
const content = await Filesystem.readArrayBuffer(filepath)
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
if (content) {
await pasteAttachment({
filename,
filepath,
mime,
content,
})
return
}
}
} catch {}
}
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (
(lineCount >= 3 || pastedContent.length > 150) &&
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
) {
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
}
input.insertText(normalizedText)
// Force layout update and render for the pasted content
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.getLayoutNode().markDirty()
renderer.requestRender()
}, 0)
await pasteInputText(normalizedText)
}}
ref={(r: TextareaRenderable) => {
input = r
setInputTarget(r)
if (promptPartTypeId === 0) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
}
@@ -1455,7 +1544,7 @@ export function Prompt(props: PromptProps) {
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
<text
flexShrink={0}
fg={fadeColor(keybind.leader ? theme.textMuted : theme.text, modelMetaAlpha())}
fg={fadeColor(leader() ? theme.textMuted : theme.text, modelMetaAlpha())}
>
{local.model.parsed().model}
</text>
@@ -1646,12 +1735,12 @@ export function Prompt(props: PromptProps) {
</Match>
<Match when={true}>
<text fg={theme.text}>
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
</text>
</Match>
</Switch>
<text fg={theme.text}>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
@@ -1664,6 +1753,28 @@ export function Prompt(props: PromptProps) {
</Show>
</box>
</box>
<Autocomplete
sessionID={props.sessionID}
ref={(r) => {
setAuto(() => r)
}}
anchor={() => anchor}
input={() => input}
setPrompt={(cb) => {
setStore("prompt", produce(cb))
}}
setExtmark={(partIndex, extmarkId) => {
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
const newMap = new Map(map)
newMap.set(extmarkId, partIndex)
return newMap
})
}}
value={store.prompt.input}
fileStyleId={fileStyleId}
agentStyleId={agentStyleId}
promptPartTypeId={() => promptPartTypeId}
/>
</>
)
}
@@ -8,6 +8,11 @@ export interface PromptTraitsInput {
autocompleteVisible: boolean
}
export type PromptTraits = EditorTraits & {
owner: "opencode"
role: "prompt"
}
/**
* Compute the textarea editor traits for the prompt.
*
@@ -16,7 +21,7 @@ export interface PromptTraitsInput {
* editing mode only `disabled` should suspend the textarea, otherwise
* users can type in shell mode but cannot delete or move the cursor.
*/
export function computePromptTraits(input: PromptTraitsInput): EditorTraits {
export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
const capture =
input.mode === "normal"
? input.autocompleteVisible
@@ -27,5 +32,7 @@ export function computePromptTraits(input: PromptTraitsInput): EditorTraits {
capture,
suspend: input.disabled,
status: input.mode === "shell" ? "SHELL" : undefined,
owner: "opencode",
role: "prompt",
}
}
@@ -1,73 +0,0 @@
import { createMemo } from "solid-js"
import type { KeyBinding } from "@opentui/core"
import { useKeybind } from "../context/keybind"
import { Keybind } from "@/util/keybind"
const TEXTAREA_ACTIONS = [
"submit",
"newline",
"move-left",
"move-right",
"move-up",
"move-down",
"select-left",
"select-right",
"select-up",
"select-down",
"line-home",
"line-end",
"select-line-home",
"select-line-end",
"visual-line-home",
"visual-line-end",
"select-visual-line-home",
"select-visual-line-end",
"buffer-home",
"buffer-end",
"select-buffer-home",
"select-buffer-end",
"delete-line",
"delete-to-line-end",
"delete-to-line-start",
"backspace",
"delete",
"undo",
"redo",
"word-forward",
"word-backward",
"select-word-forward",
"select-word-backward",
"delete-word-forward",
"delete-word-backward",
] as const
function mapTextareaKeybindings(
keybinds: Record<string, Keybind.Info[]>,
action: (typeof TEXTAREA_ACTIONS)[number],
): KeyBinding[] {
const configKey = `input_${action.replace(/-/g, "_")}`
const bindings = keybinds[configKey]
if (!bindings) return []
return bindings.map((binding) => ({
name: binding.name,
ctrl: binding.ctrl || undefined,
meta: binding.meta || undefined,
shift: binding.shift || undefined,
super: binding.super || undefined,
action,
}))
}
export function useTextareaKeybindings() {
const keybind = useKeybind()
return createMemo(() => {
const keybinds = keybind.all
return [
{ name: "return", action: "submit" },
{ name: "return", meta: true, action: "newline" },
...TEXTAREA_ACTIONS.flatMap((action) => mapTextareaKeybindings(keybinds, action)),
] satisfies KeyBinding[]
})
}