mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(tui): add shared interactivity context (#47144)
This commit is contained in:
@@ -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<TextareaRenderable | undefined>()
|
||||
|
||||
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<string>()
|
||||
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<void>) {
|
||||
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()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createContext, createMemo, getOwner, useContext, type Accessor, type ParentProps } from "solid-js"
|
||||
|
||||
const Context = createContext<Accessor<boolean>>(() => 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 <Context.Provider value={enabled}>{props.children}</Context.Provider>
|
||||
}
|
||||
|
||||
/** 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<boolean>) {
|
||||
return getOwner() ? useInteractivity() : fallback
|
||||
}
|
||||
@@ -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<boolean> }[]
|
||||
>([])
|
||||
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<boolean>) {
|
||||
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)
|
||||
},
|
||||
|
||||
@@ -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<TextareaRenderable>()
|
||||
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()}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<void>()
|
||||
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 (
|
||||
<box>
|
||||
<InteractivityProvider enabled={!active()}>
|
||||
<textarea
|
||||
ref={(value) => (peer = value)}
|
||||
focused={!active()}
|
||||
initialValue="peer"
|
||||
onSubmit={() => submissions.push(peer.plainText)}
|
||||
/>
|
||||
</InteractivityProvider>
|
||||
<InteractivityProvider enabled={active()}>{render()}</InteractivityProvider>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts directory={root} paths={{ home: root, state: root, worktree: root }}>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ animations: false })}>
|
||||
<Keymap.Provider>
|
||||
<ClientProvider api={createApi(transport.fetch)}>
|
||||
<DataProvider directory={root}>
|
||||
<LocationProvider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ToastProvider>
|
||||
<Panes />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 90, height: 24, kittyKeyboard: true },
|
||||
)
|
||||
app.renderer.start()
|
||||
await ready.promise
|
||||
await app.renderOnce()
|
||||
return { app, setActive, replies, cancellations, submissions, peer, keymap }
|
||||
}
|
||||
|
||||
function form(fields: FormWithLocation["fields"]): FormWithLocation {
|
||||
return { id: "frm_scoped", sessionID: "ses_scoped", title: "Scoped form", fields }
|
||||
}
|
||||
|
||||
const request = {
|
||||
id: "per_scoped",
|
||||
sessionID: "ses_scoped",
|
||||
action: "shell",
|
||||
resources: ["echo scoped"],
|
||||
} satisfies PermissionRequest
|
||||
|
||||
test("an inactive form leaves Enter, navigation, and paste with the focused peer", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const panes = await mountPanes(tmp.path, () => (
|
||||
<FormPrompt
|
||||
form={form([
|
||||
{
|
||||
key: "target",
|
||||
type: "string",
|
||||
options: [
|
||||
{ value: "staging", label: "Staging" },
|
||||
{ value: "production", label: "Production" },
|
||||
],
|
||||
},
|
||||
])}
|
||||
/>
|
||||
))
|
||||
try {
|
||||
expect(panes.keymap.mode.current()).toBe("base")
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
|
||||
panes.app.mockInput.pressEnter()
|
||||
panes.app.mockInput.pressArrow("down")
|
||||
panes.app.mockInput.pressKey("2")
|
||||
panes.app.mockInput.pressEscape()
|
||||
await panes.app.mockInput.pasteBracketedText(" pasted")
|
||||
expect(panes.submissions).toEqual(["peer"])
|
||||
expect(panes.peer.plainText).toContain("pasted")
|
||||
expect(panes.replies).toEqual([])
|
||||
expect(panes.cancellations).toEqual([])
|
||||
|
||||
panes.setActive(true)
|
||||
expect(panes.keymap.mode.current()).toBe(FORM_MODE)
|
||||
panes.app.mockInput.pressEnter()
|
||||
await panes.app.waitFor(() => panes.replies.length === 1)
|
||||
expect(panes.replies).toEqual([{ answer: { target: "staging" } }])
|
||||
} finally {
|
||||
panes.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("a form textarea mounts inactive and restores its draft focus after scope and modal changes", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const panes = await mountPanes(tmp.path, () => <FormPrompt form={form([{ key: "notes", type: "string" }])} />)
|
||||
try {
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
|
||||
panes.setActive(true)
|
||||
const input = panes.app.renderer.currentFocusedEditor
|
||||
expect(input).not.toBeNull()
|
||||
expect(input?.id).not.toBe(panes.peer.id)
|
||||
await panes.app.mockInput.typeText("draft answer")
|
||||
|
||||
const pop = panes.keymap.mode.push("modal")
|
||||
expect(panes.app.renderer.currentFocusedEditor).toBeNull()
|
||||
panes.setActive(false)
|
||||
panes.setActive(true)
|
||||
expect(panes.keymap.mode.current()).toBe("modal")
|
||||
expect(panes.app.renderer.currentFocusedEditor).toBeNull()
|
||||
pop()
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(input?.id)
|
||||
|
||||
panes.setActive(false)
|
||||
input?.focus()
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
|
||||
await panes.app.mockInput.typeText(" other")
|
||||
await panes.app.mockInput.pasteBracketedText(" pane")
|
||||
panes.app.mockInput.pressEnter()
|
||||
expect(panes.submissions).toHaveLength(1)
|
||||
expect(input?.plainText).toBe("draft answer")
|
||||
expect(panes.replies).toEqual([])
|
||||
|
||||
panes.setActive(true)
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(input?.id)
|
||||
panes.app.mockInput.pressEnter()
|
||||
panes.app.mockInput.pressEnter()
|
||||
await panes.app.waitFor(() => panes.replies.length === 1)
|
||||
expect(panes.replies).toEqual([{ answer: { notes: "draft answer" } }])
|
||||
} finally {
|
||||
panes.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("inactive custom forms cannot intercept a peer using the same form mode", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const panes = await mountPanes(tmp.path, () => (
|
||||
<FormPrompt
|
||||
form={form([{ key: "target", type: "string", options: [{ value: "staging", label: "Staging" }], custom: true }])}
|
||||
/>
|
||||
))
|
||||
try {
|
||||
panes.setActive(true)
|
||||
panes.app.mockInput.pressArrow("down")
|
||||
panes.setActive(false)
|
||||
const pop = panes.keymap.mode.push(FORM_MODE)
|
||||
await panes.app.mockInput.typeText(" typed")
|
||||
await panes.app.mockInput.pasteBracketedText(" pasted")
|
||||
panes.app.mockInput.pressEnter()
|
||||
await panes.app.renderOnce()
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
|
||||
expect(panes.submissions).toHaveLength(1)
|
||||
expect(panes.peer.plainText).toContain("typed")
|
||||
expect(panes.peer.plainText).toContain("pasted")
|
||||
expect(panes.app.captureCharFrame()).toContain("Type your own answer")
|
||||
expect(panes.replies).toEqual([])
|
||||
pop()
|
||||
|
||||
panes.setActive(true)
|
||||
await panes.app.mockInput.typeText("production target")
|
||||
await panes.app.waitFor(() => panes.app.renderer.currentFocusedEditor?.plainText === "production target")
|
||||
panes.setActive(false)
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
|
||||
panes.setActive(true)
|
||||
expect(panes.app.renderer.currentFocusedEditor?.plainText).toBe("production target")
|
||||
panes.app.mockInput.pressEnter()
|
||||
await panes.app.waitFor(() => panes.replies.length === 1)
|
||||
expect(panes.replies).toEqual([{ answer: { target: "production target" } }])
|
||||
} finally {
|
||||
panes.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("permission layers leave the focused peer's Enter and navigation alone until activated", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const panes = await mountPanes(tmp.path, () => <PermissionPrompt request={request} />)
|
||||
try {
|
||||
panes.app.mockInput.pressEnter()
|
||||
panes.app.mockInput.pressArrow("right")
|
||||
panes.app.mockInput.pressEscape()
|
||||
expect(panes.submissions).toEqual(["peer"])
|
||||
expect(panes.replies).toEqual([])
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
|
||||
|
||||
panes.setActive(true)
|
||||
panes.app.mockInput.pressEnter()
|
||||
await panes.app.waitFor(() => panes.replies.length === 1)
|
||||
expect(panes.replies).toEqual([{ reply: "once" }])
|
||||
expect(panes.submissions).toHaveLength(1)
|
||||
} finally {
|
||||
panes.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("permission rejection text keeps its draft and regains focus when its scope resumes", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const panes = await mountPanes(tmp.path, () => <PermissionPrompt request={request} />, "ses_parent")
|
||||
try {
|
||||
panes.setActive(true)
|
||||
panes.app.mockInput.pressEscape()
|
||||
await panes.app.waitForFrame((frame) => frame.includes("Reject permission"))
|
||||
const input = panes.app.renderer.currentFocusedEditor
|
||||
expect(input).not.toBeNull()
|
||||
await panes.app.mockInput.typeText("choose another command")
|
||||
|
||||
panes.setActive(false)
|
||||
panes.app.mockInput.pressEnter()
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
|
||||
expect(panes.submissions).toEqual(["peer"])
|
||||
expect(panes.replies).toEqual([])
|
||||
expect(input?.plainText).toBe("choose another command")
|
||||
|
||||
panes.setActive(true)
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(input?.id)
|
||||
panes.app.mockInput.pressEnter()
|
||||
await panes.app.waitFor(() => panes.replies.length === 1)
|
||||
expect(panes.replies).toEqual([{ reply: "reject", message: "choose another command" }])
|
||||
} finally {
|
||||
panes.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createSignal } from "solid-js"
|
||||
import { InteractivityProvider, useInteractivity } from "../src/context/interactivity"
|
||||
|
||||
test("interactivity is independent of the keymap and cannot re-enable a disabled ancestor", async () => {
|
||||
const [parent, setParent] = createSignal(true)
|
||||
const [child, setChild] = createSignal(true)
|
||||
let defaults!: () => boolean
|
||||
let enabled!: () => boolean
|
||||
|
||||
function Probe() {
|
||||
enabled = useInteractivity()
|
||||
return null
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
defaults = useInteractivity()
|
||||
return (
|
||||
<InteractivityProvider enabled={parent()}>
|
||||
<InteractivityProvider enabled={child()}>
|
||||
<Probe />
|
||||
</InteractivityProvider>
|
||||
</InteractivityProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />)
|
||||
try {
|
||||
expect(defaults()).toBe(true)
|
||||
expect(enabled()).toBe(true)
|
||||
setParent(false)
|
||||
expect(enabled()).toBe(false)
|
||||
setChild(false)
|
||||
setChild(true)
|
||||
expect(enabled()).toBe(false)
|
||||
setParent(true)
|
||||
expect(enabled()).toBe(true)
|
||||
setChild(false)
|
||||
expect(enabled()).toBe(false)
|
||||
expect(defaults()).toBe(true)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,271 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { Keymap } from "../src/context/keymap"
|
||||
import { InteractivityProvider, useInteractivity } from "../src/context/interactivity"
|
||||
|
||||
const config = { keybinds: { get: () => [] } }
|
||||
|
||||
test("disabled scopes isolate named, inline, and global layers without disabling application commands", async () => {
|
||||
const calls: string[] = []
|
||||
const [enabled, setEnabled] = createSignal(false)
|
||||
let keymap!: Keymap
|
||||
|
||||
function Scoped() {
|
||||
Keymap.createLayer(() => ({
|
||||
commands: [
|
||||
{ id: "scoped.submit", bind: "return", run: () => void calls.push("submit") },
|
||||
{ bind: "j", run: () => void calls.push("inline") },
|
||||
],
|
||||
}))
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
commands: [{ id: "scoped.global", bind: "g", run: () => void calls.push("scoped global") }],
|
||||
}))
|
||||
return null
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
keymap = Keymap.use()
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
commands: [{ id: "app.global", bind: "x", run: () => void calls.push("app global") }],
|
||||
}))
|
||||
return (
|
||||
<InteractivityProvider enabled={enabled()}>
|
||||
<Scoped />
|
||||
</InteractivityProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<Keymap.Provider config={config}>
|
||||
<Harness />
|
||||
</Keymap.Provider>
|
||||
))
|
||||
try {
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("j")
|
||||
app.mockInput.pressKey("g")
|
||||
keymap.dispatch("scoped.submit")
|
||||
keymap.dispatch("scoped.global")
|
||||
app.mockInput.pressKey("x")
|
||||
expect(calls).toEqual(["app global"])
|
||||
|
||||
setEnabled(true)
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("j")
|
||||
app.mockInput.pressKey("g")
|
||||
expect(calls).toEqual(["app global", "submit", "inline", "scoped global"])
|
||||
|
||||
const pop = keymap.mode.push("modal")
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("g")
|
||||
app.mockInput.pressKey("x")
|
||||
expect(calls.slice(4)).toEqual(["scoped global", "app global"])
|
||||
|
||||
setEnabled(false)
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("g")
|
||||
app.mockInput.pressKey("x")
|
||||
expect(calls.slice(6)).toEqual(["app global"])
|
||||
pop()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("nested scopes conjoin ancestors and retain dispatch-time layer predicates", async () => {
|
||||
const calls: string[] = []
|
||||
const [parent, setParent] = createSignal(false)
|
||||
const [child, setChild] = createSignal(true)
|
||||
const [layer, setLayer] = createSignal(true)
|
||||
let allowed = true
|
||||
let read!: () => boolean
|
||||
let unscoped!: () => boolean
|
||||
|
||||
function Scoped() {
|
||||
read = useInteractivity()
|
||||
Keymap.createLayer(() => ({
|
||||
enabled: layer(),
|
||||
commands: [{ bind: "return", run: () => void calls.push("boolean") }],
|
||||
}))
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
enabled: () => allowed,
|
||||
commands: [{ bind: "g", run: () => void calls.push("predicate") }],
|
||||
}))
|
||||
return null
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
unscoped = useInteractivity()
|
||||
return (
|
||||
<InteractivityProvider enabled={parent()}>
|
||||
<InteractivityProvider enabled={child()}>
|
||||
<Scoped />
|
||||
</InteractivityProvider>
|
||||
</InteractivityProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<Keymap.Provider config={config}>
|
||||
<Harness />
|
||||
</Keymap.Provider>
|
||||
))
|
||||
try {
|
||||
expect(unscoped()).toBe(true)
|
||||
expect(read()).toBe(false)
|
||||
app.mockInput.pressEnter()
|
||||
setParent(true)
|
||||
expect(read()).toBe(true)
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("g")
|
||||
|
||||
allowed = false
|
||||
app.mockInput.pressKey("g")
|
||||
setLayer(false)
|
||||
app.mockInput.pressEnter()
|
||||
expect(calls).toEqual(["boolean", "predicate"])
|
||||
|
||||
setChild(false)
|
||||
setLayer(true)
|
||||
allowed = true
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("g")
|
||||
expect(read()).toBe(false)
|
||||
setParent(false)
|
||||
setChild(true)
|
||||
expect(read()).toBe(false)
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("g")
|
||||
expect(calls).toEqual(["boolean", "predicate"])
|
||||
|
||||
setParent(true)
|
||||
expect(read()).toBe(true)
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("g")
|
||||
expect(calls).toEqual(["boolean", "predicate", "boolean", "predicate"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("ownerless mode pushes suspend and resume in their captured scope without changing stack order", async () => {
|
||||
const [enabled, setEnabled] = createSignal(false)
|
||||
const calls: string[] = []
|
||||
let scoped!: Keymap
|
||||
let global!: Keymap
|
||||
|
||||
function Scoped() {
|
||||
scoped = Keymap.use()
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "form",
|
||||
commands: [{ bind: "return", run: () => void calls.push("form") }],
|
||||
}))
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "menu",
|
||||
commands: [{ bind: "return", run: () => void calls.push("menu") }],
|
||||
}))
|
||||
return null
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
global = Keymap.use()
|
||||
return (
|
||||
<InteractivityProvider enabled={enabled()}>
|
||||
<Scoped />
|
||||
</InteractivityProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<Keymap.Provider config={config}>
|
||||
<Harness />
|
||||
</Keymap.Provider>
|
||||
))
|
||||
try {
|
||||
const form = scoped.mode.push("form")
|
||||
expect(global.mode.current()).toBe("base")
|
||||
app.mockInput.pressEnter()
|
||||
const modal = global.mode.push("modal")
|
||||
setEnabled(true)
|
||||
expect(global.mode.current()).toBe("modal")
|
||||
app.mockInput.pressEnter()
|
||||
modal()
|
||||
expect(global.mode.current()).toBe("form")
|
||||
app.mockInput.pressEnter()
|
||||
expect(calls).toEqual(["form"])
|
||||
|
||||
setEnabled(false)
|
||||
expect(global.mode.current()).toBe("base")
|
||||
const menu = scoped.mode.push("menu")
|
||||
form()
|
||||
expect(global.mode.current()).toBe("base")
|
||||
setEnabled(true)
|
||||
expect(global.mode.current()).toBe("menu")
|
||||
app.mockInput.pressEnter()
|
||||
expect(calls).toEqual(["form", "menu"])
|
||||
menu()
|
||||
expect(global.mode.current()).toBe("base")
|
||||
setEnabled(false)
|
||||
setEnabled(true)
|
||||
expect(global.mode.current()).toBe("base")
|
||||
app.mockInput.pressEnter()
|
||||
expect(calls).toEqual(["form", "menu"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("forwarded keymaps push modes in the calling component's nested scope and clean up while inactive", async () => {
|
||||
const [enabled, setEnabled] = createSignal(false)
|
||||
const [nested, setNested] = createSignal(true)
|
||||
const [mounted, setMounted] = createSignal(true)
|
||||
let global!: Keymap
|
||||
|
||||
function Scoped(props: { keymap: Keymap }) {
|
||||
onMount(() => onCleanup(props.keymap.mode.push("menu")))
|
||||
return null
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
global = Keymap.use()
|
||||
return (
|
||||
<InteractivityProvider enabled={enabled()}>
|
||||
<InteractivityProvider enabled={nested()}>
|
||||
<Show when={mounted()}>
|
||||
<Scoped keymap={global} />
|
||||
</Show>
|
||||
</InteractivityProvider>
|
||||
</InteractivityProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<Keymap.Provider config={config}>
|
||||
<Harness />
|
||||
</Keymap.Provider>
|
||||
))
|
||||
try {
|
||||
expect(global.mode.current()).toBe("base")
|
||||
setEnabled(true)
|
||||
expect(global.mode.current()).toBe("menu")
|
||||
setNested(false)
|
||||
expect(global.mode.current()).toBe("base")
|
||||
setNested(true)
|
||||
expect(global.mode.current()).toBe("menu")
|
||||
setEnabled(false)
|
||||
setMounted(false)
|
||||
setEnabled(true)
|
||||
expect(global.mode.current()).toBe("base")
|
||||
setMounted(true)
|
||||
expect(global.mode.current()).toBe("menu")
|
||||
setMounted(false)
|
||||
expect(global.mode.current()).toBe("base")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user