diff --git a/ui-tui/node_modules b/ui-tui/node_modules deleted file mode 120000 index 001f2a89107d..000000000000 --- a/ui-tui/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Users/brooklyn/www/hermes-agent/ui-tui/node_modules \ No newline at end of file diff --git a/ui-tui/src/components/activeSessionSwitcher.tsx b/ui-tui/src/components/activeSessionSwitcher.tsx index 4ac3385420c1..e4ca9ee08509 100644 --- a/ui-tui/src/components/activeSessionSwitcher.tsx +++ b/ui-tui/src/components/activeSessionSwitcher.tsx @@ -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([]) const historyDisplayRef = useRef([]) 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 onNew: () => void diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 5075598db618..4c117be263b6 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -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 ( + + + {children} + + ), + id + } + ]} + /> + + ) +} + export function PromptZone({ cols, onApprovalChoice, @@ -33,9 +67,9 @@ export function PromptZone({ if (overlay.approval) { return ( - + - + ) } @@ -48,9 +82,9 @@ export function PromptZone({ const onClose = () => patchOverlayState({ billing: null }) return ( - + - + ) } @@ -65,9 +99,9 @@ export function PromptZone({ const onClose = () => patchOverlayState({ subscription: null }) return ( - + - + ) } @@ -82,15 +116,15 @@ export function PromptZone({ const onCancel = () => patchOverlayState({ confirm: null }) return ( - + - + ) } if (overlay.clarify) { return ( - + - + ) } if (overlay.sudo) { return ( - + - + ) } if (overlay.secret) { return ( - + - + ) } @@ -178,19 +212,35 @@ export function FloatingOverlays({ const start = Math.max(0, Math.min(compIdx - Math.floor(COMPLETION_WINDOW / 2), completions.length - viewportSize)) - return ( - - {overlay.gridTest && ( - - - - )} + // 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: () => ( + + + + ) + }) + } + + if (overlay.sessions) { + widgets.push({ + id: 'sessions', + render: width => ( patchOverlayState({ sessions: false })} onClose={onActiveSessionClose} onNew={onNewLiveSession} @@ -200,66 +250,101 @@ export function FloatingOverlays({ t={theme} /> - )} + ) + }) + } - {overlay.modelPicker && ( + if (overlay.modelPicker) { + const initialRefresh = typeof overlay.modelPicker === 'object' && overlay.modelPicker.refresh === true + + widgets.push({ + id: 'model-picker', + render: width => ( patchOverlayState({ modelPicker: false })} onSelect={onModelSelect} sessionId={sid} t={theme} /> - )} + ) + }) + } - {overlay.petPicker && ( + if (overlay.petPicker) { + widgets.push({ + id: 'pet-picker', + render: width => ( - patchOverlayState({ petPicker: false })} t={theme} /> + patchOverlayState({ petPicker: false })} t={theme} /> - )} + ) + }) + } - {overlay.skillsHub && ( + if (overlay.skillsHub) { + widgets.push({ + id: 'skills-hub', + render: width => ( - patchOverlayState({ skillsHub: false })} t={theme} /> + patchOverlayState({ skillsHub: false })} t={theme} /> - )} + ) + }) + } - {overlay.pluginsHub && ( + if (overlay.pluginsHub) { + widgets.push({ + id: 'plugins-hub', + render: width => ( - patchOverlayState({ pluginsHub: false })} t={theme} /> + patchOverlayState({ pluginsHub: false })} t={theme} /> - )} + ) + }) + } - {overlay.pager && ( + const pager = overlay.pager + + if (pager) { + widgets.push({ + id: 'pager', + render: () => ( - {overlay.pager.title && ( + {pager.title && ( - {overlay.pager.title} + {pager.title} )} - {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) => ( {line} ))} - {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)`} - )} + ) + }) + } - {!!completions.length && ( + if (completions.length) { + widgets.push({ + id: 'completions', + render: () => ( {completions.slice(start, start + viewportSize).map((item, i) => { @@ -295,7 +380,13 @@ export function FloatingOverlays({ })} - )} + ) + }) + } + + return ( + + ) } diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 3c9cdd5f2512..486e4d0b96a4 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -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 ( - - - {t.brand.icon} {TAG_FULL} - + , id: 'banner-art' }, + { + children: ( + + {t.brand.icon} {TAG_FULL} + + ), + id: 'banner-tagline' + } + ]} + /> ) } if (cols >= COMPACT_FROM) { - return + return ( + , 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 ( - - {t.brand.icon} {name} - - - {t.brand.icon} {tag} - + + {t.brand.icon} {name} + + ), + id: 'banner-name' + }, + { + children: ( + + {t.brand.icon} {tag} + + ), + id: 'banner-tag' + } + ]} + /> ) } @@ -282,32 +333,36 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { return {info.system_prompt} } - return ( - - {wide && ( - - - + // The wide layout is a real two-column grid: a fixed-width hero track and a + // flexible info track (grid-template-columns: 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 ? ( + + + - - {info.model.split('/').pop()} - · Nous Research - + + {info.model.split('/').pop()} + · Nous Research + - - {info.cwd || process.cwd()} - + + {info.cwd || process.cwd()} + - {sid && ( - - Session: - {sid} - - )} - + {sid && ( + + Session: + {sid} + )} + + ) : null - + const infoColumn = ( + {wide ? ( @@ -418,7 +473,27 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { ! {info.install_warning} )} - + + ) + + return ( + + ) } diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 3be32d211299..9983743ac9b2 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -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('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 diff --git a/ui-tui/src/components/petPicker.tsx b/ui-tui/src/components/petPicker.tsx index dacb553082e3..f8ba4594362c 100644 --- a/ui-tui/src/components/petPicker.tsx +++ b/ui-tui/src/components/petPicker.tsx @@ -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 ` path. */ -export function PetPicker({ gw, onClose, t }: PetPickerProps) { +export function PetPicker({ gw, maxWidth, onClose, t }: PetPickerProps) { const [gallery, setGallery] = useState(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('pet.gallery') @@ -178,6 +180,7 @@ export function PetPicker({ gw, onClose, t }: PetPickerProps) { interface PetPickerProps { gw: GatewayClient + maxWidth?: number onClose: () => void t: Theme } diff --git a/ui-tui/src/components/pluginsHub.tsx b/ui-tui/src/components/pluginsHub.tsx index 1c235a125c11..3876c831299a 100644 --- a/ui-tui/src/components/pluginsHub.tsx +++ b/ui-tui/src/components/pluginsHub.tsx @@ -39,7 +39,7 @@ const GLYPH: Record = { enabled: '✓' } -export function PluginsHub({ gw, onClose, t }: PluginsHubProps) { +export function PluginsHub({ gw, maxWidth, onClose, t }: PluginsHubProps) { const [rows, setRows] = useState([]) 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('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 } diff --git a/ui-tui/src/components/widgetGrid.tsx b/ui-tui/src/components/widgetGrid.tsx index 291df66522fc..6867210d6974 100644 --- a/ui-tui/src/components/widgetGrid.tsx +++ b/ui-tui/src/components/widgetGrid.tsx @@ -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 } diff --git a/ui-tui/src/lib/widgetGrid.ts b/ui-tui/src/lib/widgetGrid.ts index 331861d77061..ae891eb857f8 100644 --- a/ui-tui/src/lib/widgetGrid.ts +++ b/ui-tui/src/lib/widgetGrid.ts @@ -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)