fix(tui): prevent keybind dispatch during modal prompts

Tab key during QuestionPrompt/PermissionPrompt triggered agent_cycle
because CommandProvider useKeyboard handler lacked guards for active
modal state. Root cause: opentui fires all global keyboard handlers in
mount order (parent first), so parent handler processed Tab before
child could preventDefault.

Fix: suspend command keybinds while QuestionPrompt or PermissionPrompt
is active (matching existing autocomplete.tsx pattern), and add
defaultPrevented guard as defense-in-depth. Clamp suspendCount to >= 0
to prevent mismatched enable/disable calls.
pull/17141/head
chocothin 2026-03-12 13:41:22 +09:00
parent 7b0def4b81
commit 11916aeea1
3 changed files with 14 additions and 3 deletions

View File

@ -60,6 +60,7 @@ function init() {
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)) {
@ -93,7 +94,7 @@ function init() {
})
},
keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1))
setSuspendCount((count) => Math.max(0, count + (enabled ? -1 : 1)))
},
suspended,
show() {

View File

@ -1,5 +1,5 @@
import { createStore } from "solid-js/store"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { createMemo, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
@ -16,6 +16,7 @@ import { Locale } from "@/util/locale"
import { Global } from "@/global"
import { useDialog } from "../../ui/dialog"
import { useTuiConfig } from "../../context/tui-config"
import { useCommandDialog } from "../../component/dialog-command"
type PermissionStage = "permission" | "always" | "reject"
@ -129,10 +130,14 @@ function TextBody(props: { title: string; description?: string; icon?: string })
export function PermissionPrompt(props: { request: PermissionRequest }) {
const sdk = useSDK()
const sync = useSync()
const command = useCommandDialog()
const [store, setStore] = createStore({
stage: "permission" as PermissionStage,
})
onMount(() => command.keybinds(false))
onCleanup(() => command.keybinds(true))
const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID))
const input = createMemo(() => {

View File

@ -1,5 +1,5 @@
import { createStore } from "solid-js/store"
import { createMemo, createSignal, For, Show } from "solid-js"
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
@ -9,12 +9,17 @@ import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import { useDialog } from "../../ui/dialog"
import { useCommandDialog } from "../../component/dialog-command"
export function QuestionPrompt(props: { request: QuestionRequest }) {
const sdk = useSDK()
const { theme } = useTheme()
const keybind = useKeybind()
const bindings = useTextareaKeybindings()
const command = useCommandDialog()
onMount(() => command.keybinds(false))
onCleanup(() => command.keybinds(true))
const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)