Compare commits

...
9 changed files with 1032 additions and 113 deletions
+35 -20
View File
@@ -187,6 +187,8 @@ export function Prompt(props: PromptProps) {
let anchor: BoxRenderable
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
const enabled = Keymap.useEnabled()
const disabled = () => props.disabled || !enabled()
const leader = Keymap.useLeaderActive()
const muted = () => leader() || props.muted
const local = useLocal()
@@ -259,6 +261,7 @@ export function Prompt(props: PromptProps) {
const [pendingDirectory, setPendingDirectory] = createSignal<string>()
Keymap.createLayer(() => ({
mode: "global",
enabled: !disabled(),
commands: [
{
id: "session.cd",
@@ -348,8 +351,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
})
@@ -372,12 +374,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,
@@ -648,15 +651,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",
@@ -674,12 +680,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() {
@@ -733,11 +740,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()
@@ -933,13 +942,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"],
}
})
@@ -947,7 +957,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"],
}
})
@@ -959,7 +969,7 @@ export function Prompt(props: PromptProps) {
cursorVersion()
return (
inputTarget() !== undefined &&
!props.disabled &&
!disabled() &&
store.mode === "normal" &&
!auto()?.visible &&
input?.visualCursor.offset === 0
@@ -983,7 +993,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") },
{
@@ -1002,7 +1012,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") },
@@ -1016,7 +1026,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: [
{
@@ -1052,7 +1062,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: [
{
@@ -1087,6 +1097,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
@@ -1110,7 +1121,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()
@@ -1663,7 +1673,7 @@ export function Prompt(props: PromptProps) {
const promptBg = createMemo(() => theme.raise(theme.background.surface.offset))
return (
<>
<Keymap.Scope enabled={!disabled()}>
<box ref={(r: BoxRenderable) => (anchor = r)} visible={props.visible !== false} width="100%">
<box
width="100%"
@@ -1769,18 +1779,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
}
@@ -1816,12 +1827,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)
@@ -1831,7 +1846,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">
@@ -2016,6 +2031,6 @@ export function Prompt(props: PromptProps) {
hasSkill={(id) => store.prompt.skills?.some((skill) => skill.id === id) ?? false}
promptPartTypeId={() => promptPartTypeId}
/>
</>
</Keymap.Scope>
)
}
+47 -17
View File
@@ -13,7 +13,17 @@ 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,
getOwner,
onCleanup,
useContext,
type Accessor,
type ParentProps,
} from "solid-js"
import { useConfig } from "../config"
import { TuiKeybind } from "../config/keybind"
@@ -50,6 +60,20 @@ const Context = createContext<{
readonly input: (id: string) => string | undefined
}>()
const EnabledContext = createContext<Accessor<boolean>>(() => true)
/** Gates descendant layers and modes, including layers that opt out of mode matching. */
function Scope(props: ParentProps<{ enabled: boolean }>) {
const parent = useEnabled()
const enabled = createMemo(() => parent() && props.enabled)
return <EnabledContext.Provider value={enabled}>{props.children}</EnabledContext.Provider>
}
/** Returns the combined activation of every enclosing scope. */
function useEnabled() {
return useContext(EnabledContext)
}
function Provider(props: ParentProps<{ config?: KeymapConfig }>) {
const renderer = useRenderer()
const config: KeymapConfig = props.config ?? useConfig().data
@@ -175,13 +199,18 @@ export interface Keymap {
function use(): Keymap {
const value = useValue()
const enabled = useEnabled()
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,
// Plugin APIs can forward a keymap captured above the calling component's scope.
push: (mode) => value.mode.push(mode, getOwner() ? useEnabled() : enabled),
},
intercept: value.keymap.intercept.bind(value.keymap),
isLeader,
}
@@ -189,6 +218,7 @@ function use(): Keymap {
function createLayer(input: () => KeymapLayer) {
const value = useValue()
const enabled = useEnabled()
useBindings(() => {
const layer = input()
const { commands, bindings, mode, ...options } = layer
@@ -215,6 +245,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
@@ -385,7 +416,9 @@ function useValue() {
export const Keymap = {
Provider,
Scope,
use,
useEnabled,
createLayer,
useShortcuts,
useShortcut,
@@ -397,37 +430,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)
},
@@ -0,0 +1,87 @@
/** @jsxImportSource @opentui/solid */
import type { Plugin } from "@opencode-ai/plugin/tui"
import { BoxRenderable, MouseButton } from "@opentui/core"
import { Portal, useTerminalDimensions } from "@opentui/solid"
import { createSignal, onCleanup } from "solid-js"
export function DiffFileMenu(props: {
context: Plugin.Context
state: { fileIndex: number; x: number; y: number }
reviewed: boolean
onToggle: () => void
onClose: () => void
}) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.overlay
const [hovered, setHovered] = createSignal(false)
const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete")
const width = () => Math.min(19, dimensions().width)
const run = () => {
props.onClose()
props.onToggle()
}
onCleanup(props.context.keymap.mode.push("menu"))
props.context.keymap.layer(() => ({
mode: "menu",
commands: [
{ bind: "escape,ctrl+c", title: "Close file menu", group: "Diff", run: props.onClose },
{ bind: "return", title: label(), group: "Diff", run },
],
}))
return (
<Portal
ref={(container) => {
if (!(container instanceof BoxRenderable)) return
// Portal's wrapper must also escape root flow, not follow the full-height app.
container.position = "absolute"
container.left = 0
container.top = 0
container.zIndex = 2600
}}
>
<box
id="diff-file-menu-overlay"
position="absolute"
left={0}
top={0}
width={dimensions().width}
height={dimensions().height}
zIndex={2600}
onMouseDown={(event) => {
props.onClose()
event.preventDefault()
event.stopPropagation()
}}
>
<box
id="diff-file-menu"
position="absolute"
left={Math.max(0, Math.min(props.state.x, dimensions().width - width()))}
top={Math.max(0, Math.min(props.state.y + 1, dimensions().height - 1))}
width={width()}
height={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.default}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseDown={(event) => {
if (event.button === MouseButton.RIGHT) props.onClose()
event.preventDefault()
event.stopPropagation()
}}
onMouseUp={(event) => {
event.preventDefault()
event.stopPropagation()
if (event.button === MouseButton.LEFT) run()
}}
>
<text fg={theme.text.default} selectable={false} wrapMode="none" truncate>
{label()}
</text>
</box>
</box>
</Portal>
)
}
@@ -14,6 +14,7 @@ import { filetype } from "../../util/filetype"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
import { DiffFileMenu } from "./diff-viewer-file-menu"
import { DiffViewerImage, isDiffImageFile } from "./diff-viewer-image"
import { DialogSelect } from "../../ui/dialog-select"
import { EmptyBorder } from "../../ui/border"
@@ -1076,76 +1077,6 @@ export function DiffViewerContent(props: {
)
}
function DiffFileMenu(props: {
context: Plugin.Context
state: FileMenuState
reviewed: boolean
onToggle: () => void
onClose: () => void
}) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.overlay
const [hovered, setHovered] = createSignal(false)
const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete")
const run = () => {
props.onClose()
props.onToggle()
}
onCleanup(props.context.keymap.mode.push("menu"))
props.context.keymap.layer(() => ({
mode: "menu",
commands: [
{ bind: "escape,ctrl+c", title: "Close file menu", group: "Diff", run: props.onClose },
{ bind: "return", title: label(), group: "Diff", run },
],
}))
return (
<box
id="diff-file-menu-overlay"
position="absolute"
left={0}
top={0}
width={dimensions().width}
height={dimensions().height}
zIndex={2600}
onMouseDown={(event) => {
props.onClose()
event.preventDefault()
event.stopPropagation()
}}
>
<box
id="diff-file-menu"
position="absolute"
left={Math.max(0, Math.min(props.state.x, dimensions().width - 19))}
top={Math.max(0, Math.min(props.state.y + 1, dimensions().height - 1))}
width={19}
height={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.default}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseDown={(event) => {
if (event.button === MouseButton.RIGHT) props.onClose()
event.preventDefault()
event.stopPropagation()
}}
onMouseUp={(event) => {
event.preventDefault()
event.stopPropagation()
if (event.button === MouseButton.LEFT) run()
}}
>
<text fg={theme.text.default} selectable={false}>
{label()}
</text>
</box>
</box>
)
}
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.elevated
+23 -5
View File
@@ -48,6 +48,8 @@ export function FormPrompt(props: { form: FormWithLocation }) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const keymap = Keymap.use()
const enabled = Keymap.useEnabled()
const active = () => enabled() && keymap.mode.current() === FORM_MODE
const config = useConfig().data
const clipboard = useClipboard()
const toast = useToast()
@@ -68,6 +70,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 +219,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 +344,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 +359,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 +894,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 +1034,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()}
@@ -288,6 +288,7 @@ function RejectPrompt(props: {
onCancel: () => void
}) {
let input: TextareaRenderable
const enabled = Keymap.useEnabled()
const theme = useTheme("elevated")
const config = useConfig().data
const dimensions = useTerminalDimensions()
@@ -364,7 +365,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,289 @@
/** @jsxImportSource @opentui/solid */
import type { Plugin } from "@opencode-ai/plugin/tui"
import { MouseButton } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { createSignal, Show } from "solid-js"
import { Keymap } from "../../../src/context/keymap"
import { DiffFileMenu } from "../../../src/feature-plugins/system/diff-viewer-file-menu"
import { DEFAULT_THEMES, parseTheme, resolveThemeDocument } from "../../../src/theme"
test.each(["dark", "light"] as const)(
"file menus escape an offset, narrower clipping pane in %s mode",
async (mode) => {
const menu = await renderFileMenu(mode)
try {
const pane = menu.app.renderer.root.findDescendantById("test-diff-pane")!
expect([pane.x, pane.y, pane.width]).toEqual([48, 4, 12])
expect(menu.app.renderer.currentFocusedRenderable).toBe(pane)
await menu.app.mockMouse.click(pane.x + 2, pane.y, MouseButton.RIGHT)
await menu.app.waitForFrame((frame) => frame.includes("Mark complete"))
const overlay = menu.app.renderer.root.findDescendantById("diff-file-menu-overlay")!
const popup = menu.app.renderer.root.findDescendantById("diff-file-menu")!
expect([overlay.x, overlay.y, overlay.width, overlay.height]).toEqual([0, 0, 80, 20])
expect(overlay.parent?.parent).toBe(menu.app.renderer.root)
expect([popup.x, popup.y, popup.width, popup.height]).toEqual([50, 5, 19, 1])
expect(popup.x + popup.width).toBeGreaterThan(pane.x + pane.width)
expect(menu.app.captureCharFrame().split("\n")[5].indexOf("Mark complete")).toBe(51)
expect(menu.mode()).toBe("menu")
expect(menu.app.renderer.currentFocusedRenderable).toBe(pane)
const idle = menu.app.captureSpans().lines[popup.y].spans.find((span) => span.text.includes("Mark complete"))!
expect(idle.fg).toEqual(menu.theme.contextual.overlay.text.default)
expect(idle.bg).toEqual(menu.theme.contextual.overlay.background.default)
// The action remains clickable outside the clipping pane's right edge.
await menu.app.mockMouse.moveTo(pane.x + pane.width + 1, popup.y)
await menu.app.flush()
const hovered = menu.app.captureSpans().lines[popup.y].spans.find((span) => span.text.includes("Mark complete"))!
expect(hovered.bg).toEqual(menu.theme.contextual.overlay.background.action.primary.hovered)
await menu.app.mockMouse.moveTo(1, 1)
await menu.app.flush()
expect(
menu.app.captureSpans().lines[popup.y].spans.find((span) => span.text.includes("Mark complete"))!.bg,
).toEqual(idle.bg)
await menu.app.mockMouse.click(pane.x + pane.width + 1, popup.y)
await menu.app.flush()
expect(menu.calls).toEqual(["close", "toggle"])
expect(menu.reviewed()).toBe(true)
expect(menu.mode()).toBe("base")
expect(menu.app.renderer.currentFocusedRenderable).toBe(pane)
expect(menu.app.renderer.root.findDescendantById("diff-file-menu-overlay")).toBeUndefined()
} finally {
menu.app.renderer.destroy()
}
},
)
test("file menus clamp to screen edges and follow terminal resizes rather than pane bounds", async () => {
const menu = await renderFileMenu()
try {
menu.open(79, 19)
await menu.app.flush()
const popup = menu.app.renderer.root.findDescendantById("diff-file-menu")!
const overlay = menu.app.renderer.root.findDescendantById("diff-file-menu-overlay")!
expect([popup.x, popup.y, popup.width, popup.height]).toEqual([61, 19, 19, 1])
expect(menu.app.captureCharFrame().split("\n")[19]).toContain("Mark complete")
menu.app.resize(64, 14)
await menu.app.flush()
expect([overlay.x, overlay.y, overlay.width, overlay.height]).toEqual([0, 0, 64, 14])
expect([popup.x, popup.y, popup.width, popup.height]).toEqual([45, 13, 19, 1])
expect(menu.app.captureCharFrame().split("\n")[13]).toContain("Mark complete")
menu.app.resize(12, 8)
await menu.app.flush()
expect([overlay.width, overlay.height]).toEqual([12, 8])
expect([popup.x, popup.y, popup.width, popup.height]).toEqual([0, 7, 12, 1])
expect(menu.app.captureCharFrame().split("\n")[7]).toContain("...")
menu.open(-3, -2)
await menu.app.flush()
const clamped = menu.app.renderer.root.findDescendantById("diff-file-menu")!
expect([clamped.x, clamped.y]).toEqual([0, 0])
} finally {
menu.app.renderer.destroy()
}
})
test("clicking outside the pane dismisses its menu without activating the underlying control", async () => {
const menu = await renderFileMenu()
try {
menu.open(50, 4)
await menu.app.flush()
await menu.app.mockMouse.click(1, 1)
await menu.app.flush()
expect(menu.calls).toEqual(["close"])
expect(menu.mode()).toBe("base")
expect(menu.app.renderer.currentFocusedRenderable?.id).toBe("test-diff-pane")
expect(menu.app.renderer.root.findDescendantById("diff-file-menu-overlay")).toBeUndefined()
await menu.app.mockMouse.click(1, 1)
expect(menu.calls).toEqual(["close", "outside"])
} finally {
menu.app.renderer.destroy()
}
})
test.each(["escape", "ctrl+c"] as const)("%s dismisses only the file menu and restores pane commands", async (key) => {
const menu = await renderFileMenu()
try {
menu.open(50, 4)
await menu.app.flush()
menu.app.mockInput.pressKey("j")
await menu.app.flush()
expect(menu.calls).toEqual([])
if (key === "escape") menu.app.mockInput.pressEscape()
if (key === "ctrl+c") menu.app.mockInput.pressKey("c", { ctrl: true })
await menu.app.flush()
expect(menu.calls).toEqual(["close"])
expect(menu.mode()).toBe("base")
expect(menu.app.renderer.currentFocusedRenderable?.id).toBe("test-diff-pane")
expect(menu.app.renderer.root.findDescendantById("diff-file-menu-overlay")).toBeUndefined()
menu.app.mockInput.pressKey("j")
expect(menu.calls).toEqual(["close", "pane"])
} finally {
menu.app.renderer.destroy()
}
})
test("Enter toggles either review state after closing the menu and leaves no stale menu bindings", async () => {
const menu = await renderFileMenu()
try {
menu.open(50, 4)
await menu.app.waitForFrame((frame) => frame.includes("Mark complete"))
menu.app.mockInput.pressEnter()
await menu.app.flush()
expect(menu.reviewed()).toBe(true)
expect(menu.calls).toEqual(["close", "toggle"])
expect(menu.mode()).toBe("base")
menu.open(50, 4)
await menu.app.waitForFrame((frame) => frame.includes("Mark incomplete"))
menu.app.mockInput.pressEnter()
await menu.app.flush()
expect(menu.reviewed()).toBe(false)
expect(menu.calls).toEqual(["close", "toggle", "close", "toggle"])
expect(menu.mode()).toBe("base")
expect(menu.app.renderer.currentFocusedRenderable?.id).toBe("test-diff-pane")
expect(menu.app.renderer.root.findDescendantById("diff-file-menu-overlay")).toBeUndefined()
menu.app.mockInput.pressEnter()
expect(menu.calls).toEqual(["close", "toggle", "close", "toggle", "pane"])
} finally {
menu.app.renderer.destroy()
}
})
test("right-clicking the menu dismisses without toggling", async () => {
const menu = await renderFileMenu()
try {
menu.open(50, 4)
await menu.app.flush()
await menu.app.mockMouse.click(51, 5, MouseButton.RIGHT)
await menu.app.flush()
expect(menu.calls).toEqual(["close"])
expect(menu.mode()).toBe("base")
expect(menu.reviewed()).toBe(false)
expect(menu.app.renderer.currentFocusedRenderable?.id).toBe("test-diff-pane")
} finally {
menu.app.renderer.destroy()
}
})
test("portaling the menu leaves its mode and commands owned by the pane's keymap scope", async () => {
const menu = await renderFileMenu()
try {
menu.open(50, 4)
await menu.app.flush()
expect(menu.mode()).toBe("menu")
menu.setEnabled(false)
await menu.app.flush()
expect(menu.mode()).toBe("base")
menu.app.mockInput.pressEnter()
menu.app.mockInput.pressEscape()
await menu.app.flush()
expect(menu.calls).toEqual([])
expect(menu.app.renderer.root.findDescendantById("diff-file-menu")).toBeDefined()
menu.setEnabled(true)
await menu.app.flush()
expect(menu.mode()).toBe("menu")
menu.app.mockInput.pressEnter()
await menu.app.flush()
expect(menu.calls).toEqual(["close", "toggle"])
expect(menu.mode()).toBe("base")
} finally {
menu.app.renderer.destroy()
}
})
async function renderFileMenu(mode: "dark" | "light" = "dark") {
const theme = resolveThemeDocument(parseTheme(DEFAULT_THEMES.opencode), mode)
const calls: string[] = []
const [state, setState] = createSignal<{ fileIndex: number; x: number; y: number }>()
const [reviewed, setReviewed] = createSignal(false)
const [enabled, setEnabled] = createSignal(true)
const open = (x: number, y: number) => setState({ fileIndex: 0, x, y })
let currentMode = () => "base"
function Harness() {
const keymap = Keymap.use()
currentMode = keymap.mode.current
const context: Pick<Plugin.Context, "theme" | "keymap"> = {
theme,
keymap: {
layer: Keymap.createLayer,
dispatch: keymap.dispatch,
shortcuts: Keymap.useShortcuts().list,
...Keymap.useState(),
mode: keymap.mode,
},
}
Keymap.createLayer(() => ({
commands: [{ bind: "escape,ctrl+c,return,j", title: "Pane command", run: () => void calls.push("pane") }],
}))
return (
<box width="100%" height="100%" backgroundColor={theme.background.default}>
<box position="absolute" left={0} top={0} width={20} height={3} onMouseDown={() => calls.push("outside")}>
<text>Other pane</text>
</box>
<box
id="test-diff-pane"
position="absolute"
left={48}
top={4}
width={12}
height={6}
overflow="hidden"
focusable
focused
>
<text
onMouseDown={(event) => {
if (event.button !== MouseButton.RIGHT) return
open(event.x, event.y)
event.preventDefault()
event.stopPropagation()
}}
>
file.txt
</text>
<Show when={state()} keyed>
{(state) => (
<DiffFileMenu
context={context as Plugin.Context}
state={state}
reviewed={reviewed()}
onClose={() => {
calls.push("close")
setState(undefined)
}}
onToggle={() => {
calls.push("toggle")
setReviewed((value) => !value)
}}
/>
)}
</Show>
</box>
</box>
)
}
const app = await testRender(
() => (
<Keymap.Provider config={{ keybinds: { get: () => [] } }}>
<Keymap.Scope enabled={enabled()}>
<Harness />
</Keymap.Scope>
</Keymap.Provider>
),
{ width: 80, height: 20, kittyKeyboard: true },
)
await app.flush()
return { app, calls, theme, open, reviewed, setEnabled, mode: () => currentMode() }
}
@@ -0,0 +1,278 @@
/** @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 { 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>
<Keymap.Scope enabled={!active()}>
<textarea
ref={(value) => (peer = value)}
focused={!active()}
initialValue="peer"
onSubmit={() => submissions.push(peer.plainText)}
/>
</Keymap.Scope>
<Keymap.Scope enabled={active()}>{render()}</Keymap.Scope>
</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()
}
})
+270
View File
@@ -0,0 +1,270 @@
/** @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"
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 (
<Keymap.Scope enabled={enabled()}>
<Scoped />
</Keymap.Scope>
)
}
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 = Keymap.useEnabled()
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 = Keymap.useEnabled()
return (
<Keymap.Scope enabled={parent()}>
<Keymap.Scope enabled={child()}>
<Scoped />
</Keymap.Scope>
</Keymap.Scope>
)
}
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 (
<Keymap.Scope enabled={enabled()}>
<Scoped />
</Keymap.Scope>
)
}
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 (
<Keymap.Scope enabled={enabled()}>
<Keymap.Scope enabled={nested()}>
<Show when={mounted()}>
<Scoped keymap={global} />
</Show>
</Keymap.Scope>
</Keymap.Scope>
)
}
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()
}
})