refactor(tui): tighten overlay helpers

- rename overlay help text component to match its role
- share picker window math across model, session, and skills overlays
This commit is contained in:
Brooklyn Nicholson 2026-04-25 14:23:45 -05:00
parent c6fdf48b79
commit 6e83d90eb4
5 changed files with 63 additions and 69 deletions

View file

@ -9,7 +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 { OverlayHint } 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'
@ -163,11 +163,11 @@ export function FloatingOverlays({
))} ))}
<Box marginTop={1}> <Box marginTop={1}>
<OverlayControls t={ui.theme}> <OverlayHint 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 · Esc/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 · Esc/q close (${overlay.pager.lines.length} lines)`} : `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${overlay.pager.lines.length} lines)`}
</OverlayControls> </OverlayHint>
</Box> </Box>
</Box> </Box>
</FloatBox> </FloatBox>

View file

@ -7,20 +7,12 @@ 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' import { OverlayHint, useOverlayKeys, windowItems, windowOffset } 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
const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE))
const visibleItems = (items: string[], sel: number) => {
const off = pageOffset(items.length, sel)
return { items: items.slice(off, off + VISIBLE), off }
}
export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) { export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) {
const [providers, setProviders] = useState<ModelOptionProvider[]>([]) const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [currentModel, setCurrentModel] = useState('') const [currentModel, setCurrentModel] = useState('')
@ -135,16 +127,16 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
const n = ch === '0' ? 10 : parseInt(ch, 10) const n = ch === '0' ? 10 : parseInt(ch, 10)
if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) { if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) {
const off = pageOffset(count, sel) const offset = windowOffset(count, sel, VISIBLE)
if (stage === 'provider') { if (stage === 'provider') {
const next = off + n - 1 const next = offset + n - 1
if (providers[next]) { if (providers[next]) {
setProviderIdx(next) setProviderIdx(next)
} }
} else if (provider && models[off + n - 1]) { } else if (provider && models[offset + n - 1]) {
onSelect(`${models[off + n - 1]} --provider ${provider.slug}${persistGlobal ? ' --global' : ''}`) onSelect(`${models[offset + n - 1]} --provider ${provider.slug}${persistGlobal ? ' --global' : ''}`)
} }
} }
}) })
@ -157,7 +149,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>
<OverlayControls t={t}>Esc/q cancel</OverlayControls> <OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box> </Box>
) )
} }
@ -166,7 +158,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>
<OverlayControls t={t}>Esc/q cancel</OverlayControls> <OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box> </Box>
) )
} }
@ -176,7 +168,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
(p, i) => `${p.is_current ? '*' : ' '} ${names[i]} · ${p.total_models ?? p.models?.length ?? 0} models` (p, i) => `${p.is_current ? '*' : ' '} ${names[i]} · ${p.total_models ?? p.models?.length ?? 0} models`
) )
const { items, off } = visibleItems(rows, providerIdx) const { items, offset } = windowItems(rows, providerIdx, VISIBLE)
return ( return (
<Box flexDirection="column" width={width}> <Box flexDirection="column" width={width}>
@ -191,12 +183,12 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
{provider?.warning ? `warning: ${provider.warning}` : ' '} {provider?.warning ? `warning: ${provider.warning}` : ' '}
</Text> </Text>
<Text color={t.color.dim} wrap="truncate-end"> <Text color={t.color.dim} wrap="truncate-end">
{off > 0 ? `${off} more` : ' '} {offset > 0 ? `${offset} more` : ' '}
</Text> </Text>
{Array.from({ length: VISIBLE }, (_, i) => { {Array.from({ length: VISIBLE }, (_, i) => {
const row = items[i] const row = items[i]
const idx = off + i const idx = offset + i
return row ? ( return row ? (
<Text <Text
@ -217,18 +209,18 @@ 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">
{off + VISIBLE < rows.length ? `${rows.length - off - VISIBLE} more` : ' '} {offset + VISIBLE < rows.length ? `${rows.length - offset - VISIBLE} more` : ' '}
</Text> </Text>
<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>
<OverlayControls t={t}>/ select · Enter choose · 1-9,0 quick · Esc/q cancel</OverlayControls> <OverlayHint t={t}>/ select · Enter choose · 1-9,0 quick · Esc/q cancel</OverlayHint>
</Box> </Box>
) )
} }
const { items, off } = visibleItems(models, modelIdx) const { items, offset } = windowItems(models, modelIdx, VISIBLE)
return ( return (
<Box flexDirection="column" width={width}> <Box flexDirection="column" width={width}>
@ -243,12 +235,12 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
{provider?.warning ? `warning: ${provider.warning}` : ' '} {provider?.warning ? `warning: ${provider.warning}` : ' '}
</Text> </Text>
<Text color={t.color.dim} wrap="truncate-end"> <Text color={t.color.dim} wrap="truncate-end">
{off > 0 ? `${off} more` : ' '} {offset > 0 ? `${offset} more` : ' '}
</Text> </Text>
{Array.from({ length: VISIBLE }, (_, i) => { {Array.from({ length: VISIBLE }, (_, i) => {
const row = items[i] const row = items[i]
const idx = off + i const idx = offset + i
if (!row) { if (!row) {
return !models.length && i === 0 ? ( return !models.length && i === 0 ? (
@ -277,15 +269,15 @@ 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">
{off + VISIBLE < models.length ? `${models.length - off - VISIBLE} more` : ' '} {offset + VISIBLE < models.length ? `${models.length - offset - VISIBLE} more` : ' '}
</Text> </Text>
<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>
<OverlayControls t={t}> <OverlayHint t={t}>
{models.length ? '↑/↓ select · Enter switch · 1-9,0 quick · Esc back · q close' : 'Enter/Esc back · q close'} {models.length ? '↑/↓ select · Enter switch · 1-9,0 quick · Esc back · q close' : 'Enter/Esc back · q close'}
</OverlayControls> </OverlayHint>
</Box> </Box>
) )
} }

View file

@ -2,8 +2,6 @@ import { Text, useInput } from '@hermes/ink'
import type { Theme } from '../theme.js' 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) { export function useOverlayKeys({ disabled = false, onBack, onClose }: OverlayKeysOptions) {
useInput((ch, key) => { useInput((ch, key) => {
if (disabled) { if (disabled) {
@ -20,18 +18,29 @@ export function useOverlayKeys({ disabled = false, onBack, onClose }: OverlayKey
}) })
} }
export function OverlayControls({ children, t, wrap = 'truncate-end' }: OverlayControlsProps) { export function OverlayHint({ children, t }: OverlayHintProps) {
return ( return (
<Text color={t.color.dim} wrap={wrap}> <Text color={t.color.dim} wrap="truncate-end">
{children} {children}
</Text> </Text>
) )
} }
interface OverlayControlsProps { export const windowOffset = (count: number, selected: number, visible: number) =>
Math.max(0, Math.min(selected - Math.floor(visible / 2), count - visible))
export function windowItems<T>(items: T[], selected: number, visible: number) {
const offset = windowOffset(items.length, selected, visible)
return {
items: items.slice(offset, offset + visible),
offset
}
}
interface OverlayHintProps {
children: string children: string
t: Theme t: Theme
wrap?: TextWrap
} }
interface OverlayKeysOptions { interface OverlayKeysOptions {

View file

@ -6,7 +6,7 @@ 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' import { OverlayHint, useOverlayKeys, windowOffset } from './overlayControls.js'
const VISIBLE = 15 const VISIBLE = 15
const MIN_WIDTH = 60 const MIN_WIDTH = 60
@ -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>
<OverlayControls t={t}>Esc/q cancel</OverlayControls> <OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box> </Box>
) )
} }
@ -96,12 +96,12 @@ 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>
<OverlayControls t={t}>Esc/q cancel</OverlayControls> <OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box> </Box>
) )
} }
const off = Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), items.length - VISIBLE)) const offset = windowOffset(items.length, sel, VISIBLE)
return ( return (
<Box flexDirection="column" width={width}> <Box flexDirection="column" width={width}>
@ -109,10 +109,10 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
Resume Session Resume Session
</Text> </Text>
{off > 0 && <Text color={t.color.dim}> {off} more</Text>} {offset > 0 && <Text color={t.color.dim}> {offset} more</Text>}
{items.slice(off, off + VISIBLE).map((s, vi) => { {items.slice(offset, offset + VISIBLE).map((s, vi) => {
const i = off + vi const i = offset + vi
const selected = sel === i const selected = sel === i
return ( return (
@ -140,8 +140,8 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
) )
})} })}
{off + VISIBLE < items.length && <Text color={t.color.dim}> {items.length - off - VISIBLE} more</Text>} {offset + VISIBLE < items.length && <Text color={t.color.dim}> {items.length - offset - VISIBLE} more</Text>}
<OverlayControls t={t}>/ select · Enter resume · 1-9 quick · Esc/q cancel</OverlayControls> <OverlayHint t={t}>/ select · Enter resume · 1-9 quick · Esc/q cancel</OverlayHint>
</Box> </Box>
) )
} }

View file

@ -5,20 +5,12 @@ 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' import { OverlayHint, useOverlayKeys, windowItems, windowOffset } 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
const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE))
const visibleItems = (items: string[], sel: number) => {
const off = pageOffset(items.length, sel)
return { items: items.slice(off, off + VISIBLE), off }
}
export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
const [skillsByCat, setSkillsByCat] = useState<Record<string, string[]>>({}) const [skillsByCat, setSkillsByCat] = useState<Record<string, string[]>>({})
const [selectedCat, setSelectedCat] = useState('') const [selectedCat, setSelectedCat] = useState('')
@ -161,8 +153,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
const n = ch === '0' ? 10 : parseInt(ch, 10) const n = ch === '0' ? 10 : parseInt(ch, 10)
if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) { if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) {
const off = pageOffset(count, sel) const next = windowOffset(count, sel, VISIBLE) + n - 1
const next = off + n - 1
if (stage === 'category') { if (stage === 'category') {
const cat = cats[next] const cat = cats[next]
@ -195,7 +186,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>
<OverlayControls t={t}>Esc/q cancel</OverlayControls> <OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box> </Box>
) )
} }
@ -204,14 +195,14 @@ 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>
<OverlayControls t={t}>Esc/q cancel</OverlayControls> <OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box> </Box>
) )
} }
if (stage === 'category') { if (stage === 'category') {
const rows = cats.map(c => `${c} · ${skillsByCat[c]?.length ?? 0} skills`) const rows = cats.map(c => `${c} · ${skillsByCat[c]?.length ?? 0} skills`)
const { items, off } = visibleItems(rows, catIdx) const { items, offset } = windowItems(rows, catIdx, VISIBLE)
return ( return (
<Box flexDirection="column" width={width}> <Box flexDirection="column" width={width}>
@ -220,10 +211,10 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
</Text> </Text>
<Text color={t.color.dim}>select a category</Text> <Text color={t.color.dim}>select a category</Text>
{off > 0 && <Text color={t.color.dim}> {off} more</Text>} {offset > 0 && <Text color={t.color.dim}> {offset} more</Text>}
{items.map((row, i) => { {items.map((row, i) => {
const idx = off + i const idx = offset + i
return ( return (
<Text <Text
@ -239,14 +230,14 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
) )
})} })}
{off + VISIBLE < rows.length && <Text color={t.color.dim}> {rows.length - off - VISIBLE} more</Text>} {offset + VISIBLE < rows.length && <Text color={t.color.dim}> {rows.length - offset - VISIBLE} more</Text>}
<OverlayControls t={t}>/ select · Enter open · 1-9,0 quick · Esc/q cancel</OverlayControls> <OverlayHint t={t}>/ select · Enter open · 1-9,0 quick · Esc/q cancel</OverlayHint>
</Box> </Box>
) )
} }
if (stage === 'skill') { if (stage === 'skill') {
const { items, off } = visibleItems(skills, skillIdx) const { items, offset } = windowItems(skills, skillIdx, VISIBLE)
return ( return (
<Box flexDirection="column" width={width}> <Box flexDirection="column" width={width}>
@ -256,10 +247,10 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
<Text color={t.color.dim}>{skills.length} skill(s)</Text> <Text color={t.color.dim}>{skills.length} skill(s)</Text>
{!skills.length ? <Text color={t.color.dim}>no skills in this category</Text> : null} {!skills.length ? <Text color={t.color.dim}>no skills in this category</Text> : null}
{off > 0 && <Text color={t.color.dim}> {off} more</Text>} {offset > 0 && <Text color={t.color.dim}> {offset} more</Text>}
{items.map((row, i) => { {items.map((row, i) => {
const idx = off + i const idx = offset + i
return ( return (
<Text <Text
@ -275,10 +266,12 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
) )
})} })}
{off + VISIBLE < skills.length && <Text color={t.color.dim}> {skills.length - off - VISIBLE} more</Text>} {offset + VISIBLE < skills.length && (
<OverlayControls t={t}> <Text color={t.color.dim}> {skills.length - offset - VISIBLE} more</Text>
)}
<OverlayHint t={t}>
{skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back · q close' : 'Esc back · q close'} {skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back · q close' : 'Esc back · q close'}
</OverlayControls> </OverlayHint>
</Box> </Box>
) )
} }
@ -296,7 +289,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}
<OverlayControls t={t}>i reinspect · x reinstall · Enter/Esc back · q close</OverlayControls> <OverlayHint t={t}>i reinspect · x reinstall · Enter/Esc back · q close</OverlayHint>
</Box> </Box>
) )
} }