hermes-agent/ui-tui/src/components/appLayout.tsx
Brooklyn Nicholson c6ed61430a perf(tui): paint banner on first frame, don't wait on session.create
Previously `historyItems` was seeded empty and the intro (with Banner +
SessionPanel) was only pushed after Python's `session.create` returned —
~1.8s of agent + tools + MCP init with nothing on screen. Base CLI feels
instant because it prints the banner as its first action.

Seed `historyItems` with an info-less intro on mount. `appLayout` now
renders the Banner unconditionally for `kind === 'intro'` and gates only
the SessionPanel on `info` being present. Gateway.ready swaps the skin
(~200ms) and session.info fills in the panel when the agent is ready.

Net: first usable frame drops from ~2s to ~300ms (node + module graph +
React mount). No behavior change — intro message is replaced in place
by `introMsg(info)` when `newSession()` / `resumeById()` resolve.
2026-04-16 14:58:12 -05:00

231 lines
7.7 KiB
TypeScript

import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { memo } from 'react'
import type { AppLayoutProps } from '../app/interfaces.js'
import { $isBlocked } from '../app/overlayStore.js'
import { $uiState } from '../app/uiStore.js'
import { PLACEHOLDER } from '../content/placeholders.js'
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
import { AppOverlays } from './appOverlays.js'
import { Banner, Panel, SessionPanel } from './branding.js'
import { MessageLine } from './messageLine.js'
import { QueuedMessages } from './queuedMessages.js'
import { TextInput } from './textInput.js'
import { ToolTrail } from './thinking.js'
const TranscriptPane = memo(function TranscriptPane({
actions,
composer,
progress,
transcript
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
const ui = useStore($uiState)
const visibleHistory = transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end)
return (
<>
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={transcript.scrollRef} stickyScroll>
<Box flexDirection="column" paddingX={1}>
{transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null}
{visibleHistory.map(row => (
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
{row.msg.kind === 'intro' ? (
<Box flexDirection="column" paddingTop={1}>
<Banner t={ui.theme} />
{row.msg.info && <SessionPanel info={row.msg.info} 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} />
) : (
<MessageLine
cols={composer.cols}
compact={ui.compact}
detailsMode={ui.detailsMode}
msg={row.msg}
t={ui.theme}
/>
)}
</Box>
))}
{transcript.virtualHistory.bottomSpacer > 0 ? <Box height={transcript.virtualHistory.bottomSpacer} /> : null}
{progress.showProgressArea && (
<ToolTrail
activity={progress.activity}
busy={ui.busy && !progress.streaming}
detailsMode={ui.detailsMode}
reasoning={progress.reasoning}
reasoningActive={progress.reasoningActive}
reasoningStreaming={progress.reasoningStreaming}
reasoningTokens={progress.reasoningTokens}
subagents={progress.subagents}
t={ui.theme}
tools={progress.tools}
toolTokens={progress.toolTokens}
trail={progress.turnTrail}
/>
)}
{progress.showStreamingArea && (
<MessageLine
cols={composer.cols}
compact={ui.compact}
detailsMode={ui.detailsMode}
isStreaming
msg={{ role: 'assistant', text: progress.streaming }}
t={ui.theme}
/>
)}
</Box>
</ScrollBox>
<NoSelect flexShrink={0} marginLeft={1}>
<TranscriptScrollbar scrollRef={transcript.scrollRef} t={ui.theme} />
</NoSelect>
<StickyPromptTracker
messages={transcript.historyItems}
offsets={transcript.virtualHistory.offsets}
onChange={actions.setStickyPrompt}
scrollRef={transcript.scrollRef}
/>
</>
)
})
const ComposerPane = memo(function ComposerPane({
actions,
composer,
status
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'status'>) {
const ui = useStore($uiState)
const isBlocked = useStore($isBlocked)
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
const pw = sh ? 2 : 3
return (
<NoSelect flexDirection="column" flexShrink={0} fromLeftEdge paddingX={1}>
<QueuedMessages
cols={composer.cols}
queued={composer.queuedDisplay}
queueEditIdx={composer.queueEditIdx}
t={ui.theme}
/>
{ui.bgTasks.size > 0 && (
<Text color={ui.theme.color.dim}>
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
</Text>
)}
{status.showStickyPrompt ? (
<Text color={ui.theme.color.dim} wrap="truncate-end">
<Text color={ui.theme.color.label}> </Text>
{status.stickyPrompt}
</Text>
) : (
<Text> </Text>
)}
<Box flexDirection="column" position="relative">
{ui.statusBar && (
<StatusRule
bgCount={ui.bgTasks.size}
cols={composer.cols}
cwdLabel={status.cwdLabel}
model={ui.info?.model?.split('/').pop() ?? ''}
sessionStartedAt={status.sessionStartedAt}
status={ui.status}
statusColor={status.statusColor}
t={ui.theme}
usage={ui.usage}
voiceLabel={status.voiceLabel}
/>
)}
<AppOverlays
cols={composer.cols}
compIdx={composer.compIdx}
completions={composer.completions}
onApprovalChoice={actions.answerApproval}
onClarifyAnswer={actions.answerClarify}
onModelSelect={actions.onModelSelect}
onPickerSelect={actions.resumeById}
onSecretSubmit={actions.answerSecret}
onSudoSubmit={actions.answerSudo}
pagerPageSize={composer.pagerPageSize}
/>
</Box>
{!isBlocked && (
<Box flexDirection="column" marginBottom={1}>
{composer.inputBuf.map((line, i) => (
<Box key={i}>
<Box width={3}>
<Text color={ui.theme.color.dim}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
</Box>
<Text color={ui.theme.color.cornsilk}>{line || ' '}</Text>
</Box>
))}
<Box position="relative">
<Box width={pw}>
{sh ? (
<Text color={ui.theme.color.shellDollar}>$ </Text>
) : (
<Text bold color={ui.theme.color.gold}>
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
</Text>
)}
</Box>
<Box flexGrow={1} position="relative">
<TextInput
columns={Math.max(20, composer.cols - pw)}
onChange={composer.updateInput}
onPaste={composer.handleTextPaste}
onSubmit={composer.submit}
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
value={composer.input}
/>
<Box position="absolute" right={0}>
<GoodVibesHeart t={ui.theme} tick={status.goodVibesTick} />
</Box>
</Box>
</Box>
</Box>
)}
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim}> {ui.status}</Text>}
</NoSelect>
)
})
export const AppLayout = memo(function AppLayout({
actions,
composer,
mouseTracking,
progress,
status,
transcript
}: AppLayoutProps) {
return (
<AlternateScreen mouseTracking={mouseTracking}>
<Box flexDirection="column" flexGrow={1}>
<Box flexDirection="row" flexGrow={1}>
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
</Box>
<ComposerPane actions={actions} composer={composer} status={status} />
</Box>
</AlternateScreen>
)
})