opentui(keymap): adopt native @opentui/keymap for overlay close + confirm

@opentui/keymap@0.3.2 was installed but unused (the spec said we'd use it). Wire
it natively: createDefaultOpenTuiKeymap(renderer) + <KeymapProvider> at the render
root, and a useCloseLayer(target,onClose) helper that registers a focus-within
Esc/Ctrl+C → close layer. Migrated the close handling of sessionSwitcher, picker,
approvalPrompt (close-only), confirmPrompt (y/n via confirm/cancel commands), and
pager + agentsDashboard (close via keymap; scroll/select stay raw — not cleanly
focus-gated). Overlays gain a root ref + focus-on-mount so the focus-within layer
activates. q-close re-added to pager/dashboard (footer advertises it).

Composer history/refocus + masked prompt + the Ctrl+C quit machine stay raw by
design (need the in-flight keystroke / careful state). Test harness gains a
withKeymap() wrapper so view tests mount under a provider. 91 pass; live-verified
/sessions + /tools Esc-close and composer focus recovery after.
This commit is contained in:
alt-glitch 2026-06-09 06:24:14 +00:00
parent 46293f618c
commit 79c6896153
8 changed files with 132 additions and 47 deletions

View file

@ -19,6 +19,8 @@
* The body of `run` does not change when the backend swaps that's the point of
* the layer; only `makeAppLayer(...)` differs at the edge.
*/
import { createDefaultOpenTuiKeymap } from '@opentui/keymap/opentui'
import { KeymapProvider } from '@opentui/keymap/solid'
import { render } from '@opentui/solid'
import { Deferred, Duration, Effect } from 'effect'
import { writeFileSync } from 'node:fs'
@ -289,6 +291,11 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
if (!renderer.isDestroyed) renderer.destroy()
}
// Native keymap host (Phase 3): one keymap bound to this renderer, provided
// to the whole Solid tree via <KeymapProvider>. Overlays/prompts register
// close (and confirm) layers against it through useCloseLayer/useBindings.
const keymap = createDefaultOpenTuiKeymap(renderer)
// Submit a user turn: the service value is in hand, so `gateway.request(...)`
// is Effect<…, never> — fire it detached with runFork; failures are logged.
const submitPrompt = (text: string) => {
@ -384,19 +391,21 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
yield* Effect.promise(() =>
render(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App
store={store}
onSubmit={submit}
onType={onType}
onRespond={respond}
onResume={onResume}
sessionId={() => gateway.sessionId()}
history={history}
onImagePaste={onImagePaste}
pasteStore={pasteStore}
/>
</ThemeProvider>
<KeymapProvider keymap={keymap}>
<ThemeProvider theme={() => store.state.theme}>
<App
store={store}
onSubmit={submit}
onType={onType}
onRespond={respond}
onResume={onResume}
sessionId={() => gateway.sessionId()}
history={history}
onImagePaste={onImagePaste}
pasteStore={pasteStore}
/>
</ThemeProvider>
</KeymapProvider>
),
renderer
)

View file

@ -0,0 +1,27 @@
/**
* keymap.tsx thin Solid helpers over the native `@opentui/keymap` (Phase 3).
*
* `useCloseLayer` is the shared CLOSE binding for overlays/prompts: a `close`
* command bound to Esc and Ctrl+C, scoped to the overlay's root box via a
* `focus-within` layer (the default when a `target` accessor is present). The
* box itself isn't focused the native `<select>`/`<textarea>` inside it is
* so `focus-within` is what makes the layer active while the overlay owns the
* screen. The keymap host is provided once at the entry by `<KeymapProvider>`.
*/
import type { BoxRenderable } from '@opentui/core'
import { useBindings } from '@opentui/keymap/solid'
/**
* Bind Esc / Ctrl+C `onClose`, scoped to the given root box (focus-within).
* Until the ref resolves the layer simply isn't registered (useBindings waits).
*/
export function useCloseLayer(target: () => BoxRenderable | undefined, onClose: () => void): void {
useBindings<BoxRenderable>(() => ({
target,
commands: [{ name: 'close', run() { onClose() } }],
bindings: [
{ key: 'escape', cmd: 'close' },
{ key: { name: 'c', ctrl: true }, cmd: 'close' }
]
}))
}

View file

@ -6,13 +6,14 @@
* - bottom: the SELECTED subagent's live trace (goal · status · model, latest
* thought, and the tool/progress/summary log) sticky-bottom so it follows
* live; PgUp/PgDn scroll it.
* Esc/q close. §8 #2 scrollbox gotchas (minHeight:0, sticky bottom).
* Esc/Ctrl+C close (native keymap). §8 #2 scrollbox gotchas (minHeight:0, sticky bottom).
*/
import { type ScrollBoxRenderable } from '@opentui/core'
import { type BoxRenderable, type ScrollBoxRenderable } from '@opentui/core'
import { useKeyboard } from '@opentui/solid'
import { createSignal, For, Show } from 'solid-js'
import { createSignal, For, onMount, Show } from 'solid-js'
import type { SubagentInfo } from '../../logic/store.ts'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
const PAGE = 8
@ -28,17 +29,21 @@ function statusColor(status: string, theme: ReturnType<typeof useTheme>): string
export function AgentsDashboard(props: { subagents: SubagentInfo[]; onClose: () => void }) {
const theme = useTheme()
const [sel, setSel] = createSignal(0)
let rootRef: BoxRenderable | undefined
let traceBox: ScrollBoxRenderable | undefined
const count = () => props.subagents.length
const selected = () => Math.min(sel(), Math.max(0, count() - 1))
const current = () => props.subagents[selected()]
// Close (Esc/Ctrl+C) is the native keymap; select + scroll stay in the raw global
// handler below. Focus the root box on mount so the focus-within close layer is active.
onMount(() => rootRef?.focus())
useCloseLayer(() => rootRef, () => props.onClose())
useKeyboard(key => {
if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
props.onClose()
return
}
// `q` closes (footer advertises "Esc/q close"); Esc/Ctrl+C close via the keymap.
if (key.name === 'q') return props.onClose()
if (key.name === 'up') setSel(s => Math.max(0, s - 1))
else if (key.name === 'down') setSel(s => Math.min(Math.max(0, count() - 1), s + 1))
else if (key.name === 'pageup') traceBox?.scrollBy(-PAGE)
@ -46,7 +51,12 @@ export function AgentsDashboard(props: { subagents: SubagentInfo[]; onClose: ()
})
return (
<box style={{ borderColor: theme().color.accent, flexDirection: 'column', flexGrow: 1, minHeight: 0 }} border>
<box
ref={el => (rootRef = el)}
focusable
style={{ borderColor: theme().color.accent, flexDirection: 'column', flexGrow: 1, minHeight: 0 }}
border
>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text fg={theme().color.accent}>
<b>

View file

@ -4,28 +4,35 @@
* /tools) at once. Replaces the transcript+composer while open (the App swaps it
* in on `store.state.pager`).
*
* Scrolling is driven explicitly via `useKeyboard` `scrollBy`/`scrollTo` (no
* reliance on scrollbox auto-focus); Esc/q/Ctrl+C close. Carries the §8 #2
* Scrolling is driven explicitly via a GLOBAL `useKeyboard` `scrollBy`/`scrollTo`
* (no reliance on focus); Esc/Ctrl+C close via the native keymap. Carries the §8 #2
* scrollbox gotchas (minHeight:0 wrapper+box, NO flexDirection on the box root).
*/
import { type ScrollBoxRenderable } from '@opentui/core'
import { type BoxRenderable, type ScrollBoxRenderable } from '@opentui/core'
import { useKeyboard } from '@opentui/solid'
import { For } from 'solid-js'
import { For, onMount } from 'solid-js'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
const PAGE = 10
export function Pager(props: { title: string; text: string; onClose: () => void }) {
const theme = useTheme()
let rootRef: BoxRenderable | undefined
let box: ScrollBoxRenderable | undefined
const lines = () => props.text.split('\n')
// Close (Esc/Ctrl+C) is the native keymap; scroll keys stay in the raw global
// handler below. Focus the root box on mount so the focus-within close layer is
// active (the scrollbox isn't focused — scroll is global, not focus-gated).
onMount(() => rootRef?.focus())
useCloseLayer(() => rootRef, () => props.onClose())
useKeyboard(key => {
if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
props.onClose()
return
}
// `q` closes (the footer advertises "Esc/q close"); Esc/Ctrl+C close via the
// keymap layer above. Scroll stays raw (not focus-gated).
if (key.name === 'q') return props.onClose()
if (!box) return
if (key.name === 'up') box.scrollBy(-1)
else if (key.name === 'down') box.scrollBy(1)
@ -36,7 +43,12 @@ export function Pager(props: { title: string; text: string; onClose: () => void
})
return (
<box style={{ borderColor: theme().color.accent, flexDirection: 'column', flexGrow: 1, minHeight: 0 }} border>
<box
ref={el => (rootRef = el)}
focusable
style={{ borderColor: theme().color.accent, flexDirection: 'column', flexGrow: 1, minHeight: 0 }}
border
>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text fg={theme().color.accent}>
<b>{props.title}</b>

View file

@ -4,10 +4,11 @@
* Native select nav (/j/k/Enter); a small useKeyboard adds Esc/Ctrl+C close.
* Replaces the composer while open.
*/
import { useKeyboard } from '@opentui/solid'
import type { BoxRenderable } from '@opentui/core'
import { createMemo } from 'solid-js'
import type { PickerItem } from '../../logic/store.ts'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
export function Picker(props: {
@ -17,9 +18,9 @@ export function Picker(props: {
onClose: () => void
}) {
const theme = useTheme()
useKeyboard(key => {
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) props.onClose()
})
let rootRef: BoxRenderable | undefined
// Native select handles ↑↓/j/k/Enter; the keymap owns Esc/Ctrl+C close.
useCloseLayer(() => rootRef, () => props.onClose())
const options = createMemo(() =>
props.items.map(it => ({ description: it.description ?? '', name: it.label, value: it.value }))
@ -27,6 +28,7 @@ export function Picker(props: {
return (
<box
ref={el => (rootRef = el)}
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>

View file

@ -4,10 +4,11 @@
* Enter resumes the chosen session (the entry runs the same resume-hydrate path
* as launch), Esc/Ctrl+C closes. Replaces the composer while open.
*/
import { useKeyboard } from '@opentui/solid'
import type { BoxRenderable } from '@opentui/core'
import { createMemo } from 'solid-js'
import type { SessionItem } from '../../logic/store.ts'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
export function SessionSwitcher(props: {
@ -16,9 +17,9 @@ export function SessionSwitcher(props: {
onClose: () => void
}) {
const theme = useTheme()
useKeyboard(key => {
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) props.onClose()
})
let rootRef: BoxRenderable | undefined
// Native select handles ↑↓/Enter; the keymap owns Esc/Ctrl+C close.
useCloseLayer(() => rootRef, () => props.onClose())
const options = createMemo(() =>
props.sessions.map(s => ({
@ -30,6 +31,7 @@ export function SessionSwitcher(props: {
return (
<box
ref={el => (rootRef = el)}
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>

View file

@ -4,8 +4,9 @@
* adds the Esc/Ctrl+C deny cancel path the select doesn't cover. Answered via
* `approval.respond {choice, session_id}`.
*/
import { useKeyboard } from '@opentui/solid'
import type { BoxRenderable } from '@opentui/core'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
const OPTIONS = [
@ -22,12 +23,14 @@ export function ApprovalPrompt(props: {
onCancel: () => void
}) {
const theme = useTheme()
useKeyboard(key => {
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) props.onCancel()
})
let rootRef: BoxRenderable | undefined
// Native select handles ↑↓/j/k/Enter over the options; the keymap owns the
// Esc/Ctrl+C → deny cancel path the select doesn't cover.
useCloseLayer(() => rootRef, () => props.onCancel())
return (
<box
ref={el => (rootRef = el)}
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>

View file

@ -3,19 +3,39 @@
* callback, not an RPC: y/Enter confirm, n/Esc/Ctrl+C cancel. Used by client
* slash commands like /clear and /new.
*/
import { useKeyboard } from '@opentui/solid'
import type { BoxRenderable } from '@opentui/core'
import { useBindings } from '@opentui/keymap/solid'
import { onMount } from 'solid-js'
import { useTheme } from '../theme.tsx'
export function ConfirmPrompt(props: { message: string; onYes: () => void; onNo: () => void }) {
const theme = useTheme()
useKeyboard(key => {
if (key.name === 'y' || key.name === 'return') props.onYes()
else if (key.name === 'n' || key.name === 'escape' || (key.ctrl && key.name === 'c')) props.onNo()
})
let rootRef: BoxRenderable | undefined
// No focusable child here (unlike the <select> prompts), so focus the dialog box
// itself on mount — that makes the focus-within keymap layer below active.
onMount(() => rootRef?.focus())
// Local Y/N dialog: y/Enter → confirm, n/Esc/Ctrl+C → cancel, scoped to the
// dialog box (focus-within) via the native keymap.
useBindings<BoxRenderable>(() => ({
target: () => rootRef,
commands: [
{ name: 'confirm', run() { props.onYes() } },
{ name: 'cancel', run() { props.onNo() } }
],
bindings: [
{ key: 'y', cmd: 'confirm' },
{ key: 'return', cmd: 'confirm' },
{ key: 'n', cmd: 'cancel' },
{ key: 'escape', cmd: 'cancel' },
{ key: { name: 'c', ctrl: true }, cmd: 'cancel' }
]
}))
return (
<box
ref={el => (rootRef = el)}
focusable
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>