mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
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:
parent
46293f618c
commit
79c6896153
8 changed files with 132 additions and 47 deletions
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
27
ui-tui-opentui-v2/src/view/keymap.tsx
Normal file
27
ui-tui-opentui-v2/src/view/keymap.tsx
Normal 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' }
|
||||
]
|
||||
}))
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue