Compare commits

...

3 Commits

Author SHA1 Message Date
Brooklyn Nicholson
dff5dc34ce Merge origin/main into bb/widget-grid-slots
Resolve the appOverlays.tsx conflict by keeping the widget-grid overlay layout while adopting main's focused ui selectors for theme/session subscriptions.
2026-05-05 15:44:41 -05:00
Brooklyn Nicholson
4532182bda refactor(ui-tui): defer overlay selector perf split
Revert focused overlay selector subscriptions from the widget-grid branch so the layout PR stays scoped to grid behavior and picker sizing only.
2026-05-05 15:29:30 -05:00
Brooklyn Nicholson
dbbd5512d5 feat(ui-tui): add responsive overlay widget grid
Introduce a shared widget grid and width-capped overlay pickers so wide terminals can tile widgets cleanly while reducing overlay rerenders via focused ui store selectors.
2026-05-05 15:09:18 -05:00
7 changed files with 385 additions and 58 deletions

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { layoutWidgetGrid } from '../lib/widgetGrid.js'
describe('layoutWidgetGrid', () => {
it('falls back to a single column on narrow widths', () => {
const layout = layoutWidgetGrid({
items: [{ id: 'a' }, { id: 'b' }],
maxColumns: 3,
minColumnWidth: 40,
width: 35
})
expect(layout.columnCount).toBe(1)
expect(layout.columns).toEqual([35])
expect(layout.rows).toEqual([[{ col: 0, id: 'a', span: 1, width: 35 }], [{ col: 0, id: 'b', span: 1, width: 35 }]])
})
it('packs spans left-to-right and wraps to the next row', () => {
const layout = layoutWidgetGrid({
gap: 2,
items: [
{ id: 'a', span: 1 },
{ id: 'b', span: 2 },
{ id: 'c', span: 1 }
],
maxColumns: 3,
minColumnWidth: 30,
width: 100
})
expect(layout.columnCount).toBe(3)
expect(layout.columns).toEqual([32, 32, 32])
expect(layout.rows).toEqual([
[
{ col: 0, id: 'a', span: 1, width: 32 },
{ col: 1, id: 'b', span: 2, width: 66 }
],
[{ col: 0, id: 'c', span: 1, width: 32 }]
])
})
it('clamps spans to available columns', () => {
const layout = layoutWidgetGrid({
gap: 1,
items: [{ id: 'huge', span: 9 }],
maxColumns: 2,
minColumnWidth: 20,
width: 50
})
expect(layout.columnCount).toBe(2)
expect(layout.rows[0]?.[0]).toEqual({
col: 0,
id: 'huge',
span: 2,
width: 50
})
})
})

View File

@@ -13,6 +13,7 @@ import { OverlayHint } from './overlayControls.js'
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
import { SessionPicker } from './sessionPicker.js'
import { SkillsHub } from './skillsHub.js'
import { WidgetGrid, type WidgetGridWidget } from './widgetGrid.js'
const COMPLETION_WINDOW = 16
@@ -110,6 +111,11 @@ export function FloatingOverlays({
return null
}
const gridCols = Math.max(24, cols - 2)
const gridMaxColumns = cols >= 120 ? 2 : 1
const fullSpan = gridMaxColumns
const capWidth = (cellWidth: number) => Math.max(24, cellWidth - 4)
// Fixed viewport centered on compIdx — previously the slice end was
// compIdx + 8 so the dropdown grew from 8 rows to 16 as the user scrolled
// down, bouncing the height on every keystroke.
@@ -117,66 +123,99 @@ export function FloatingOverlays({
const start = Math.max(0, Math.min(compIdx - Math.floor(COMPLETION_WINDOW / 2), completions.length - viewportSize))
return (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
{overlay.picker && (
const widgets: WidgetGridWidget[] = []
if (overlay.picker) {
widgets.push({
id: 'picker',
render: width => (
<FloatBox color={theme.color.border}>
<SessionPicker
gw={gw}
maxWidth={capWidth(width)}
onCancel={() => patchOverlayState({ picker: false })}
onSelect={onPickerSelect}
t={theme}
/>
</FloatBox>
)}
)
})
}
{overlay.modelPicker && (
if (overlay.modelPicker) {
widgets.push({
id: 'model-picker',
render: width => (
<FloatBox color={theme.color.border}>
<ModelPicker
gw={gw}
maxWidth={capWidth(width)}
onCancel={() => patchOverlayState({ modelPicker: false })}
onSelect={onModelSelect}
sessionId={sid}
t={theme}
/>
</FloatBox>
)}
)
})
}
{overlay.skillsHub && (
if (overlay.skillsHub) {
widgets.push({
id: 'skills-hub',
render: width => (
<FloatBox color={theme.color.border}>
<SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={theme} />
<SkillsHub
gw={gw}
maxWidth={capWidth(width)}
onClose={() => patchOverlayState({ skillsHub: false })}
t={theme}
/>
</FloatBox>
)}
)
})
}
{overlay.pager && (
if (overlay.pager) {
const pager = overlay.pager
widgets.push({
id: 'pager',
render: width => (
<FloatBox color={theme.color.border}>
<Box flexDirection="column" paddingX={1} paddingY={1}>
{overlay.pager.title && (
<Box flexDirection="column" paddingX={1} paddingY={1} width={capWidth(width)}>
{pager.title && (
<Box justifyContent="center" marginBottom={1}>
<Text bold color={theme.color.primary}>
{overlay.pager.title}
{pager.title}
</Text>
</Box>
)}
{overlay.pager.lines.slice(overlay.pager.offset, overlay.pager.offset + pagerPageSize).map((line, i) => (
{pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => (
<Text key={i}>{line}</Text>
))}
<Box marginTop={1}>
<OverlayHint t={theme}>
{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})`
: `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${overlay.pager.lines.length} lines)`}
{pager.offset + pagerPageSize < pager.lines.length
? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})`
: `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${pager.lines.length} lines)`}
</OverlayHint>
</Box>
</Box>
</FloatBox>
)}
),
span: fullSpan
})
}
{!!completions.length && (
if (completions.length) {
widgets.push({
id: 'completions',
render: width => (
<FloatBox color={theme.color.primary}>
<Box flexDirection="column" width={Math.max(28, cols - 6)}>
<Box flexDirection="column" width={capWidth(width)}>
{completions.slice(start, start + viewportSize).map((item, i) => {
const active = start + i === compIdx
@@ -197,7 +236,14 @@ export function FloatingOverlays({
})}
</Box>
</FloatBox>
)}
),
span: fullSpan
})
}
return (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
<WidgetGrid cols={gridCols} maxColumns={gridMaxColumns} minColumnWidth={46} rowGap={0} widgets={widgets} />
</Box>
)
}

View File

@@ -16,7 +16,7 @@ const MAX_WIDTH = 90
type Stage = 'provider' | 'key' | 'model' | 'disconnect'
export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) {
export function ModelPicker({ gw, maxWidth, onCancel, onSelect, sessionId, t }: ModelPickerProps) {
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [currentModel, setCurrentModel] = useState('')
const [err, setErr] = useState('')
@@ -30,11 +30,13 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
const [keyError, setKeyError] = useState('')
const { stdout } = useStdout()
// Pin the picker to a stable width so the FloatBox parent (which shrinks-
// to-fit with alignSelf="flex-start") doesn't resize as long provider /
// model names scroll into view, and so `wrap="truncate-end"` on each row
// has an actual constraint to truncate against.
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
// Pin the picker to a stable width so the FloatBox parent doesn't resize as
// long provider/model names scroll into view. Optional maxWidth lets
// callers constrain it inside multi-column widget layouts.
const terminalWidth = Math.max(1, (stdout?.columns ?? 80) - 6)
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, terminalWidth))
const widthCap = Math.max(24, Math.trunc(maxWidth ?? preferredWidth))
const width = Math.max(24, Math.min(preferredWidth, widthCap))
useEffect(() => {
gw.request<ModelOptionsResponse>('model.options', sessionId ? { session_id: sessionId } : {})
@@ -105,7 +107,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
gw.request<{ provider?: ModelOptionProvider }>('model.save_key', {
slug: provider?.slug,
api_key: keyInput.trim(),
...(sessionId ? { session_id: sessionId } : {}),
...(sessionId ? { session_id: sessionId } : {})
})
.then(raw => {
const r = asRpcResult<{ provider?: ModelOptionProvider }>(raw)
@@ -118,9 +120,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
}
// Update the provider in our list with fresh data
setProviders(prev =>
prev.map(p => p.slug === r.provider!.slug ? r.provider! : p)
)
setProviders(prev => prev.map(p => (p.slug === r.provider!.slug ? r.provider! : p)))
setKeyInput('')
setKeySaving(false)
setStage('model')
@@ -166,7 +166,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
setKeySaving(true)
gw.request<{ disconnected?: boolean }>('model.disconnect', {
slug: provider.slug,
...(sessionId ? { session_id: sessionId } : {}),
...(sessionId ? { session_id: sessionId } : {})
})
.then(raw => {
const r = asRpcResult<{ disconnected?: boolean }>(raw)
@@ -174,9 +174,16 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
if (r?.disconnected) {
// Mark provider as unauthenticated in local state
setProviders(prev =>
prev.map(p => p.slug === provider.slug
? { ...p, authenticated: false, models: [], total_models: 0, warning: p.key_env ? `paste ${p.key_env} to activate` : 'run `hermes model` to configure' }
: p
prev.map(p =>
p.slug === provider.slug
? {
...p,
authenticated: false,
models: [],
total_models: 0,
warning: p.key_env ? `paste ${p.key_env} to activate` : 'run `hermes model` to configure'
}
: p
)
)
}
@@ -302,17 +309,23 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
Paste your API key below (saved to ~/.hermes/.env)
</Text>
<Text color={t.color.muted} wrap="truncate-end"> </Text>
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{provider.key_env}:
</Text>
<Text color={t.color.accent} wrap="truncate-end">
{' '}{masked || '(empty)'}{keySaving ? '' : '▎'}
{' '}
{masked || '(empty)'}
{keySaving ? '' : '▎'}
</Text>
<Text color={t.color.muted} wrap="truncate-end"> </Text>
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
{keyError ? (
<Text color={t.color.label} wrap="truncate-end">
@@ -323,7 +336,9 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
saving
</Text>
) : (
<Text color={t.color.muted} wrap="truncate-end"> </Text>
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
)}
<OverlayHint t={t}>Enter save · Ctrl+U clear · Esc back</OverlayHint>
@@ -339,7 +354,9 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
Disconnect {provider.name}?
</Text>
<Text color={t.color.muted} wrap="truncate-end"> </Text>
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
This removes saved credentials for {provider.name}.
@@ -349,10 +366,14 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
You can re-authenticate later by selecting it again.
</Text>
<Text color={t.color.muted} wrap="truncate-end"> </Text>
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
{keySaving ? (
<Text color={t.color.muted} wrap="truncate-end">disconnecting</Text>
<Text color={t.color.muted} wrap="truncate-end">
disconnecting
</Text>
) : (
<OverlayHint t={t}>y/Enter confirm · n/Esc cancel</OverlayHint>
)}
@@ -362,17 +383,14 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
// ── Provider selection stage ─────────────────────────────────────────
if (stage === 'provider') {
const rows = providers.map(
(p, i) => {
const authMark = p.authenticated === false ? '○' : p.is_current ? '*' : '●'
const modelCount = p.total_models ?? p.models?.length ?? 0
const suffix = p.authenticated === false
? (p.auth_type === 'api_key' ? '(no key)' : '(needs setup)')
: `${modelCount} models`
const rows = providers.map((p, i) => {
const authMark = p.authenticated === false ? '○' : p.is_current ? '*' : '●'
const modelCount = p.total_models ?? p.models?.length ?? 0
const suffix =
p.authenticated === false ? (p.auth_type === 'api_key' ? '(no key)' : '(needs setup)') : `${modelCount} models`
return `${authMark} ${names[i]} · ${suffix}`
}
)
return `${authMark} ${names[i]} · ${suffix}`
})
const { items, offset } = windowItems(rows, providerIdx, VISIBLE)
@@ -499,6 +517,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
interface ModelPickerProps {
gw: GatewayClient
maxWidth?: number
onCancel: () => void
onSelect: (value: string) => void
sessionId: string | null

View File

@@ -26,7 +26,7 @@ const age = (ts: number) => {
return `${Math.floor(d)}d ago`
}
export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) {
export function SessionPicker({ gw, maxWidth, onCancel, onSelect, t }: SessionPickerProps) {
const [items, setItems] = useState<SessionListItem[]>([])
const [err, setErr] = useState('')
const [sel, setSel] = useState(0)
@@ -37,7 +37,10 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
const [deleting, setDeleting] = useState(false)
const { stdout } = useStdout()
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const terminalWidth = Math.max(1, (stdout?.columns ?? 80) - 6)
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, terminalWidth))
const widthCap = Math.max(24, Math.trunc(maxWidth ?? preferredWidth))
const width = Math.max(24, Math.min(preferredWidth, widthCap))
useOverlayKeys({ onClose: onCancel })
@@ -171,7 +174,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
Resume Session
</Text>
{offset > 0 && <Text color={t.color.muted}> {offset} more</Text>}
{offset > 0 && <Text color={t.color.muted}> {offset} more</Text>}
{items.slice(offset, offset + VISIBLE).map((s, vi) => {
const i = offset + vi
@@ -208,7 +211,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
)
})}
{offset + VISIBLE < items.length && <Text color={t.color.muted}> {items.length - offset - VISIBLE} more</Text>}
{offset + VISIBLE < items.length && <Text color={t.color.muted}> {items.length - offset - VISIBLE} more</Text>}
{err && <Text color={t.color.label}>error: {err}</Text>}
{deleting ? (
<OverlayHint t={t}>deleting</OverlayHint>
@@ -221,6 +224,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
interface SessionPickerProps {
gw: GatewayClient
maxWidth?: number
onCancel: () => void
onSelect: (id: string) => void
t: Theme

View File

@@ -11,7 +11,7 @@ const VISIBLE = 12
const MIN_WIDTH = 40
const MAX_WIDTH = 90
export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
export function SkillsHub({ gw, maxWidth, onClose, t }: SkillsHubProps) {
const [skillsByCat, setSkillsByCat] = useState<Record<string, string[]>>({})
const [selectedCat, setSelectedCat] = useState('')
const [catIdx, setCatIdx] = useState(0)
@@ -23,7 +23,10 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
const [loading, setLoading] = useState(true)
const { stdout } = useStdout()
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const terminalWidth = Math.max(1, (stdout?.columns ?? 80) - 6)
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, terminalWidth))
const widthCap = Math.max(24, Math.trunc(maxWidth ?? preferredWidth))
const width = Math.max(24, Math.min(preferredWidth, widthCap))
useEffect(() => {
gw.request<{ skills?: Record<string, string[]> }>('skills.manage', { action: 'list' })
@@ -303,6 +306,7 @@ interface SkillInfo {
interface SkillsHubProps {
gw: GatewayClient
maxWidth?: number
onClose: () => void
t: Theme
}

View File

@@ -0,0 +1,90 @@
import { Box } from '@hermes/ink'
import { Fragment, memo, useMemo, type ReactNode } from 'react'
import { layoutWidgetGrid, type WidgetGridCell, type WidgetGridItem } from '../lib/widgetGrid.js'
export interface WidgetGridWidget extends WidgetGridItem {
render: (width: number) => ReactNode
}
interface WidgetGridProps {
cols: number
gap?: number
maxColumns?: number
minColumnWidth?: number
rowGap?: number
widgets: WidgetGridWidget[]
}
export const WidgetGrid = memo(function WidgetGrid({
cols,
gap = 2,
maxColumns = 2,
minColumnWidth = 46,
rowGap = 1,
widgets
}: WidgetGridProps) {
const layout = useMemo(
() =>
layoutWidgetGrid({
gap,
items: widgets.map(({ id, span }) => ({ id, span })),
maxColumns,
minColumnWidth,
width: cols
}),
[cols, gap, maxColumns, minColumnWidth, widgets]
)
const widgetById = useMemo(() => new Map(widgets.map(widget => [widget.id, widget])), [widgets])
if (!layout.rows.length) {
return null
}
return (
<Box flexDirection="column" width={Math.max(1, cols)}>
{layout.rows.map((row, rowIdx) => (
<Box flexDirection="column" key={`row-${rowIdx}`}>
<Box flexDirection="row">
{row.map((cell, cellIdx) => (
<WidgetCell
cell={cell}
gap={gap}
isLast={cellIdx === row.length - 1}
key={cell.id}
widget={widgetById.get(cell.id)}
/>
))}
</Box>
{rowGap > 0 && rowIdx < layout.rows.length - 1 ? <Box height={rowGap} /> : null}
</Box>
))}
</Box>
)
})
const WidgetCell = memo(function WidgetCell({
cell,
gap,
isLast,
widget
}: {
cell: WidgetGridCell
gap: number
isLast: boolean
widget?: WidgetGridWidget
}) {
const node = widget?.render(cell.width) ?? null
return (
<Fragment>
<Box flexShrink={0} width={cell.width}>
{node}
</Box>
{!isLast && gap > 0 ? <Box flexShrink={0} width={gap} /> : null}
</Fragment>
)
})

View File

@@ -0,0 +1,104 @@
export interface WidgetGridItem {
id: string
span?: number
}
export interface WidgetGridCell {
col: number
id: string
span: number
width: number
}
export interface WidgetGridLayout {
columnCount: number
columns: number[]
rows: WidgetGridCell[][]
}
export interface WidgetGridLayoutOptions {
gap?: number
items: WidgetGridItem[]
maxColumns?: number
minColumnWidth?: number
width: number
}
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value))
const toInt = (value: number, fallback: number) => {
if (!Number.isFinite(value)) {
return fallback
}
return Math.trunc(value)
}
const columnCountForWidth = (width: number, minColumnWidth: number, gap: number, maxColumns: number) => {
const safeWidth = Math.max(1, toInt(width, 1))
const safeMinWidth = Math.max(1, toInt(minColumnWidth, 1))
const safeGap = Math.max(0, toInt(gap, 0))
const safeMaxColumns = Math.max(1, toInt(maxColumns, 1))
const count = Math.floor((safeWidth + safeGap) / (safeMinWidth + safeGap))
return clamp(count || 1, 1, safeMaxColumns)
}
const buildColumnWidths = (width: number, columnCount: number, gap: number) => {
const safeWidth = Math.max(1, toInt(width, 1))
const safeGap = Math.max(0, toInt(gap, 0))
const slots = Math.max(1, toInt(columnCount, 1))
const usable = Math.max(1, safeWidth - safeGap * Math.max(0, slots - 1))
const base = Math.floor(usable / slots)
const remainder = usable % slots
return Array.from({ length: slots }, (_, idx) => base + (idx < remainder ? 1 : 0))
}
const spanWidth = (columns: number[], colStart: number, span: number, gap: number) => {
const end = Math.min(columns.length, colStart + span)
const width = columns.slice(colStart, end).reduce((acc, value) => acc + value, 0)
const safeGap = Math.max(0, toInt(gap, 0))
return width + safeGap * Math.max(0, end - colStart - 1)
}
export function layoutWidgetGrid({
gap = 1,
items,
maxColumns = 3,
minColumnWidth = 28,
width
}: WidgetGridLayoutOptions): WidgetGridLayout {
const safeGap = Math.max(0, toInt(gap, 1))
const columnCount = columnCountForWidth(width, minColumnWidth, safeGap, maxColumns)
const columns = buildColumnWidths(width, columnCount, safeGap)
const rows: WidgetGridCell[][] = []
let row: WidgetGridCell[] = []
let usedCols = 0
for (const item of items) {
const wantedSpan = clamp(toInt(item.span ?? 1, 1), 1, columnCount)
if (row.length > 0 && usedCols + wantedSpan > columnCount) {
rows.push(row)
row = []
usedCols = 0
}
row.push({
col: usedCols,
id: item.id,
span: wantedSpan,
width: spanWidth(columns, usedCols, wantedSpan, safeGap)
})
usedCols += wantedSpan
}
if (row.length > 0) {
rows.push(row)
}
return { columnCount, columns, rows }
}