refactor(ui-tui): route production surfaces through the widget-grid engine

Banner (responsive tiers: full logo -> compact rule -> text -> hidden),
SessionPanel (fixed hero track + flexible info track, the desktop pane
shell's fixed-vs-flex contract), floating overlays, prompt zone, and the
pickers all render through WidgetGrid instead of hand-rolled flex math.
Pickers gain maxWidth so grid cells can cap them.
This commit is contained in:
Brooklyn Nicholson 2026-07-20 17:36:18 -05:00
parent 58d75ec5c6
commit 4a129af709
9 changed files with 289 additions and 94 deletions

View file

@ -1 +0,0 @@
/Users/brooklyn/www/hermes-agent/ui-tui/node_modules

View file

@ -283,6 +283,7 @@ function OrchestratorHintText({ segments, t }: OrchestratorHintTextProps) {
export function ActiveSessionSwitcher({
currentSessionId,
gw,
maxWidth,
onCancel,
onClose,
onNew,
@ -318,7 +319,9 @@ export function ActiveSessionSwitcher({
const itemsRef = useRef<SessionActiveItem[]>([])
const historyDisplayRef = useRef<SessionListItem[]>([])
const { stdout } = useStdout()
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
// Optional maxWidth lets grid layouts hand the switcher its cell budget.
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const width = Math.max(24, Math.min(preferredWidth, Math.trunc(maxWidth ?? preferredWidth)))
const promptColumns = Math.max(20, width - 11)
// Rows are [new][live…][history…]: the "+ new" row is pinned first (index 0,
@ -893,6 +896,7 @@ interface OrchestratorHintTextProps {
interface ActiveSessionSwitcherProps {
currentSessionId: null | string
gw: GatewayClient
maxWidth?: number
onCancel: () => void
onClose: (id: string) => Promise<null | SessionCloseResponse>
onNew: () => void

View file

@ -1,5 +1,6 @@
import { Box, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { useGateway } from '../app/gatewayContext.js'
import type { AppOverlaysProps } from '../app/interfaces.js'
@ -18,9 +19,42 @@ import { PluginsHub } from './pluginsHub.js'
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
import { SkillsHub } from './skillsHub.js'
import { SubscriptionOverlay } from './subscriptionOverlay.js'
import { WidgetGrid, type WidgetGridWidget } from './widgetGrid.js'
const COMPLETION_WINDOW = 16
/**
* A prompt hosted in a single-cell WidgetGrid with the classic 1-cell padding.
* The inner full-width column restores the horizontal stretch the old plain
* padded Box gave its child, so rendering is identical; routing through the
* grid makes the prompt zone a layout-engine surface like the desktop app's
* pane shell.
*/
function PromptCell({ children, cols, id }: { children: ReactNode; cols: number; id: string }) {
return (
<Box flexDirection="column" flexShrink={0}>
<WidgetGrid
cols={cols}
columns={1}
gap={0}
paddingX={1}
paddingY={1}
rowGap={0}
widgets={[
{
children: (
<Box flexDirection="column" width="100%">
{children}
</Box>
),
id
}
]}
/>
</Box>
)
}
export function PromptZone({
cols,
onApprovalChoice,
@ -33,9 +67,9 @@ export function PromptZone({
if (overlay.approval) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<PromptCell cols={cols} id="approval">
<ApprovalPrompt cols={cols} onChoice={onApprovalChoice} req={overlay.approval} t={theme} />
</Box>
</PromptCell>
)
}
@ -48,9 +82,9 @@ export function PromptZone({
const onClose = () => patchOverlayState({ billing: null })
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<PromptCell cols={cols} id="billing">
<BillingOverlay onClose={onClose} onPatch={onPatch} overlay={current} t={theme} />
</Box>
</PromptCell>
)
}
@ -65,9 +99,9 @@ export function PromptZone({
const onClose = () => patchOverlayState({ subscription: null })
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<PromptCell cols={cols} id="subscription">
<SubscriptionOverlay onClose={onClose} onPatch={onPatch} overlay={current} t={theme} />
</Box>
</PromptCell>
)
}
@ -82,15 +116,15 @@ export function PromptZone({
const onCancel = () => patchOverlayState({ confirm: null })
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<PromptCell cols={cols} id="confirm">
<ConfirmPrompt onCancel={onCancel} onConfirm={onConfirm} req={req} t={theme} />
</Box>
</PromptCell>
)
}
if (overlay.clarify) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<PromptCell cols={cols} id="clarify">
<ClarifyPrompt
cols={cols}
onAnswer={onClarifyAnswer}
@ -98,21 +132,21 @@ export function PromptZone({
req={overlay.clarify}
t={theme}
/>
</Box>
</PromptCell>
)
}
if (overlay.sudo) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<PromptCell cols={cols} id="sudo">
<MaskedPrompt cols={cols} icon="🔐" label="sudo password required" onSubmit={onSudoSubmit} t={theme} />
</Box>
</PromptCell>
)
}
if (overlay.secret) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<PromptCell cols={cols} id="secret">
<MaskedPrompt
cols={cols}
icon="🔑"
@ -121,7 +155,7 @@ export function PromptZone({
sub={`for ${overlay.secret.envVar}`}
t={theme}
/>
</Box>
</PromptCell>
)
}
@ -178,19 +212,35 @@ 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.gridTest && (
<FloatBox color={theme.color.border}>
<GridTestOverlay cols={Math.max(24, cols - 6)} state={overlay.gridTest} t={theme} />
</FloatBox>
)}
// Every floating panel is a widget in a single-column grid. Panels keep
// their intrinsic (content-hugging) widths inside full-width cells today;
// multi-column tiling on wide terminals is a `columns`/track change here,
// not a rewrite. `maxWidth` hands each panel its cell budget — with one
// column it never binds, so rendering is identical to the pre-grid layout.
const widgets: WidgetGridWidget[] = []
{overlay.sessions && (
const gridTest = overlay.gridTest
if (gridTest) {
widgets.push({
id: 'grid-test',
render: () => (
<FloatBox color={theme.color.border}>
<GridTestOverlay cols={Math.max(24, cols - 6)} state={gridTest} t={theme} />
</FloatBox>
)
})
}
if (overlay.sessions) {
widgets.push({
id: 'sessions',
render: width => (
<FloatBox color={theme.color.border}>
<ActiveSessionSwitcher
currentSessionId={sid}
gw={gw}
maxWidth={width}
onCancel={() => patchOverlayState({ sessions: false })}
onClose={onActiveSessionClose}
onNew={onNewLiveSession}
@ -200,66 +250,101 @@ export function FloatingOverlays({
t={theme}
/>
</FloatBox>
)}
)
})
}
{overlay.modelPicker && (
if (overlay.modelPicker) {
const initialRefresh = typeof overlay.modelPicker === 'object' && overlay.modelPicker.refresh === true
widgets.push({
id: 'model-picker',
render: width => (
<FloatBox color={theme.color.border}>
<ModelPicker
gw={gw}
initialRefresh={typeof overlay.modelPicker === 'object' && overlay.modelPicker.refresh === true}
initialRefresh={initialRefresh}
maxWidth={width}
onCancel={() => patchOverlayState({ modelPicker: false })}
onSelect={onModelSelect}
sessionId={sid}
t={theme}
/>
</FloatBox>
)}
)
})
}
{overlay.petPicker && (
if (overlay.petPicker) {
widgets.push({
id: 'pet-picker',
render: width => (
<FloatBox color={theme.color.border}>
<PetPicker gw={gw} onClose={() => patchOverlayState({ petPicker: false })} t={theme} />
<PetPicker gw={gw} maxWidth={width} onClose={() => patchOverlayState({ petPicker: false })} 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={width} onClose={() => patchOverlayState({ skillsHub: false })} t={theme} />
</FloatBox>
)}
)
})
}
{overlay.pluginsHub && (
if (overlay.pluginsHub) {
widgets.push({
id: 'plugins-hub',
render: width => (
<FloatBox color={theme.color.border}>
<PluginsHub gw={gw} onClose={() => patchOverlayState({ pluginsHub: false })} t={theme} />
<PluginsHub gw={gw} maxWidth={width} onClose={() => patchOverlayState({ pluginsHub: false })} t={theme} />
</FloatBox>
)}
)
})
}
{overlay.pager && (
const pager = overlay.pager
if (pager) {
widgets.push({
id: 'pager',
render: () => (
<FloatBox color={theme.color.border}>
<Box flexDirection="column" paddingX={1} paddingY={1}>
{overlay.pager.title && (
{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>
)}
)
})
}
{!!completions.length && (
if (completions.length) {
widgets.push({
id: 'completions',
render: () => (
<FloatBox color={theme.color.primary}>
<Box flexDirection="column" width={Math.max(28, cols - 6)}>
{completions.slice(start, start + viewportSize).map((item, i) => {
@ -295,7 +380,13 @@ export function FloatingOverlays({
})}
</Box>
</FloatBox>
)}
)
})
}
return (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
<WidgetGrid cols={cols} columns={1} gap={0} paddingX={0} paddingY={0} rowGap={0} widgets={widgets} />
</Box>
)
}

View file

@ -7,6 +7,8 @@ import { flat } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { PanelSection, SessionInfo } from '../types.js'
import { WidgetGrid } from './widgetGrid.js'
const LOADER_TICK_MS = 120
function InlineLoader({ label, t }: { label: string; t: Theme }) {
@ -94,19 +96,48 @@ export function Banner({ maxWidth, t }: { maxWidth?: number; t: Theme }) {
const logoLines = logo(t.color, t.bannerLogo || undefined)
const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH
// Each tier renders its rows through a single-column WidgetGrid sized to
// the available columns — same visual output as the old plain flex column
// (cells clip where truncate-end used to), but the banner is now a
// layout-engine surface.
if (cols >= logoW + 2) {
return (
<Box flexDirection="column" marginBottom={1}>
<ArtLines lines={logoLines} />
<Text color={t.color.muted} wrap="truncate-end">
{t.brand.icon} {TAG_FULL}
</Text>
<WidgetGrid
cols={cols}
columns={1}
gap={0}
paddingX={0}
paddingY={0}
rowGap={0}
widgets={[
{ children: <ArtLines lines={logoLines} />, id: 'banner-art' },
{
children: (
<Text color={t.color.muted} wrap="truncate-end">
{t.brand.icon} {TAG_FULL}
</Text>
),
id: 'banner-tagline'
}
]}
/>
</Box>
)
}
if (cols >= COMPACT_FROM) {
return <CompactBanner cols={cols} t={t} />
return (
<WidgetGrid
cols={cols}
columns={1}
gap={0}
paddingX={0}
paddingY={0}
rowGap={0}
widgets={[{ children: <CompactBanner cols={cols} t={t} />, id: 'banner-compact' }]}
/>
)
}
const name = cols >= 52 ? t.brand.name : (t.brand.name.split(' ')[0] ?? t.brand.name)
@ -114,12 +145,32 @@ export function Banner({ maxWidth, t }: { maxWidth?: number; t: Theme }) {
return (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={t.color.primary} wrap="truncate-end">
{t.brand.icon} {name}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{t.brand.icon} {tag}
</Text>
<WidgetGrid
cols={cols}
columns={1}
gap={0}
paddingX={0}
paddingY={0}
rowGap={0}
widgets={[
{
children: (
<Text bold color={t.color.primary} wrap="truncate-end">
{t.brand.icon} {name}
</Text>
),
id: 'banner-name'
},
{
children: (
<Text color={t.color.muted} wrap="truncate-end">
{t.brand.icon} {tag}
</Text>
),
id: 'banner-tag'
}
]}
/>
</Box>
)
}
@ -282,32 +333,36 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
return <Text color={t.color.muted}>{info.system_prompt}</Text>
}
return (
<Box borderColor={t.color.border} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
{wide && (
<Box flexDirection="column" marginRight={2} width={leftW}>
<ArtLines lines={heroLines} />
<Text />
// The wide layout is a real two-column grid: a fixed-width hero track and a
// flexible info track (grid-template-columns: <leftW> 1fr, gap 2) — the
// terminal equivalent of the desktop pane shell's fixed-vs-flex tracks.
// Narrow drops to a single flexible track. Track math reproduces the old
// hand-rolled widths exactly: usable = (leftW + 2 + w) - gap = leftW + w.
const heroColumn = wide ? (
<Box flexDirection="column" width="100%">
<ArtLines lines={heroLines} />
<Text />
<Text color={t.color.accent}>
{info.model.split('/').pop()}
<Text color={t.color.muted}> · Nous Research</Text>
</Text>
<Text color={t.color.accent}>
{info.model.split('/').pop()}
<Text color={t.color.muted}> · Nous Research</Text>
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{info.cwd || process.cwd()}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{info.cwd || process.cwd()}
</Text>
{sid && (
<Text>
<Text color={t.color.sessionLabel}>Session: </Text>
<Text color={t.color.sessionBorder}>{sid}</Text>
</Text>
)}
</Box>
{sid && (
<Text>
<Text color={t.color.sessionLabel}>Session: </Text>
<Text color={t.color.sessionBorder}>{sid}</Text>
</Text>
)}
</Box>
) : null
<Box flexDirection="column" width={w}>
const infoColumn = (
<Box flexDirection="column" width="100%">
{wide ? (
<Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.primary}>
@ -418,7 +473,27 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
! {info.install_warning}
</Text>
)}
</Box>
</Box>
)
return (
<Box borderColor={t.color.border} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
<WidgetGrid
cols={wide ? leftW + 2 + w : w}
columns={wide ? [leftW, { fr: 1 }] : 1}
gap={2}
paddingX={0}
paddingY={0}
rowGap={0}
widgets={
wide
? [
{ children: heroColumn, id: 'session-hero' },
{ children: infoColumn, id: 'session-info' }
]
: [{ children: infoColumn, id: 'session-info' }]
}
/>
</Box>
)
}

View file

@ -34,6 +34,7 @@ export function ModelPicker({
allowPersistGlobal = true,
gw,
initialRefresh = false,
maxWidth,
onCancel,
onSelect,
sessionId,
@ -57,8 +58,10 @@ export function ModelPicker({
// 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))
// has an actual constraint to truncate against. Optional maxWidth lets
// grid layouts hand the picker its cell budget.
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const width = Math.max(24, Math.min(preferredWidth, Math.trunc(maxWidth ?? preferredWidth)))
useEffect(() => {
gw.request<ModelOptionsResponse>('model.options', {
@ -697,6 +700,7 @@ interface ModelPickerProps {
allowPersistGlobal?: boolean
gw: GatewayClient
initialRefresh?: boolean
maxWidth?: number
onCancel: () => void
onSelect: (value: string) => void
sessionId: string | null

View file

@ -30,7 +30,7 @@ interface Gallery {
* (install-on-demand). The mascot lights up live once `usePet` next polls
* no restart. This is the interactive sibling of the text `/pet <slug>` path.
*/
export function PetPicker({ gw, onClose, t }: PetPickerProps) {
export function PetPicker({ gw, maxWidth, onClose, t }: PetPickerProps) {
const [gallery, setGallery] = useState<Gallery | null>(null)
const [query, setQuery] = useState('')
const [idx, setIdx] = useState(0)
@ -39,7 +39,9 @@ export function PetPicker({ gw, onClose, t }: PetPickerProps) {
const [loading, setLoading] = useState(true)
const { stdout } = useStdout()
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
// Optional maxWidth lets grid layouts hand the picker its cell budget.
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const width = Math.max(24, Math.min(preferredWidth, Math.trunc(maxWidth ?? preferredWidth)))
useEffect(() => {
gw.request<Gallery>('pet.gallery')
@ -178,6 +180,7 @@ export function PetPicker({ gw, onClose, t }: PetPickerProps) {
interface PetPickerProps {
gw: GatewayClient
maxWidth?: number
onClose: () => void
t: Theme
}

View file

@ -39,7 +39,7 @@ const GLYPH: Record<string, string> = {
enabled: '✓'
}
export function PluginsHub({ gw, onClose, t }: PluginsHubProps) {
export function PluginsHub({ gw, maxWidth, onClose, t }: PluginsHubProps) {
const [rows, setRows] = useState<PluginRow[]>([])
const [bundledCount, setBundledCount] = useState(0)
const [userCount, setUserCount] = useState(0)
@ -50,7 +50,9 @@ export function PluginsHub({ gw, onClose, t }: PluginsHubProps) {
const [loading, setLoading] = useState(true)
const { stdout } = useStdout()
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
// Optional maxWidth lets grid layouts hand the hub its cell budget.
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const width = Math.max(24, Math.min(preferredWidth, Math.trunc(maxWidth ?? preferredWidth)))
const load = () => {
gw.request<PluginsListResponse>('plugins.manage', { action: 'list' })
@ -233,6 +235,7 @@ export function PluginsHub({ gw, onClose, t }: PluginsHubProps) {
interface PluginsHubProps {
gw: GatewayClient
maxWidth?: number
onClose: () => void
t: Theme
}

View file

@ -37,7 +37,8 @@ export interface WidgetGridWidget extends WidgetGridItem {
* neighbouring cell or break the parent border.
*/
interface WidgetGridProps {
columns?: number
/** Column count (equal shares) or grid-template-style track list. */
columns?: GridTrackSize[] | number
cols: number
depth?: number
gap?: number
@ -51,12 +52,17 @@ interface WidgetGridProps {
const toInt = (value: number, fallback: number) => (Number.isFinite(value) ? Math.trunc(value) : fallback)
const inferredGap = (cols: number, columns: number | undefined, depth: number) => {
if (cols < 36 || (columns ?? 0) >= 8) {
const columnCountHint = (columns: GridTrackSize[] | number | undefined) =>
Array.isArray(columns) ? columns.length : (columns ?? 0)
const inferredGap = (cols: number, columns: GridTrackSize[] | number | undefined, depth: number) => {
const count = columnCountHint(columns)
if (cols < 36 || count >= 8) {
return 0
}
if (depth > 0 || cols < 72 || (columns ?? 0) >= 4) {
if (depth > 0 || cols < 72 || count >= 4) {
return 1
}

View file

@ -19,7 +19,11 @@ export interface WidgetGridLayout {
}
export interface WidgetGridLayoutOptions {
columns?: number
/**
* Explicit column count (equal shares) or a grid-template-style track list
* (fixed cell counts / weighted `fr` shares). Omitted: auto from width.
*/
columns?: GridTrackSize[] | number
gap?: number
items: WidgetGridItem[]
maxColumns?: number
@ -111,12 +115,18 @@ export function layoutWidgetGrid({
const safeWidth = Math.max(1, toInt(width, 1))
const maxDrawableColumns = safeGap > 0 ? Math.max(1, Math.floor((safeWidth + safeGap) / (safeGap + 1))) : safeWidth
const columnCount =
requestedColumns === undefined
const trackList = Array.isArray(requestedColumns) && requestedColumns.length ? requestedColumns : null
const columnCount = trackList
? trackList.length
: requestedColumns === undefined || Array.isArray(requestedColumns)
? columnCountForWidth(safeWidth, minColumnWidth, safeGap, maxColumns)
: clamp(toInt(requestedColumns, 1), 1, maxDrawableColumns)
const columns = buildColumnWidths(width, columnCount, safeGap)
const columns = trackList
? resolveGridTracks(safeWidth, safeGap, trackList)
: buildColumnWidths(width, columnCount, safeGap)
const rows: WidgetGridCell[][] = []
let row: WidgetGridCell[] = []
let occupied = Array.from({ length: columnCount }, () => false)