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
)
}