fix(tui): share overlay close controls

- add reusable overlay key and help-text helpers for picker-style overlays
- make model, session, skills, and pager hints consistently support Esc/q close behavior
This commit is contained in:
Brooklyn Nicholson 2026-04-25 14:17:04 -05:00
parent fdcbd2257b
commit a046483e86
6 changed files with 102 additions and 57 deletions

View file

@ -345,6 +345,7 @@ const measureTextNode = function (
// pathological frames where yoga probes many widths. // pathological frames where yoga probes many widths.
if (cache.entries.size >= MEASURE_CACHE_CAP) { if (cache.entries.size >= MEASURE_CACHE_CAP) {
const firstKey = cache.entries.keys().next().value const firstKey = cache.entries.keys().next().value
if (firstKey !== undefined) { if (firstKey !== undefined) {
cache.entries.delete(firstKey) cache.entries.delete(firstKey)
} }

View file

@ -9,6 +9,7 @@ import { $uiState } from '../app/uiStore.js'
import { FloatBox } from './appChrome.js' import { FloatBox } from './appChrome.js'
import { MaskedPrompt } from './maskedPrompt.js' import { MaskedPrompt } from './maskedPrompt.js'
import { ModelPicker } from './modelPicker.js' import { ModelPicker } from './modelPicker.js'
import { OverlayControls } from './overlayControls.js'
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js' import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
import { SessionPicker } from './sessionPicker.js' import { SessionPicker } from './sessionPicker.js'
import { SkillsHub } from './skillsHub.js' import { SkillsHub } from './skillsHub.js'
@ -162,11 +163,11 @@ export function FloatingOverlays({
))} ))}
<Box marginTop={1}> <Box marginTop={1}>
<Text color={ui.theme.color.dim}> <OverlayControls t={ui.theme}>
{overlay.pager.offset + pagerPageSize < overlay.pager.lines.length {overlay.pager.offset + pagerPageSize < overlay.pager.lines.length
? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})` ? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})`
: `end · ↑↓/jk · b/PgUp back · g top · q close (${overlay.pager.lines.length} lines)`} : `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${overlay.pager.lines.length} lines)`}
</Text> </OverlayControls>
</Box> </Box>
</Box> </Box>
</FloatBox> </FloatBox>

View file

@ -7,6 +7,8 @@ import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js' import type { Theme } from '../theme.js'
import { OverlayControls, useOverlayKeys } from './overlayControls.js'
const VISIBLE = 12 const VISIBLE = 12
const MIN_WIDTH = 40 const MIN_WIDTH = 40
const MAX_WIDTH = 90 const MAX_WIDTH = 90
@ -71,20 +73,20 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
const models = provider?.models ?? [] const models = provider?.models ?? []
const names = useMemo(() => providerDisplayNames(providers), [providers]) const names = useMemo(() => providerDisplayNames(providers), [providers])
useInput((ch, key) => { const back = () => {
if (key.escape) { if (stage === 'model') {
if (stage === 'model') { setStage('provider')
setStage('provider') setModelIdx(0)
setModelIdx(0)
return
}
onCancel()
return return
} }
onCancel()
}
useOverlayKeys({ onBack: back, onClose: onCancel })
useInput((ch, key) => {
const count = stage === 'provider' ? providers.length : models.length const count = stage === 'provider' ? providers.length : models.length
const sel = stage === 'provider' ? providerIdx : modelIdx const sel = stage === 'provider' ? providerIdx : modelIdx
const setSel = stage === 'provider' ? setProviderIdx : setModelIdx const setSel = stage === 'provider' ? setProviderIdx : setModelIdx
@ -155,7 +157,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
<Text color={t.color.label}>error: {err}</Text> <Text color={t.color.label}>error: {err}</Text>
<Text color={t.color.dim}>Esc to cancel</Text> <OverlayControls t={t}>Esc/q cancel</OverlayControls>
</Box> </Box>
) )
} }
@ -164,7 +166,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
<Text color={t.color.dim}>no authenticated providers</Text> <Text color={t.color.dim}>no authenticated providers</Text>
<Text color={t.color.dim}>Esc to cancel</Text> <OverlayControls t={t}>Esc/q cancel</OverlayControls>
</Box> </Box>
) )
} }
@ -221,9 +223,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
<Text color={t.color.dim} wrap="truncate-end"> <Text color={t.color.dim} wrap="truncate-end">
persist: {persistGlobal ? 'global' : 'session'} · g toggle persist: {persistGlobal ? 'global' : 'session'} · g toggle
</Text> </Text>
<Text color={t.color.dim} wrap="truncate-end"> <OverlayControls t={t}>/ select · Enter choose · 1-9,0 quick · Esc/q cancel</OverlayControls>
/ select · Enter choose · 1-9,0 quick · Esc cancel
</Text>
</Box> </Box>
) )
} }
@ -283,9 +283,9 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
<Text color={t.color.dim} wrap="truncate-end"> <Text color={t.color.dim} wrap="truncate-end">
persist: {persistGlobal ? 'global' : 'session'} · g toggle persist: {persistGlobal ? 'global' : 'session'} · g toggle
</Text> </Text>
<Text color={t.color.dim} wrap="truncate-end"> <OverlayControls t={t}>
{models.length ? '↑/↓ select · Enter switch · 1-9,0 quick · Esc back' : 'Enter/Esc back'} {models.length ? '↑/↓ select · Enter switch · 1-9,0 quick · Esc back · q close' : 'Enter/Esc back · q close'}
</Text> </OverlayControls>
</Box> </Box>
) )
} }

View file

@ -0,0 +1,41 @@
import { Text, useInput } from '@hermes/ink'
import type { Theme } from '../theme.js'
type TextWrap = 'end' | 'middle' | 'truncate' | 'truncate-end' | 'truncate-middle' | 'wrap' | 'wrap-char' | 'wrap-trim'
export function useOverlayKeys({ disabled = false, onBack, onClose }: OverlayKeysOptions) {
useInput((ch, key) => {
if (disabled) {
return
}
if (ch.toLowerCase() === 'q') {
return onClose()
}
if (key.escape) {
return onBack ? onBack() : onClose()
}
})
}
export function OverlayControls({ children, t, wrap = 'truncate-end' }: OverlayControlsProps) {
return (
<Text color={t.color.dim} wrap={wrap}>
{children}
</Text>
)
}
interface OverlayControlsProps {
children: string
t: Theme
wrap?: TextWrap
}
interface OverlayKeysOptions {
disabled?: boolean
onBack?: () => void
onClose: () => void
}

View file

@ -6,6 +6,8 @@ import type { SessionListItem, SessionListResponse } from '../gatewayTypes.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js' import type { Theme } from '../theme.js'
import { OverlayControls, useOverlayKeys } from './overlayControls.js'
const VISIBLE = 15 const VISIBLE = 15
const MIN_WIDTH = 60 const MIN_WIDTH = 60
const MAX_WIDTH = 120 const MAX_WIDTH = 120
@ -33,6 +35,8 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
const { stdout } = useStdout() const { stdout } = useStdout()
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
useOverlayKeys({ onClose: onCancel })
useEffect(() => { useEffect(() => {
gw.request<SessionListResponse>('session.list', { limit: 20 }) gw.request<SessionListResponse>('session.list', { limit: 20 })
.then(raw => { .then(raw => {
@ -56,10 +60,6 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
}, [gw]) }, [gw])
useInput((ch, key) => { useInput((ch, key) => {
if (key.escape) {
return onCancel()
}
if (key.upArrow && sel > 0) { if (key.upArrow && sel > 0) {
setSel(s => s - 1) setSel(s => s - 1)
} }
@ -87,7 +87,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
<Text color={t.color.label}>error: {err}</Text> <Text color={t.color.label}>error: {err}</Text>
<Text color={t.color.dim}>Esc to cancel</Text> <OverlayControls t={t}>Esc/q cancel</OverlayControls>
</Box> </Box>
) )
} }
@ -96,7 +96,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
<Text color={t.color.dim}>no previous sessions</Text> <Text color={t.color.dim}>no previous sessions</Text>
<Text color={t.color.dim}>Esc to cancel</Text> <OverlayControls t={t}>Esc/q cancel</OverlayControls>
</Box> </Box>
) )
} }
@ -141,7 +141,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
})} })}
{off + VISIBLE < items.length && <Text color={t.color.dim}> {items.length - off - VISIBLE} more</Text>} {off + VISIBLE < items.length && <Text color={t.color.dim}> {items.length - off - VISIBLE} more</Text>}
<Text color={t.color.dim}>/ select · Enter resume · 1-9 quick · Esc cancel</Text> <OverlayControls t={t}>/ select · Enter resume · 1-9 quick · Esc/q cancel</OverlayControls>
</Box> </Box>
) )
} }

View file

@ -5,6 +5,8 @@ import type { GatewayClient } from '../gatewayClient.js'
import { rpcErrorMessage } from '../lib/rpc.js' import { rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js' import type { Theme } from '../theme.js'
import { OverlayControls, useOverlayKeys } from './overlayControls.js'
const VISIBLE = 12 const VISIBLE = 12
const MIN_WIDTH = 40 const MIN_WIDTH = 40
const MAX_WIDTH = 90 const MAX_WIDTH = 90
@ -48,6 +50,27 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
const skills = selectedCat ? (skillsByCat[selectedCat] ?? []) : [] const skills = selectedCat ? (skillsByCat[selectedCat] ?? []) : []
const skillName = skills[skillIdx] ?? '' const skillName = skills[skillIdx] ?? ''
const back = () => {
if (stage === 'actions') {
setStage('skill')
setInfo(null)
setErr('')
return
}
if (stage === 'skill') {
setStage('category')
setSkillIdx(0)
return
}
onClose()
}
useOverlayKeys({ disabled: installing, onBack: back, onClose })
const inspect = (name: string) => { const inspect = (name: string) => {
setInfo(null) setInfo(null)
setErr('') setErr('')
@ -72,27 +95,6 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
return return
} }
if (key.escape) {
if (stage === 'actions') {
setStage('skill')
setInfo(null)
setErr('')
return
}
if (stage === 'skill') {
setStage('category')
setSkillIdx(0)
return
}
onClose()
return
}
if (stage === 'actions') { if (stage === 'actions') {
if (key.return) { if (key.return) {
setStage('skill') setStage('skill')
@ -193,7 +195,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
return ( return (
<Box flexDirection="column" width={width}> <Box flexDirection="column" width={width}>
<Text color={t.color.label}>error: {err}</Text> <Text color={t.color.label}>error: {err}</Text>
<Text color={t.color.dim}>Esc to cancel</Text> <OverlayControls t={t}>Esc/q cancel</OverlayControls>
</Box> </Box>
) )
} }
@ -202,7 +204,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
return ( return (
<Box flexDirection="column" width={width}> <Box flexDirection="column" width={width}>
<Text color={t.color.dim}>no skills available</Text> <Text color={t.color.dim}>no skills available</Text>
<Text color={t.color.dim}>Esc to cancel</Text> <OverlayControls t={t}>Esc/q cancel</OverlayControls>
</Box> </Box>
) )
} }
@ -238,7 +240,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
})} })}
{off + VISIBLE < rows.length && <Text color={t.color.dim}> {rows.length - off - VISIBLE} more</Text>} {off + VISIBLE < rows.length && <Text color={t.color.dim}> {rows.length - off - VISIBLE} more</Text>}
<Text color={t.color.dim}>/ select · Enter open · 1-9,0 quick · Esc cancel</Text> <OverlayControls t={t}>/ select · Enter open · 1-9,0 quick · Esc/q cancel</OverlayControls>
</Box> </Box>
) )
} }
@ -274,9 +276,9 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
})} })}
{off + VISIBLE < skills.length && <Text color={t.color.dim}> {skills.length - off - VISIBLE} more</Text>} {off + VISIBLE < skills.length && <Text color={t.color.dim}> {skills.length - off - VISIBLE} more</Text>}
<Text color={t.color.dim}> <OverlayControls t={t}>
{skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back' : 'Esc back'} {skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back · q close' : 'Esc back · q close'}
</Text> </OverlayControls>
</Box> </Box>
) )
} }
@ -294,7 +296,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
{err ? <Text color={t.color.label}>error: {err}</Text> : null} {err ? <Text color={t.color.label}>error: {err}</Text> : null}
{installing ? <Text color={t.color.amber}>installing</Text> : null} {installing ? <Text color={t.color.amber}>installing</Text> : null}
<Text color={t.color.dim}>i reinspect · x reinstall · Enter/Esc back</Text> <OverlayControls t={t}>i reinspect · x reinstall · Enter/Esc back · q close</OverlayControls>
</Box> </Box>
) )
} }