feat(tui): responsive banner tiers

Terminals can't scale glyphs, so the banner now picks a layout per
column width instead of always rendering the full 101-col logo:

- Wide (>= logo width): full ASCII logo + tagline.
- Mid (>= 58 cols): centered rule banner that expands with viewport.
- Narrow (>= 34 cols): brand line + tagline, both width-aware.
- < 34 cols: hidden.

SessionPanel surfaces model/cwd/sid inline when the hero column is
hidden, so narrow layouts don't lose that info. Logo width constants
derive from the art itself.
This commit is contained in:
Brooklyn Nicholson 2026-05-23 17:37:51 -05:00
parent 0277194e3b
commit 35fdf11145
3 changed files with 110 additions and 27 deletions

View file

@ -79,8 +79,8 @@ const colorize = (art: string[], gradient: readonly number[], c: ThemeColors): L
return art.map((text, i) => [p[gradient[i]!] ?? c.muted, text])
}
export const LOGO_WIDTH = 98
export const CADUCEUS_WIDTH = 30
export const LOGO_WIDTH = Math.max(...LOGO_ART.map(line => line.length))
export const CADUCEUS_WIDTH = Math.max(...CADUCEUS_ART.map(line => line.length))
export const logo = (c: ThemeColors, customLogo?: string): Line[] =>
customLogo ? parseRichMarkup(customLogo) : colorize(LOGO_ART, LOGO_GRADIENT, c)

View file

@ -112,9 +112,9 @@ const TranscriptPane = memo(function TranscriptPane({
{row.msg.kind === 'intro' ? (
<Box flexDirection="column" paddingTop={1}>
<Banner t={ui.theme} />
<Banner maxWidth={Math.max(1, composer.cols - 2)} t={ui.theme} />
{row.msg.info && <SessionPanel info={row.msg.info} sid={ui.sid} t={ui.theme} />}
{row.msg.info && <SessionPanel info={row.msg.info} maxWidth={Math.max(1, composer.cols - 2)} sid={ui.sid} t={ui.theme} />}
</Box>
) : row.msg.kind === 'panel' && row.msg.panelData ? (
<Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} />

View file

@ -29,31 +29,92 @@ function InlineLoader({ label, t }: { label: string; t: Theme }) {
export function ArtLines({ lines }: { lines: [string, string][] }) {
return (
<>
<Box flexDirection="column" height={lines.length} opaque width={artWidth(lines)}>
{lines.map(([c, text], i) => (
<Text color={c} key={i}>
<Text color={c} key={i} wrap="truncate-end">
{text}
</Text>
))}
</>
</Box>
)
}
export function Banner({ t }: { t: Theme }) {
const cols = useStdout().stdout?.columns ?? 80
// Responsive Banner: full art → compact rule → text → hidden.
//
// Terminals can't scale glyphs, so "responsive" means picking a layout that
// fits the available columns. Thresholds are picked so each tier reads
// comfortably without forcing wrap or truncation drift on box-drawing edges.
const TAG_FULL = 'Nous Research · Messenger of the Digital Gods'
const TAG_MID = 'Messenger of the Digital Gods'
const TAG_TINY = 'Nous Research'
const HIDE_BELOW = 34
const COMPACT_FROM = 58
const clip = (s: string, w: number) =>
w <= 0 ? '' : s.length > w ? `${s.slice(0, Math.max(0, w - 1))}` : s
const centerIn = (s: string, w: number) => {
const f = clip(s, w)
const slack = Math.max(0, w - f.length)
const left = slack >> 1
return `${' '.repeat(left)}${f}${' '.repeat(slack - left)}`
}
const ruleIn = (label: string, w: number) => {
const f = clip(label, Math.max(1, w - 4))
const slack = Math.max(0, w - f.length - 2)
const left = slack >> 1
return `${'─'.repeat(left)} ${f} ${'─'.repeat(slack - left)}`
}
function CompactBanner({ cols, t }: { cols: number; t: Theme }) {
// -4 keeps a margin so exact-edge rows don't trip terminal pending-wrap.
const w = Math.max(28, cols - 4)
return (
<Box flexDirection="column" height={3} marginBottom={1} opaque width={w}>
<Text bold color={t.color.primary}>{ruleIn(t.brand.name, w)}</Text>
<Text color={t.color.muted}>{centerIn(TAG_FULL, w)}</Text>
<Text color={t.color.primary}>{'─'.repeat(w)}</Text>
</Box>
)
}
export function Banner({ maxWidth, t }: { maxWidth?: number; t: Theme }) {
const term = useStdout().stdout?.columns ?? 80
const cols = Math.max(1, Math.min(term, maxWidth ?? term))
if (cols < HIDE_BELOW) {
return null
}
const logoLines = logo(t.color, t.bannerLogo || undefined)
const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH
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>
</Box>
)
}
if (cols >= COMPACT_FROM) {
return <CompactBanner cols={cols} t={t} />
}
const name = cols >= 52 ? t.brand.name : (t.brand.name.split(' ')[0] ?? t.brand.name)
const tag = cols >= 64 ? TAG_FULL : cols >= 46 ? TAG_MID : TAG_TINY
return (
<Box flexDirection="column" marginBottom={1}>
{cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? (
<ArtLines lines={logoLines} />
) : (
<Text bold color={t.color.primary}>
{t.brand.icon} NOUS HERMES
</Text>
)}
<Text color={t.color.muted}>{t.brand.icon} Nous Research · Messenger of the Digital Gods</Text>
<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>
</Box>
)
}
@ -96,8 +157,9 @@ function CollapseToggle({
const SKILLS_MAX = 8
const TOOLSETS_MAX = 8
export function SessionPanel({ info, sid, t }: SessionPanelProps) {
const cols = useStdout().stdout?.columns ?? 100
export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
const term = useStdout().stdout?.columns ?? 100
const cols = Math.max(20, Math.min(term, maxWidth ?? term))
const heroLines = caduceus(t.color, t.bannerHero || undefined)
const leftW = Math.min((artWidth(heroLines) || CADUCEUS_WIDTH) + 4, Math.floor(cols * 0.4))
const wide = cols >= 90 && leftW + 40 < cols
@ -241,13 +303,33 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
)}
<Box flexDirection="column" width={w}>
<Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.primary}>
{t.brand.name}
{info.version ? ` v${info.version}` : ''}
{info.release_date ? ` (${info.release_date})` : ''}
</Text>
</Box>
{wide ? (
<Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.primary}>
{t.brand.name}
{info.version ? ` v${info.version}` : ''}
{info.release_date ? ` (${info.release_date})` : ''}
</Text>
</Box>
) : (
// Narrow layout hides the hero column; surface model/cwd/session
// here so they aren't lost.
<Box flexDirection="column" marginBottom={1}>
<Text color={t.color.accent} wrap="truncate-end">
{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>
{sid && (
<Text wrap="truncate-end">
<Text color={t.color.sessionLabel}>Session: </Text>
<Text color={t.color.sessionBorder}>{sid}</Text>
</Text>
)}
</Box>
)}
{/* ── Tools (expanded by default) ── */}
<Box flexDirection="column" marginTop={1}>
@ -378,6 +460,7 @@ interface PanelProps {
interface SessionPanelProps {
info: SessionInfo
maxWidth?: number
sid?: string | null
t: Theme
}