From 90cd910f52f6df0ae49b09570740a3c8e384bb39 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 4 Sep 2026 16:07:11 -0400 Subject: [PATCH] feat(tui): add shared interactivity context (#47144) --- packages/tui/src/component/prompt/index.tsx | 52 ++-- packages/tui/src/context/interactivity.tsx | 20 ++ packages/tui/src/context/keymap.tsx | 47 +-- packages/tui/src/routes/session/form.tsx | 29 +- .../tui/src/routes/session/permission.tsx | 4 +- .../tui/test/cli/tui/input-scope.test.tsx | 279 ++++++++++++++++++ packages/tui/test/interactivity.test.tsx | 46 +++ packages/tui/test/keymap-scope.test.tsx | 271 +++++++++++++++++ 8 files changed, 707 insertions(+), 41 deletions(-) create mode 100644 packages/tui/src/context/interactivity.tsx create mode 100644 packages/tui/test/cli/tui/input-scope.test.tsx create mode 100644 packages/tui/test/interactivity.test.tsx create mode 100644 packages/tui/test/keymap-scope.test.tsx diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 0e5275f5f1e..e5486ac2656 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -54,6 +54,7 @@ import { resolvePastedAttachments } from "./local-attachment" import { locationKey, useData } from "../../context/data" import { useLocation } from "../../context/location" import { Keymap, type KeymapCommand } from "../../context/keymap" +import { useInteractivity } from "../../context/interactivity" import { abbreviateHome } from "../../runtime" import { Slot } from "../../plugin/render" import type { SessionInbox } from "@opencode-ai/schema/session-inbox" @@ -186,6 +187,8 @@ export function Prompt(props: PromptProps) { let anchor: BoxRenderable const [inputTarget, setInputTarget] = createSignal() + const enabled = useInteractivity() + const disabled = () => props.disabled || !enabled() const leader = Keymap.useLeaderActive() const muted = () => leader() || props.muted const local = useLocal() @@ -257,6 +260,7 @@ export function Prompt(props: PromptProps) { const [pendingDirectory, setPendingDirectory] = createSignal() Keymap.createLayer(() => ({ mode: "global", + enabled: !disabled(), commands: [ { id: "session.cd", @@ -346,8 +350,7 @@ export function Prompt(props: PromptProps) { createEffect(() => { if (!input || input.isDestroyed) return - if (props.disabled) input.cursorColor = theme.background.surface.offset - if (!props.disabled) input.cursorColor = theme.text.default + input.cursorColor = disabled() ? theme.background.surface.offset : theme.text.default if (config.cursor) input.cursorStyle = config.cursor }) @@ -370,12 +373,13 @@ export function Prompt(props: PromptProps) { function enqueuePaste(run: (changed: () => boolean) => Promise) { pasteQueue = pasteQueue .then(async () => { - if (disposed || input.isDestroyed) return + if (disposed || input.isDestroyed || disabled()) return const before = { sessionID: props.sessionID, mode: store.mode, text: input.plainText } await run( () => disposed || input.isDestroyed || + disabled() || props.sessionID !== before.sessionID || store.mode !== before.mode || input.plainText !== before.text, @@ -634,15 +638,18 @@ export function Prompt(props: PromptProps) { Keymap.createLayer(() => ({ mode: "global", + enabled: !disabled(), commands: promptCommands(), })) Keymap.createLayer(() => ({ priority: 1, + enabled: !disabled(), bindings: ["prompt.queue"], })) Keymap.createLayer(() => ({ + enabled: !disabled(), bindings: [ "prompt.submit", "prompt.editor", @@ -660,12 +667,13 @@ export function Prompt(props: PromptProps) { const ref: PromptRef = { get focused() { - return input.focused + return !disabled() && input.focused }, get current() { return store.prompt }, focus() { + if (disabled()) return input.focus() }, blur() { @@ -719,11 +727,13 @@ export function Prompt(props: PromptProps) { createEffect(() => { if (!input || input.isDestroyed) return - if (props.visible === false || props.disabled || dialog.stack.length > 0) { + if (props.visible === false || disabled() || dialog.stack.length > 0) { if (input.focused) input.blur() + input.focusable = false return } + input.focusable = true // Slot/plugin updates can remount the background prompt while a dialog is open. // Keep focus with the dialog and let the prompt reclaim it after the dialog closes. if (!input.focused) input.focus() @@ -919,13 +929,14 @@ export function Prompt(props: PromptProps) { Keymap.createLayer(() => ({ mode: "global", + enabled: !disabled(), commands: stashCommands(), })) Keymap.createLayer(() => { return { target: inputTarget, - enabled: inputTarget() !== undefined && !props.disabled, + enabled: inputTarget() !== undefined && !disabled(), bindings: ["prompt.paste"], } }) @@ -933,7 +944,7 @@ export function Prompt(props: PromptProps) { Keymap.createLayer(() => { return { target: inputTarget, - enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "", + enabled: inputTarget() !== undefined && !disabled() && store.prompt.text !== "", bindings: ["prompt.clear"], } }) @@ -945,7 +956,7 @@ export function Prompt(props: PromptProps) { cursorVersion() return ( inputTarget() !== undefined && - !props.disabled && + !disabled() && store.mode === "normal" && !auto()?.visible && input?.visualCursor.offset === 0 @@ -969,7 +980,7 @@ export function Prompt(props: PromptProps) { return { priority: 1, target: inputTarget, - enabled: inputTarget() !== undefined && store.mode === "shell", + enabled: inputTarget() !== undefined && !disabled() && store.mode === "shell", commands: [ { bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") }, { @@ -988,7 +999,7 @@ export function Prompt(props: PromptProps) { target: inputTarget, enabled: (() => { cursorVersion() - return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0 + return inputTarget() !== undefined && !disabled() && store.mode === "shell" && input?.visualCursor.offset === 0 })(), commands: [ { bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") }, @@ -1002,7 +1013,7 @@ export function Prompt(props: PromptProps) { target: inputTarget, enabled: (() => { cursorVersion() - return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined + return inputTarget() !== undefined && !disabled() && !auto()?.visible && input !== undefined })(), commands: [ { @@ -1038,7 +1049,7 @@ export function Prompt(props: PromptProps) { target: inputTarget, enabled: (() => { cursorVersion() - return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined + return inputTarget() !== undefined && !disabled() && !auto()?.visible && input !== undefined })(), commands: [ { @@ -1073,6 +1084,7 @@ export function Prompt(props: PromptProps) { let submitting = false async function submit(delivery: SessionInbox.Delivery = "steer") { + if (disabled()) return false // Prevent overlapping invocations (e.g. a double-pressed Enter, or the // input's native onSubmit racing another dispatch). Without this guard, // a second call slips past the empty-input check before the first call @@ -1096,7 +1108,6 @@ export function Prompt(props: PromptProps) { setStore("prompt", "text", input.plainText) syncExtmarksWithPromptParts() } - if (props.disabled) return false if (move.creating()) return false if (auto()?.visible) return false const trimmed = store.prompt.text.trim() @@ -1764,18 +1775,19 @@ export function Prompt(props: PromptProps) { }} onCursorChange={() => setCursorVersion((value) => value + 1)} onKeyDown={(e: { preventDefault(): void }) => { - if (props.disabled) { + if (disabled()) { e.preventDefault() return } }} onSubmit={() => { + if (disabled()) return // IME: double-defer so the last composed character (e.g. Korean // hangul) is flushed to plainText before we read it for submission. setTimeout(() => setTimeout(() => submit(), 0), 0) }} onPaste={(event: PasteEvent) => { - if (props.disabled) { + if (disabled()) { event.preventDefault() return } @@ -1811,12 +1823,16 @@ export function Prompt(props: PromptProps) { setTimeout(() => { // setTimeout is a workaround and needs to be addressed properly if (!input || input.isDestroyed) return - input.cursorColor = theme.text.default + input.cursorColor = disabled() ? theme.background.surface.offset : theme.text.default if (config.cursor) input.cursorStyle = config.cursor }, 0) }} onMouseDown={(r: MouseEvent) => { - if (props.disabled || r.button !== 0) return + if (disabled()) { + r.preventDefault() + return + } + if (r.button !== 0) return r.target?.focus() const extmark = input.extmarks .getAtOffset(input.cursorOffset) @@ -1826,7 +1842,7 @@ export function Prompt(props: PromptProps) { r.stopPropagation() }} focusedBackgroundColor="transparent" - cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default} + cursorColor={disabled() ? theme.background.surface.offset : theme.text.default} syntaxStyle={syntax()} /> diff --git a/packages/tui/src/context/interactivity.tsx b/packages/tui/src/context/interactivity.tsx new file mode 100644 index 00000000000..143cc8122a2 --- /dev/null +++ b/packages/tui/src/context/interactivity.tsx @@ -0,0 +1,20 @@ +import { createContext, createMemo, getOwner, useContext, type Accessor, type ParentProps } from "solid-js" + +const Context = createContext>(() => true) + +/** Disabling a subtree also disables every nested interactivity provider. */ +export function InteractivityProvider(props: ParentProps<{ enabled: boolean }>) { + const parent = useInteractivity() + const enabled = createMemo(() => parent() && props.enabled) + return {props.children} +} + +/** Shared by keymap consumers and native input/focus handlers. Defaults to enabled. */ +export function useInteractivity() { + return useContext(Context) +} + +/** Forwarded APIs use the calling component's context, or their captured context outside a Solid owner. */ +export function resolveInteractivity(fallback: Accessor) { + return getOwner() ? useInteractivity() : fallback +} diff --git a/packages/tui/src/context/keymap.tsx b/packages/tui/src/context/keymap.tsx index 21d7108ff45..b1878e7e9d9 100644 --- a/packages/tui/src/context/keymap.tsx +++ b/packages/tui/src/context/keymap.tsx @@ -13,9 +13,19 @@ import { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { KeymapProvider, useBindings, useKeymapSelector } from "@opentui/keymap/solid" import { useRenderer } from "@opentui/solid" -import { createContext, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js" +import { + createComputed, + createContext, + createMemo, + createSignal, + onCleanup, + useContext, + type Accessor, + type ParentProps, +} from "solid-js" import { useConfig } from "../config" import { TuiKeybind } from "../config/keybind" +import { resolveInteractivity, useInteractivity } from "./interactivity" declare module "@opentui/keymap" { interface Command { @@ -175,13 +185,17 @@ export interface Keymap { function use(): Keymap { const value = useValue() + const enabled = useInteractivity() const leader = value.config.keybinds.get("leader")?.[0]?.key const isLeader = leader ? value.keymap.createKeyMatcher(leader) : () => false return { dispatch(id, input) { value.dispatch(id, input) }, - mode: value.mode, + mode: { + current: value.mode.current, + push: (mode) => value.mode.push(mode, resolveInteractivity(enabled)), + }, intercept: value.keymap.intercept.bind(value.keymap), isLeader, } @@ -189,6 +203,7 @@ function use(): Keymap { function createLayer(input: () => KeymapLayer) { const value = useValue() + const enabled = useInteractivity() useBindings(() => { const layer = input() const { commands, bindings, mode, ...options } = layer @@ -215,6 +230,7 @@ function createLayer(input: () => KeymapLayer) { ) return { ...options, + enabled: enabled() ? options.enabled : false, ...(mode === "global" ? {} : { mode: mode ?? MODE.base }), commands: grouped.named.map((command) => { const { id, description, group, palette, bind, run, ...definition } = command @@ -397,37 +413,34 @@ export const Keymap = { } as const function createMode(keymap: OpenTuiKeymap) { - keymap.setData(MODE.key, MODE.base) + const [stack, setStack] = createSignal< + { readonly id: symbol; readonly mode: string; readonly enabled: Accessor }[] + >([]) + const current = createMemo(() => stack().findLast((item) => item.enabled())?.mode ?? MODE.base) + // Publish mode changes before another command can be dispatched in the same callback. + createComputed(() => keymap.setData(MODE.key, current())) const unregister = keymap.registerLayerFields({ mode(value, context) { context.require(MODE.key, value) }, }) - const stack: { readonly id: symbol; readonly mode: string }[] = [] let disposed = false - const update = () => keymap.setData(MODE.key, stack.at(-1)?.mode ?? MODE.base) - return { - current() { - return stack.at(-1)?.mode ?? MODE.base - }, - push(mode: string) { + current, + push(mode: string, enabled: Accessor) { if (disposed) return () => {} const id = Symbol(mode) - stack.push({ id, mode }) - update() + // Inactive scopes retain their stack position beneath any newer modes. + setStack((items) => [...items, { id, mode, enabled }]) return () => { - const index = stack.findIndex((item) => item.id === id) - if (index < 0) return - stack.splice(index, 1) - update() + setStack((items) => items.filter((item) => item.id !== id)) } }, dispose() { if (disposed) return disposed = true - stack.length = 0 + setStack([]) unregister() keymap.setData(MODE.key, undefined) }, diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index 1d53fcb9bea..68bbdfab4d2 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -17,6 +17,7 @@ import { useClipboard } from "../../context/clipboard" import { SplitBorder } from "../../ui/border" import { useToast } from "../../ui/toast" import { Keymap } from "../../context/keymap" +import { useInteractivity } from "../../context/interactivity" import { useConfig } from "../../config" import { errorMessage } from "../../util/error" import { @@ -48,6 +49,8 @@ export function FormPrompt(props: { form: FormWithLocation }) { const renderer = useRenderer() const dimensions = useTerminalDimensions() const keymap = Keymap.use() + const enabled = useInteractivity() + const active = () => enabled() && keymap.mode.current() === FORM_MODE const config = useConfig().data const clipboard = useClipboard() const toast = useToast() @@ -68,6 +71,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { }) let textarea: TextareaRenderable | undefined + const [inputTarget, setInputTarget] = createSignal() let review: ScrollBoxRenderable | undefined let measureReview: (() => void) | undefined @@ -216,9 +220,22 @@ export function FormPrompt(props: { form: FormWithLocation }) { if (measureReview) renderer.off(CliRenderEvents.FRAME, measureReview) }) + // Refs publish after initialization so burst typing stays with the interceptor until the editor is ready. + createEffect(() => { + const target = inputTarget() + if (!target || target.isDestroyed) return + if (!active()) { + target.blur() + target.focusable = false + return + } + target.focusable = true + target.focus() + }) + onCleanup( keymap.intercept("key", ({ event, consume }) => { - if (keymap.mode.current() !== FORM_MODE) return + if (!active()) return if (textual() || !other() || (store.editing && renderer.currentFocusedEditor === textarea)) return if (event.ctrl || event.meta || event.option || event.super || event.hyper) return if ((!store.editing && event.sequence === " ") || !/^[^\p{C}\p{Zl}\p{Zp}]$/u.test(event.sequence)) return @@ -328,7 +345,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { } usePaste((event) => { - if (keymap.mode.current() !== FORM_MODE) return + if (!active()) return const value = stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n") if (store.editing && renderer.currentFocusedEditor === textarea) { textarea.insertText(value) @@ -343,7 +360,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { return clipboard .read() .then((content) => { - if (content?.mime !== "text/plain") return + if (!active() || content?.mime !== "text/plain") return const value = stripAnsiSequences(content.data).replace(/\r\n?/g, "\n") if (store.editing || textual()) { textarea?.insertText(value) @@ -878,8 +895,9 @@ export function FormPrompt(props: { form: FormWithLocation }) { textarea = val val.traits = { status: "ANSWER" } queueMicrotask(() => { - val.focus() + if (val.isDestroyed) return val.gotoLineEnd() + setInputTarget(val) }) }} initialValue={ @@ -1017,9 +1035,10 @@ export function FormPrompt(props: { form: FormWithLocation }) { textarea = val val.traits = { status: "ANSWER" } queueMicrotask(() => { + if (val.isDestroyed) return val.setText(input()) - val.focus() val.gotoLineEnd() + setInputTarget(val) }) }} initialValue={input()} diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 9b126896589..434a25aef86 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -11,6 +11,7 @@ import { permissionAlwaysLines, permissionOptionLabel, permissionPresentation } import { getScrollAcceleration } from "../../util/scroll" import { useConfig } from "../../config" import { Keymap } from "../../context/keymap" +import { useInteractivity } from "../../context/interactivity" import { usePathFormatter } from "../../context/path-format" import { SimulationSemantics } from "../../simulation/semantics" import { PatchDiff } from "../../component/patch-diff" @@ -288,6 +289,7 @@ function RejectPrompt(props: { onCancel: () => void }) { let input: TextareaRenderable + const enabled = useInteractivity() const theme = useTheme("elevated") const config = useConfig().data const dimensions = useTerminalDimensions() @@ -364,7 +366,7 @@ function RejectPrompt(props: { }))(val) val.traits = { status: "REJECT" } }} - focused + focused={enabled()} textColor={theme.text.default} focusedTextColor={theme.text.default} cursorColor={theme.text.default} diff --git a/packages/tui/test/cli/tui/input-scope.test.tsx b/packages/tui/test/cli/tui/input-scope.test.tsx new file mode 100644 index 00000000000..5237eb0efc9 --- /dev/null +++ b/packages/tui/test/cli/tui/input-scope.test.tsx @@ -0,0 +1,279 @@ +/** @jsxImportSource @opentui/solid */ +import type { PermissionRequest } from "@opencode-ai/client" +import type { TextareaRenderable } from "@opentui/core" +import { testRender, type JSX } from "@opentui/solid" +import { expect, test } from "bun:test" +import { createSignal, onMount } from "solid-js" +import { ConfigProvider } from "../../../src/config" +import { ClientProvider } from "../../../src/context/client" +import { DataProvider, useData, type FormWithLocation } from "../../../src/context/data" +import { Keymap } from "../../../src/context/keymap" +import { InteractivityProvider } from "../../../src/context/interactivity" +import { LocationProvider } from "../../../src/context/location" +import { ThemeProvider } from "../../../src/context/theme" +import { FormPrompt, FORM_MODE } from "../../../src/routes/session/form" +import { PermissionPrompt } from "../../../src/routes/session/permission" +import { ToastProvider } from "../../../src/ui/toast" +import { emptyThemeSource, tmpdir } from "../../fixture/fixture" +import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" + +async function mountPanes(root: string, render: () => JSX.Element, parentID?: string) { + const [active, setActive] = createSignal(false) + const replies: unknown[] = [] + const cancellations: string[] = [] + const submissions: string[] = [] + const ready = Promise.withResolvers() + let peer!: TextareaRenderable + let keymap!: Keymap + const transport = createFetch((url, request) => { + if (url.pathname === "/api/session/ses_scoped") + return json({ + data: { + id: "ses_scoped", + parentID, + title: "Scoped session", + projectID: "proj_test", + location: { directory: root }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + }, + }) + if (url.pathname.endsWith("/reply")) + return request.json().then((body) => { + replies.push(body) + return new Response(null, { status: 204 }) + }) + if (url.pathname.endsWith("/cancel")) { + cancellations.push(url.pathname) + return new Response(null, { status: 204 }) + } + }, createEventStream()) + + function Panes() { + const data = useData() + keymap = Keymap.use() + onMount(() => void data.session.sync("ses_scoped").then(ready.resolve, ready.reject)) + return ( + + +