TodoPanel now renders as a child of the most recent user message's virtualized row container, so it visually belongs to that prompt and follows it during scroll. Falls back gracefully when no user message exists yet (panel just doesn't render).
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
|
|
import { useStore } from '@nanostores/react'
|
|
import { Fragment, memo, useMemo } from 'react'
|
|
|
|
import { useGateway } from '../app/gatewayContext.js'
|
|
import type { AppLayoutProps } from '../app/interfaces.js'
|
|
import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js'
|
|
import { $uiState } from '../app/uiStore.js'
|
|
import { INLINE_MODE, SHOW_FPS } from '../config/env.js'
|
|
import { FULL_RENDER_TAIL_ITEMS } from '../config/limits.js'
|
|
import { PLACEHOLDER } from '../content/placeholders.js'
|
|
import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
|
|
import { PerfPane } from '../lib/perfPane.js'
|
|
|
|
import { AgentsOverlay } from './agentsOverlay.js'
|
|
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
|
|
import { FloatingOverlays, PromptZone } from './appOverlays.js'
|
|
import { Banner, Panel, SessionPanel } from './branding.js'
|
|
import { FpsOverlay } from './fpsOverlay.js'
|
|
import { MessageLine } from './messageLine.js'
|
|
import { QueuedMessages } from './queuedMessages.js'
|
|
import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js'
|
|
import { TextInput } from './textInput.js'
|
|
|
|
const TranscriptPane = memo(function TranscriptPane({
|
|
actions,
|
|
composer,
|
|
progress,
|
|
transcript
|
|
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
|
|
const ui = useStore($uiState)
|
|
|
|
// Index of the latest user message — LiveTodoPanel is rendered as a child
|
|
// of that row so it visually belongs to the user's prompt and follows it
|
|
// during scroll. Falls back to -1 when no user message exists yet (empty
|
|
// session); LiveTodoPanel then doesn't render at all.
|
|
const lastUserIdx = useMemo(() => {
|
|
for (let i = transcript.historyItems.length - 1; i >= 0; i--) {
|
|
if (transcript.historyItems[i].role === 'user') return i
|
|
}
|
|
return -1
|
|
}, [transcript.historyItems])
|
|
|
|
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}
|
|
|
|
{transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).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?.version && <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}
|
|
detailsModeCommandOverride={ui.detailsModeCommandOverride}
|
|
limitHistoryRender={row.index < transcript.historyItems.length - FULL_RENDER_TAIL_ITEMS}
|
|
msg={row.msg}
|
|
sections={ui.sections}
|
|
t={ui.theme}
|
|
/>
|
|
)}
|
|
|
|
{row.index === lastUserIdx && <LiveTodoPanel />}
|
|
</Box>
|
|
))}
|
|
|
|
{transcript.virtualHistory.bottomSpacer > 0 ? <Box height={transcript.virtualHistory.bottomSpacer} /> : null}
|
|
|
|
<StreamingAssistant
|
|
cols={composer.cols}
|
|
compact={ui.compact}
|
|
detailsMode={ui.detailsMode}
|
|
detailsModeCommandOverride={ui.detailsModeCommandOverride}
|
|
progress={progress}
|
|
sections={ui.sections}
|
|
/>
|
|
</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
|
|
const inputColumns = stableComposerColumns(composer.cols, pw)
|
|
const inputHeight = inputVisualHeight(composer.input, inputColumns)
|
|
|
|
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>
|
|
)}
|
|
|
|
<StatusRulePane at="top" composer={composer} status={status} />
|
|
|
|
<Box flexDirection="column" marginTop={ui.statusBar === 'top' ? 0 : 1} position="relative">
|
|
<FloatingOverlays
|
|
cols={composer.cols}
|
|
compIdx={composer.compIdx}
|
|
completions={composer.completions}
|
|
onModelSelect={actions.onModelSelect}
|
|
onPickerSelect={actions.resumeById}
|
|
pagerPageSize={composer.pagerPageSize}
|
|
/>
|
|
|
|
{!isBlocked && (
|
|
<>
|
|
{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.prompt}>
|
|
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
|
|
<Box flexGrow={0} flexShrink={0} height={inputHeight} position="relative" width={inputColumns}>
|
|
{/* Reserve the transcript scrollbar gutter too so typing never rewraps when the scrollbar column repaints. */}
|
|
<TextInput
|
|
columns={inputColumns}
|
|
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>}
|
|
|
|
<StatusRulePane at="bottom" composer={composer} status={status} />
|
|
</NoSelect>
|
|
)
|
|
})
|
|
|
|
const AgentsOverlayPane = memo(function AgentsOverlayPane() {
|
|
const { gw } = useGateway()
|
|
const ui = useStore($uiState)
|
|
const overlay = useStore($overlayState)
|
|
|
|
return (
|
|
<AgentsOverlay
|
|
gw={gw}
|
|
initialHistoryIndex={overlay.agentsInitialHistoryIndex}
|
|
onClose={() => patchOverlayState({ agents: false, agentsInitialHistoryIndex: 0 })}
|
|
t={ui.theme}
|
|
/>
|
|
)
|
|
})
|
|
|
|
const StatusRulePane = memo(function StatusRulePane({
|
|
at,
|
|
composer,
|
|
status
|
|
}: Pick<AppLayoutProps, 'composer' | 'status'> & { at: 'bottom' | 'top' }) {
|
|
const ui = useStore($uiState)
|
|
|
|
if (ui.statusBar !== at) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<Box marginTop={at === 'top' ? 1 : 0}>
|
|
<StatusRule
|
|
bgCount={ui.bgTasks.size}
|
|
busy={ui.busy}
|
|
cols={composer.cols}
|
|
cwdLabel={status.cwdLabel}
|
|
model={ui.info?.model ?? ''}
|
|
modelFast={ui.info?.fast || ui.info?.service_tier === 'priority'}
|
|
modelReasoningEffort={ui.info?.reasoning_effort}
|
|
sessionStartedAt={status.sessionStartedAt}
|
|
showCost={ui.showCost}
|
|
status={ui.status}
|
|
statusColor={status.statusColor}
|
|
t={ui.theme}
|
|
turnStartedAt={status.turnStartedAt}
|
|
usage={ui.usage}
|
|
voiceLabel={status.voiceLabel}
|
|
/>
|
|
</Box>
|
|
)
|
|
})
|
|
|
|
export const AppLayout = memo(function AppLayout({
|
|
actions,
|
|
composer,
|
|
mouseTracking,
|
|
progress,
|
|
status,
|
|
transcript
|
|
}: AppLayoutProps) {
|
|
const overlay = useStore($overlayState)
|
|
|
|
// Inline mode: skip <AlternateScreen> so the TUI renders into the
|
|
// primary buffer and the terminal's native scrollback can capture rows
|
|
// that scroll off the top. Mouse tracking is still enabled via
|
|
// AlternateScreen when the wrapper is on; in inline mode we leave it
|
|
// to the host terminal, which typically does wheel → scrollback.
|
|
//
|
|
// `Fragment` (via alias so the JSX stays legible) drops the alt-screen
|
|
// constraint while keeping the inner layout identical. Content height
|
|
// will then follow flex-column growth, which means the ScrollBox below
|
|
// grows beyond the viewport — the terminal's primary buffer scrolls
|
|
// old rows off the top into native scrollback. Composer + progress
|
|
// stay at the bottom via normal flow (they're the last siblings).
|
|
const Shell = INLINE_MODE ? Fragment : AlternateScreen
|
|
const shellProps = INLINE_MODE ? {} : { mouseTracking }
|
|
|
|
return (
|
|
<Shell {...shellProps}>
|
|
<Box flexDirection="column" flexGrow={1}>
|
|
<Box flexDirection="row" flexGrow={1}>
|
|
{overlay.agents ? (
|
|
<PerfPane id="agents">
|
|
<AgentsOverlayPane />
|
|
</PerfPane>
|
|
) : (
|
|
<PerfPane id="transcript">
|
|
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
|
|
</PerfPane>
|
|
)}
|
|
</Box>
|
|
|
|
{!overlay.agents && (
|
|
<>
|
|
<PerfPane id="prompt">
|
|
<PromptZone
|
|
cols={composer.cols}
|
|
onApprovalChoice={actions.answerApproval}
|
|
onClarifyAnswer={actions.answerClarify}
|
|
onSecretSubmit={actions.answerSecret}
|
|
onSudoSubmit={actions.answerSudo}
|
|
/>
|
|
</PerfPane>
|
|
|
|
<PerfPane id="composer">
|
|
<ComposerPane actions={actions} composer={composer} status={status} />
|
|
</PerfPane>
|
|
|
|
{/* FPS counter overlay: pinned to the bottom row, right
|
|
aligned, gated on HERMES_TUI_FPS. Returns null + skips
|
|
this subtree when disabled (zero cost). */}
|
|
{SHOW_FPS && (
|
|
<Box flexShrink={0} justifyContent="flex-end" paddingRight={1}>
|
|
<FpsOverlay />
|
|
</Box>
|
|
)}
|
|
</>
|
|
)}
|
|
</Box>
|
|
</Shell>
|
|
)
|
|
})
|