mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
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:
parent
fdcbd2257b
commit
a046483e86
6 changed files with 102 additions and 57 deletions
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
41
ui-tui/src/components/overlayControls.tsx
Normal file
41
ui-tui/src/components/overlayControls.tsx
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue