mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
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:
parent
58d75ec5c6
commit
4a129af709
9 changed files with 289 additions and 94 deletions
|
|
@ -1 +0,0 @@
|
|||
/Users/brooklyn/www/hermes-agent/ui-tui/node_modules
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue