From a046483e86016a78feddf9801fdb93f85c53837a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 14:17:04 -0500 Subject: [PATCH] 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 --- ui-tui/packages/hermes-ink/src/ink/dom.ts | 1 + ui-tui/src/components/appOverlays.tsx | 9 ++-- ui-tui/src/components/modelPicker.tsx | 36 +++++++------- ui-tui/src/components/overlayControls.tsx | 41 ++++++++++++++++ ui-tui/src/components/sessionPicker.tsx | 14 +++--- ui-tui/src/components/skillsHub.tsx | 58 ++++++++++++----------- 6 files changed, 102 insertions(+), 57 deletions(-) create mode 100644 ui-tui/src/components/overlayControls.tsx diff --git a/ui-tui/packages/hermes-ink/src/ink/dom.ts b/ui-tui/packages/hermes-ink/src/ink/dom.ts index 1a1ad4af49..938f01f814 100644 --- a/ui-tui/packages/hermes-ink/src/ink/dom.ts +++ b/ui-tui/packages/hermes-ink/src/ink/dom.ts @@ -345,6 +345,7 @@ const measureTextNode = function ( // pathological frames where yoga probes many widths. if (cache.entries.size >= MEASURE_CACHE_CAP) { const firstKey = cache.entries.keys().next().value + if (firstKey !== undefined) { cache.entries.delete(firstKey) } diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 331fb58733..9e1b6ded30 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -9,6 +9,7 @@ import { $uiState } from '../app/uiStore.js' import { FloatBox } from './appChrome.js' import { MaskedPrompt } from './maskedPrompt.js' import { ModelPicker } from './modelPicker.js' +import { OverlayControls } from './overlayControls.js' import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js' import { SessionPicker } from './sessionPicker.js' import { SkillsHub } from './skillsHub.js' @@ -162,11 +163,11 @@ export function FloatingOverlays({ ))} - + {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})` - : `end · ↑↓/jk · b/PgUp back · g top · q close (${overlay.pager.lines.length} lines)`} - + ? `↑↓/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 · Esc/q close (${overlay.pager.lines.length} lines)`} + diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 7927f3b736..3e5c8c3648 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -7,6 +7,8 @@ import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes. import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' +import { OverlayControls, useOverlayKeys } from './overlayControls.js' + const VISIBLE = 12 const MIN_WIDTH = 40 const MAX_WIDTH = 90 @@ -71,20 +73,20 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke const models = provider?.models ?? [] const names = useMemo(() => providerDisplayNames(providers), [providers]) - useInput((ch, key) => { - if (key.escape) { - if (stage === 'model') { - setStage('provider') - setModelIdx(0) - - return - } - - onCancel() + const back = () => { + if (stage === 'model') { + setStage('provider') + setModelIdx(0) return } + onCancel() + } + + useOverlayKeys({ onBack: back, onClose: onCancel }) + + useInput((ch, key) => { const count = stage === 'provider' ? providers.length : models.length const sel = stage === 'provider' ? providerIdx : modelIdx const setSel = stage === 'provider' ? setProviderIdx : setModelIdx @@ -155,7 +157,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke return ( error: {err} - Esc to cancel + Esc/q cancel ) } @@ -164,7 +166,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke return ( no authenticated providers - Esc to cancel + Esc/q cancel ) } @@ -221,9 +223,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke persist: {persistGlobal ? 'global' : 'session'} · g toggle - - ↑/↓ select · Enter choose · 1-9,0 quick · Esc cancel - + ↑/↓ select · Enter choose · 1-9,0 quick · Esc/q cancel ) } @@ -283,9 +283,9 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke persist: {persistGlobal ? 'global' : 'session'} · g toggle - - {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'} + ) } diff --git a/ui-tui/src/components/overlayControls.tsx b/ui-tui/src/components/overlayControls.tsx new file mode 100644 index 0000000000..03d28127ed --- /dev/null +++ b/ui-tui/src/components/overlayControls.tsx @@ -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 ( + + {children} + + ) +} + +interface OverlayControlsProps { + children: string + t: Theme + wrap?: TextWrap +} + +interface OverlayKeysOptions { + disabled?: boolean + onBack?: () => void + onClose: () => void +} diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index c840782399..fa1529ef4c 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -6,6 +6,8 @@ import type { SessionListItem, SessionListResponse } from '../gatewayTypes.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' +import { OverlayControls, useOverlayKeys } from './overlayControls.js' + const VISIBLE = 15 const MIN_WIDTH = 60 const MAX_WIDTH = 120 @@ -33,6 +35,8 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) const { stdout } = useStdout() const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + useOverlayKeys({ onClose: onCancel }) + useEffect(() => { gw.request('session.list', { limit: 20 }) .then(raw => { @@ -56,10 +60,6 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) }, [gw]) useInput((ch, key) => { - if (key.escape) { - return onCancel() - } - if (key.upArrow && sel > 0) { setSel(s => s - 1) } @@ -87,7 +87,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) return ( error: {err} - Esc to cancel + Esc/q cancel ) } @@ -96,7 +96,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) return ( no previous sessions - Esc to cancel + Esc/q cancel ) } @@ -141,7 +141,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) })} {off + VISIBLE < items.length && ↓ {items.length - off - VISIBLE} more} - ↑/↓ select · Enter resume · 1-9 quick · Esc cancel + ↑/↓ select · Enter resume · 1-9 quick · Esc/q cancel ) } diff --git a/ui-tui/src/components/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx index 1bff92c0c8..44710645a8 100644 --- a/ui-tui/src/components/skillsHub.tsx +++ b/ui-tui/src/components/skillsHub.tsx @@ -5,6 +5,8 @@ import type { GatewayClient } from '../gatewayClient.js' import { rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' +import { OverlayControls, useOverlayKeys } from './overlayControls.js' + const VISIBLE = 12 const MIN_WIDTH = 40 const MAX_WIDTH = 90 @@ -48,6 +50,27 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { const skills = selectedCat ? (skillsByCat[selectedCat] ?? []) : [] 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) => { setInfo(null) setErr('') @@ -72,27 +95,6 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { 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 (key.return) { setStage('skill') @@ -193,7 +195,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { return ( error: {err} - Esc to cancel + Esc/q cancel ) } @@ -202,7 +204,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { return ( no skills available - Esc to cancel + Esc/q cancel ) } @@ -238,7 +240,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { })} {off + VISIBLE < rows.length && ↓ {rows.length - off - VISIBLE} more} - ↑/↓ select · Enter open · 1-9,0 quick · Esc cancel + ↑/↓ select · Enter open · 1-9,0 quick · Esc/q cancel ) } @@ -274,9 +276,9 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { })} {off + VISIBLE < skills.length && ↓ {skills.length - off - VISIBLE} more} - - {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'} + ) } @@ -294,7 +296,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { {err ? error: {err} : null} {installing ? installing… : null} - i reinspect · x reinstall · Enter/Esc back + i reinspect · x reinstall · Enter/Esc back · q close ) }