introduce opentui keymap as sole key/cmd engine (#26053)
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user