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
@@ -49,12 +49,10 @@ import type { WebSearchTool } from "@/tool/websearch"
import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question"
import type { SkillTool } from "@/tool/skill"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useEditorContext } from "@tui/context/editor"
import { useCommandDialog } from "@tui/component/dialog-command"
import type { DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
import { useDialog } from "../../ui/dialog"
import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message"
@@ -90,6 +88,8 @@ import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
import { SessionRetry } from "@/session/retry"
import { getRevertDiffFiles } from "../../util/revert-diff"
import { useCommandPalette } from "../../context/command-palette"
import { useBindings, useCommandShortcut } from "../../keymap"
addDefaultParsers(parsers.parsers)
@@ -124,6 +124,9 @@ export function Session() {
const event = useEvent()
const project = useProject()
const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const kv = useKV()
const { theme } = useTheme()
const promptRef = usePromptRef()
@@ -250,7 +253,7 @@ export function Session() {
seeded = true
r.set(route.prompt)
}
const keybind = useKeybind()
const command = useCommandPalette()
const dialog = useDialog()
const renderer = useRenderer()
@@ -271,7 +274,6 @@ export function Session() {
})
})
// Allow exit when in child session (prompt is hidden)
const exit = useExit()
createEffect(() => {
@@ -293,13 +295,6 @@ export function Session() {
)
})
useKeyboard((evt) => {
if (!session()?.parentID) return
if (keybind.match("app_exit", evt)) {
void exit()
}
})
// Helper: Find next visible message boundary in direction
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
const children = scroll.getChildren()
@@ -382,26 +377,24 @@ export function Session() {
}
}
function childSessionHandler(func: (dialog: DialogContext) => void) {
return (dialog: DialogContext) => {
function childSessionHandler(func: () => void) {
return () => {
if (!session()?.parentID || dialog.stack.length > 0) return
func(dialog)
func()
}
}
const command = useCommandDialog()
command.register(() => [
const sessionCommandList = createMemo(() => [
{
title: session()?.share?.url ? "Copy share link" : "Share session",
value: "session.share",
suggested: route.type === "session",
keybind: "session_share",
category: "Session",
enabled: sync.data.config.share !== "disabled",
slash: {
name: "share",
},
onSelect: async (dialog) => {
run: async () => {
const copy = (url: string) =>
Clipboard.copy(url)
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
@@ -434,24 +427,22 @@ export function Session() {
{
title: "Rename session",
value: "session.rename",
keybind: "session_rename",
category: "Session",
slash: {
name: "rename",
},
onSelect: (dialog) => {
run: () => {
dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
},
},
{
title: "Jump to message",
value: "session.timeline",
keybind: "session_timeline",
category: "Session",
slash: {
name: "timeline",
},
onSelect: (dialog) => {
run: () => {
dialog.replace(() => (
<DialogTimeline
onMove={(messageID) => {
@@ -469,12 +460,11 @@ export function Session() {
{
title: "Fork session",
value: "session.fork",
keybind: "session_fork",
category: "Session",
slash: {
name: "fork",
},
onSelect: (dialog) => {
run: () => {
dialog.replace(() => (
<DialogForkFromTimeline
onMove={(messageID) => {
@@ -492,13 +482,12 @@ export function Session() {
{
title: "Compact session",
value: "session.compact",
keybind: "session_compact",
category: "Session",
slash: {
name: "compact",
aliases: ["summarize"],
},
onSelect: (dialog) => {
run: () => {
const selectedModel = local.model.current()
if (!selectedModel) {
toast.show({
@@ -519,13 +508,12 @@ export function Session() {
{
title: "Unshare session",
value: "session.unshare",
keybind: "session_unshare",
category: "Session",
enabled: !!session()?.share?.url,
slash: {
name: "unshare",
},
onSelect: async (dialog) => {
run: async () => {
await sdk.client.session
.unshare({
sessionID: route.sessionID,
@@ -543,12 +531,11 @@ export function Session() {
{
title: "Undo previous message",
value: "session.undo",
keybind: "messages_undo",
category: "Session",
slash: {
name: "undo",
},
onSelect: async (dialog) => {
run: async () => {
const status = sync.data.session_status?.[route.sessionID]
if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {})
const revert = session()?.revert?.messageID
@@ -581,13 +568,12 @@ export function Session() {
{
title: "Redo",
value: "session.redo",
keybind: "messages_redo",
category: "Session",
enabled: !!session()?.revert?.messageID,
slash: {
name: "redo",
},
onSelect: (dialog) => {
run: () => {
dialog.clear()
const messageID = session()?.revert?.messageID
if (!messageID) return
@@ -608,9 +594,8 @@ export function Session() {
{
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
value: "session.sidebar.toggle",
keybind: "sidebar_toggle",
category: "Session",
onSelect: (dialog) => {
run: () => {
batch(() => {
const isVisible = sidebarVisible()
setSidebar(() => (isVisible ? "hide" : "auto"))
@@ -622,9 +607,8 @@ export function Session() {
{
title: conceal() ? "Disable code concealment" : "Enable code concealment",
value: "session.toggle.conceal",
keybind: "messages_toggle_conceal",
category: "Session",
onSelect: (dialog) => {
run: () => {
setConceal((prev) => !prev)
dialog.clear()
},
@@ -637,7 +621,7 @@ export function Session() {
name: "timestamps",
aliases: ["toggle-timestamps"],
},
onSelect: (dialog) => {
run: () => {
setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
dialog.clear()
},
@@ -645,13 +629,12 @@ export function Session() {
{
title: showThinking() ? "Hide thinking" : "Show thinking",
value: "session.toggle.thinking",
keybind: "display_thinking",
category: "Session",
slash: {
name: "thinking",
aliases: ["toggle-thinking"],
},
onSelect: (dialog) => {
run: () => {
setShowThinking((prev) => !prev)
dialog.clear()
},
@@ -659,9 +642,8 @@ export function Session() {
{
title: showDetails() ? "Hide tool details" : "Show tool details",
value: "session.toggle.actions",
keybind: "tool_details",
category: "Session",
onSelect: (dialog) => {
run: () => {
setShowDetails((prev) => !prev)
dialog.clear()
},
@@ -669,9 +651,8 @@ export function Session() {
{
title: "Toggle session scrollbar",
value: "session.toggle.scrollbar",
keybind: "scrollbar_toggle",
category: "Session",
onSelect: (dialog) => {
run: () => {
setShowScrollbar((prev) => !prev)
dialog.clear()
},
@@ -680,7 +661,7 @@ export function Session() {
title: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output",
value: "session.toggle.generic_tool_output",
category: "Session",
onSelect: (dialog) => {
run: () => {
setShowGenericToolOutput((prev) => !prev)
dialog.clear()
},
@@ -688,10 +669,9 @@ export function Session() {
{
title: "Page up",
value: "session.page.up",
keybind: "messages_page_up",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
scroll.scrollBy(-scroll.height / 2)
dialog.clear()
},
@@ -699,10 +679,9 @@ export function Session() {
{
title: "Page down",
value: "session.page.down",
keybind: "messages_page_down",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
scroll.scrollBy(scroll.height / 2)
dialog.clear()
},
@@ -710,10 +689,9 @@ export function Session() {
{
title: "Line up",
value: "session.line.up",
keybind: "messages_line_up",
category: "Session",
disabled: true,
onSelect: (dialog) => {
enabled: false,
run: () => {
scroll.scrollBy(-1)
dialog.clear()
},
@@ -721,10 +699,9 @@ export function Session() {
{
title: "Line down",
value: "session.line.down",
keybind: "messages_line_down",
category: "Session",
disabled: true,
onSelect: (dialog) => {
enabled: false,
run: () => {
scroll.scrollBy(1)
dialog.clear()
},
@@ -732,10 +709,9 @@ export function Session() {
{
title: "Half page up",
value: "session.half.page.up",
keybind: "messages_half_page_up",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
scroll.scrollBy(-scroll.height / 4)
dialog.clear()
},
@@ -743,10 +719,9 @@ export function Session() {
{
title: "Half page down",
value: "session.half.page.down",
keybind: "messages_half_page_down",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
scroll.scrollBy(scroll.height / 4)
dialog.clear()
},
@@ -754,10 +729,9 @@ export function Session() {
{
title: "First message",
value: "session.first",
keybind: "messages_first",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
scroll.scrollTo(0)
dialog.clear()
},
@@ -765,10 +739,9 @@ export function Session() {
{
title: "Last message",
value: "session.last",
keybind: "messages_last",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
scroll.scrollTo(scroll.scrollHeight)
dialog.clear()
},
@@ -776,10 +749,9 @@ export function Session() {
{
title: "Jump to last user message",
value: "session.messages_last_user",
keybind: "messages_last_user",
category: "Session",
hidden: true,
onSelect: () => {
run: () => {
const messages = sync.data.message[route.sessionID]
if (!messages || !messages.length) return
@@ -808,25 +780,22 @@ export function Session() {
{
title: "Next message",
value: "session.message.next",
keybind: "messages_next",
category: "Session",
hidden: true,
onSelect: (dialog) => scrollToMessage("next", dialog),
run: () => scrollToMessage("next", dialog),
},
{
title: "Previous message",
value: "session.message.previous",
keybind: "messages_previous",
category: "Session",
hidden: true,
onSelect: (dialog) => scrollToMessage("prev", dialog),
run: () => scrollToMessage("prev", dialog),
},
{
title: "Copy last assistant message",
value: "messages.copy",
keybind: "messages_copy",
category: "Session",
onSelect: (dialog) => {
run: () => {
const revertID = session()?.revert?.messageID
const lastAssistantMessage = messages().findLast(
(msg) => msg.role === "assistant" && (!revertID || msg.id < revertID),
@@ -871,7 +840,7 @@ export function Session() {
slash: {
name: "copy",
},
onSelect: async (dialog) => {
run: async () => {
try {
const sessionData = session()
if (!sessionData) return
@@ -897,12 +866,11 @@ export function Session() {
{
title: "Export session transcript",
value: "session.export",
keybind: "session_export",
category: "Session",
slash: {
name: "export",
},
onSelect: async (dialog) => {
run: async () => {
try {
const sessionData = session()
if (!sessionData) return
@@ -959,10 +927,9 @@ export function Session() {
{
title: "Go to child session",
value: "session.child.first",
keybind: "session_child_first",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
moveFirstChild()
dialog.clear()
},
@@ -970,11 +937,10 @@ export function Session() {
{
title: "Go to parent session",
value: "session.parent",
keybind: "session_parent",
category: "Session",
hidden: true,
enabled: !!session()?.parentID,
onSelect: childSessionHandler((dialog) => {
run: childSessionHandler(() => {
const parentID = session()?.parentID
if (parentID) {
navigate({
@@ -988,11 +954,10 @@ export function Session() {
{
title: "Next child session",
value: "session.child.next",
keybind: "session_child_cycle",
category: "Session",
hidden: true,
enabled: !!session()?.parentID,
onSelect: childSessionHandler((dialog) => {
run: childSessionHandler(() => {
moveChild(1)
dialog.clear()
}),
@@ -1000,17 +965,36 @@ export function Session() {
{
title: "Previous child session",
value: "session.child.previous",
keybind: "session_child_cycle_reverse",
category: "Session",
hidden: true,
enabled: !!session()?.parentID,
onSelect: childSessionHandler((dialog) => {
run: childSessionHandler(() => {
moveChild(-1)
dialog.clear()
}),
},
])
const sessionCommands = createMemo(() =>
sessionCommandList().map((command) => ({
namespace: "palette",
name: command.value,
desc: "description" in command ? command.description : undefined,
slashName: "slash" in command ? command.slash?.name : undefined,
slashAliases: "slash" in command ? command.slash?.aliases : undefined,
...command,
})),
)
useBindings(() => ({
commands: sessionCommands(),
}))
useBindings(() => ({
enabled: command.matcher,
bindings: sections.session,
}))
const revertInfo = createMemo(() => session()?.revert)
const revertMessageID = createMemo(() => revertInfo()?.messageID)
@@ -1082,7 +1066,8 @@ export function Session() {
<Switch>
<Match when={message.id === revert()?.messageID}>
{(function () {
const command = useCommandDialog()
const command = useCommandPalette()
const redoShortcut = useCommandShortcut("session.redo")
const [hover, setHover] = createSignal(false)
const dialog = useDialog()
@@ -1093,7 +1078,7 @@ export function Session() {
"Are you sure you want to restore the reverted messages?",
)
if (confirmed) {
command.trigger("session.redo")
command.run("session.redo")
}
}
@@ -1116,7 +1101,7 @@ export function Session() {
>
<text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
<text fg={theme.textMuted}>
<span style={{ fg: theme.text }}>{keybind.print("messages_redo")}</span> or /redo to
<span style={{ fg: theme.text }}>{redoShortcut()}</span> or /redo to
restore
</text>
<Show when={revert()!.diffFiles?.length}>
@@ -1370,7 +1355,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
return props.message.time.completed - user.time.created
})
const keybind = useKeybind()
const childShortcut = useCommandShortcut("session.child.first")
return (
<>
@@ -1392,7 +1377,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
<Show when={props.parts.some((x) => x.type === "tool" && x.tool === "task")}>
<box paddingTop={1} paddingLeft={3}>
<text fg={theme.text}>
{keybind.print("session_child_first")}
{childShortcut()}
<span style={{ fg: theme.textMuted }}> view subagents</span>
</text>
</box>
@@ -1,24 +1,22 @@
import { createStore } from "solid-js/store"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
import { useTheme, selectedForeground } from "../../context/theme"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useSync } from "../../context/sync"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import { useProject } from "../../context/project"
import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Global } from "@opencode-ai/core/global"
import { ShellID } from "@/tool/shell/id"
import { useDialog } from "../../ui/dialog"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
import { useBindings, useCommandShortcut } from "../../keymap"
type PermissionStage = "permission" | "always" | "reject"
@@ -463,25 +461,27 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) {
let input: TextareaRenderable
const { theme } = useTheme()
const keybind = useKeybind()
const textareaKeybindings = useTextareaKeybindings()
const tuiConfig = useTuiConfig()
const keymapConfig = tuiConfig.keymap
const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80)
const dialog = useDialog()
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
props.onCancel()
return
}
if (evt.name === "return") {
evt.preventDefault()
props.onConfirm(input.plainText)
}
})
useBindings(() => ({
enabled: dialog.stack.length === 0,
commands: [
{
name: "permission.reject.cancel",
run() {
props.onCancel()
},
},
],
bindings: [
{ key: "escape", cmd: () => props.onCancel() },
...keymapConfig.pick("permission", ["permission.reject.cancel"]),
{ key: "return", cmd: () => props.onConfirm(input.plainText) },
],
}))
return (
<box
@@ -520,7 +520,6 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
keyBindings={textareaKeybindings()}
/>
<box flexDirection="row" gap={2} flexShrink={0}>
<text fg={theme.text}>
@@ -545,50 +544,75 @@ function Prompt<const T extends Record<string, string>>(props: {
onSelect: (option: keyof T) => void
}) {
const { theme } = useTheme()
const keybind = useKeybind()
const tuiConfig = useTuiConfig()
const keymapConfig = tuiConfig.keymap
const dimensions = useTerminalDimensions()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
selected: keys[0],
expanded: false,
})
const diffKey = Keybind.parse("ctrl+f")[0]
const narrow = createMemo(() => dimensions().width < 80)
const dialog = useDialog()
const fullscreenHint = useCommandShortcut("permission.prompt.fullscreen")
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
if (evt.name === "left" || evt.name == "h") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
}
if (evt.name === "right" || evt.name == "l") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
}
if (evt.name === "return") {
evt.preventDefault()
props.onSelect(store.selected)
}
if (props.escapeKey && (evt.name === "escape" || keybind.match("app_exit", evt))) {
evt.preventDefault()
props.onSelect(props.escapeKey)
}
if (props.fullscreen && diffKey && Keybind.match(diffKey, keybind.parse(evt))) {
evt.preventDefault()
evt.stopPropagation()
setStore("expanded", (v) => !v)
}
})
useBindings(() => ({
enabled: dialog.stack.length === 0,
commands: [
{
name: "permission.prompt.escape",
run() {
if (!props.escapeKey) return
props.onSelect(props.escapeKey)
},
},
{
name: "permission.prompt.fullscreen",
run() {
if (!props.fullscreen) return
setStore("expanded", (v) => !v)
},
},
],
bindings: [
{
key: "left",
cmd: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
},
},
{
key: "h",
cmd: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
},
},
{
key: "right",
cmd: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
},
},
{
key: "l",
cmd: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
},
},
{ key: "return", cmd: () => props.onSelect(store.selected) },
...(props.escapeKey ? [{ key: "escape", cmd: () => props.onSelect(props.escapeKey!) }] : []),
...(props.escapeKey ? keymapConfig.pick("permission", ["permission.prompt.escape"]) : []),
...(props.fullscreen ? keymapConfig.pick("permission", ["permission.prompt.fullscreen"]) : []),
],
}))
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
useRenderer()
@@ -661,7 +685,7 @@ function Prompt<const T extends Record<string, string>>(props: {
<box flexDirection="row" gap={2} flexShrink={0}>
<Show when={props.fullscreen}>
<text fg={theme.text}>
{"ctrl+f"} <span style={{ fg: theme.textMuted }}>{hint()}</span>
{fullscreenHint()} <span style={{ fg: theme.textMuted }}>{hint()}</span>
</text>
</Show>
<text fg={theme.text}>
@@ -1,20 +1,22 @@
import { createStore } from "solid-js/store"
import { createMemo, createSignal, For, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
import { selectedForeground, tint, useTheme } from "../../context/theme"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import { useDialog } from "../../ui/dialog"
import { useTuiConfig } from "../../context/tui-config"
import { useBindings } from "../../keymap"
export function QuestionPrompt(props: { request: QuestionRequest }) {
const sdk = useSDK()
const { theme } = useTheme()
const keybind = useKeybind()
const bindings = useTextareaKeybindings()
const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const keymapConfig = tuiConfig.keymap
const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
@@ -122,131 +124,124 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const dialog = useDialog()
useKeyboard((evt) => {
// Skip processing if a dialog (e.g., command palette) is open
if (dialog.stack.length > 0) return
// When editing custom answer textarea
if (store.editing && !confirm()) {
if (evt.name === "escape") {
evt.preventDefault()
setStore("editing", false)
return
}
if (keybind.match("input_clear", evt)) {
evt.preventDefault()
const text = textarea?.plainText ?? ""
if (!text) {
useBindings(() => ({
enabled: store.editing && !confirm(),
commands: [
{
name: "question.edit.clear",
run() {
const text = textarea?.plainText ?? ""
if (!text) {
setStore("editing", false)
return
}
textarea?.setText("")
},
},
],
bindings: [
{
key: "escape",
cmd: () => {
setStore("editing", false)
return
}
textarea?.setText("")
return
}
if (evt.name === "return") {
evt.preventDefault()
const text = textarea?.plainText?.trim() ?? ""
const prev = store.custom[store.tab]
},
},
...keymapConfig.pick("question", ["question.edit.clear"]),
{
key: "return",
cmd: () => {
const text = textarea?.plainText?.trim() ?? ""
const prev = store.custom[store.tab]
if (!text) {
if (prev) {
if (!text) {
if (prev) {
const inputs = [...store.custom]
inputs[store.tab] = ""
setStore("custom", inputs)
const answers = [...store.answers]
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
setStore("answers", answers)
}
setStore("editing", false)
return
}
if (multi()) {
const inputs = [...store.custom]
inputs[store.tab] = ""
inputs[store.tab] = text
setStore("custom", inputs)
const existing = store.answers[store.tab] ?? []
const next = [...existing]
if (prev) {
const index = next.indexOf(prev)
if (index !== -1) next.splice(index, 1)
}
if (!next.includes(text)) next.push(text)
const answers = [...store.answers]
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
answers[store.tab] = next
setStore("answers", answers)
setStore("editing", false)
return
}
pick(text, true)
setStore("editing", false)
return
}
},
},
],
}))
if (multi()) {
const inputs = [...store.custom]
inputs[store.tab] = text
setStore("custom", inputs)
useBindings(() => {
const opts = options()
const total = opts.length + (custom() ? 1 : 0)
const max = Math.min(total, 9)
const existing = store.answers[store.tab] ?? []
const next = [...existing]
if (prev) {
const index = next.indexOf(prev)
if (index !== -1) next.splice(index, 1)
}
if (!next.includes(text)) next.push(text)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
setStore("editing", false)
return
}
pick(text, true)
setStore("editing", false)
return
}
// Let textarea handle all other keys
return
}
if (evt.name === "left" || evt.name === "h") {
evt.preventDefault()
selectTab((store.tab - 1 + tabs()) % tabs())
}
if (evt.name === "right" || evt.name === "l") {
evt.preventDefault()
selectTab((store.tab + 1) % tabs())
}
if (evt.name === "tab") {
evt.preventDefault()
const direction = evt.shift ? -1 : 1
selectTab((store.tab + direction + tabs()) % tabs())
}
if (confirm()) {
if (evt.name === "return") {
evt.preventDefault()
submit()
}
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
reject()
}
} else {
const opts = options()
const total = opts.length + (custom() ? 1 : 0)
const max = Math.min(total, 9)
const digit = Number(evt.name)
if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
evt.preventDefault()
const index = digit - 1
moveTo(index)
selectOption()
return
}
if (evt.name === "up" || evt.name === "k") {
evt.preventDefault()
moveTo((store.selected - 1 + total) % total)
}
if (evt.name === "down" || evt.name === "j") {
evt.preventDefault()
moveTo((store.selected + 1) % total)
}
if (evt.name === "return") {
evt.preventDefault()
selectOption()
}
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
reject()
}
return {
enabled: dialog.stack.length === 0 && !store.editing,
commands: [
{
name: "question.reject",
run() {
reject()
},
},
],
bindings: [
{ key: "left", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) },
{ key: "h", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) },
{ key: "right", cmd: () => selectTab((store.tab + 1) % tabs()) },
{ key: "l", cmd: () => selectTab((store.tab + 1) % tabs()) },
{
key: "tab",
cmd: ({ event }: { event: { shift: boolean } }) => {
selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs())
},
},
...(confirm()
? [
{ key: "return", cmd: () => submit() },
{ key: "escape", cmd: () => reject() },
...sections.question,
]
: [
...Array.from({ length: max }, (_, index) => ({
key: String(index + 1),
cmd: () => {
moveTo(index)
selectOption()
},
})),
{ key: "up", cmd: () => moveTo((store.selected - 1 + total) % total) },
{ key: "k", cmd: () => moveTo((store.selected - 1 + total) % total) },
{ key: "down", cmd: () => moveTo((store.selected + 1) % total) },
{ key: "j", cmd: () => moveTo((store.selected + 1) % total) },
{ key: "return", cmd: () => selectOption() },
{ key: "escape", cmd: () => reject() },
...sections.question,
]),
],
}
})
@@ -394,7 +389,6 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
keyBindings={bindings()}
/>
</box>
</Show>
@@ -4,10 +4,10 @@ import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useKeybind } from "../../context/keybind"
import { Locale } from "@/util/locale"
import { useTerminalDimensions } from "@opentui/solid"
import { useCommandPalette } from "../../context/command-palette"
import { useCommandShortcut } from "../../keymap"
export function SubagentFooter() {
const route = useRouteData("session")
@@ -56,8 +56,10 @@ export function SubagentFooter() {
})
const { theme } = useTheme()
const keybind = useKeybind()
const command = useCommandDialog()
const command = useCommandPalette()
const parentShortcut = useCommandShortcut("session.parent")
const previousShortcut = useCommandShortcut("session.child.previous")
const nextShortcut = useCommandShortcut("session.child.next")
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
useTerminalDimensions()
@@ -96,31 +98,31 @@ export function SubagentFooter() {
<box
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.parent")}
onMouseUp={() => command.run("session.parent")}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
Parent <span style={{ fg: theme.textMuted }}>{parentShortcut()}</span>
</text>
</box>
<box
onMouseOver={() => setHover("prev")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.previous")}
onMouseUp={() => command.run("session.child.previous")}
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
Prev <span style={{ fg: theme.textMuted }}>{previousShortcut()}</span>
</text>
</box>
<box
onMouseOver={() => setHover("next")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.next")}
onMouseUp={() => command.run("session.child.next")}
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
Next <span style={{ fg: theme.textMuted }}>{nextShortcut()}</span>
</text>
</box>
</box>