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) { 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 ( <> {transcript.virtualHistory.topSpacer > 0 ? : null} {transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => ( {row.msg.kind === 'intro' ? ( {row.msg.info?.version && } ) : row.msg.kind === 'panel' && row.msg.panelData ? ( ) : ( )} {row.index === lastUserIdx && } ))} {transcript.virtualHistory.bottomSpacer > 0 ? : null} ) }) const ComposerPane = memo(function ComposerPane({ actions, composer, status }: Pick) { 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 ( {ui.bgTasks.size > 0 && ( {ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running )} {status.showStickyPrompt ? ( {status.stickyPrompt} ) : ( )} {!isBlocked && ( <> {composer.inputBuf.map((line, i) => ( {i === 0 ? `${ui.theme.brand.prompt} ` : ' '} {line || ' '} ))} {sh ? ( $ ) : ( {composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `} )} {/* Reserve the transcript scrollbar gutter too so typing never rewraps when the scrollbar column repaints. */} )} {!composer.empty && !ui.sid && ⚕ {ui.status}} ) }) const AgentsOverlayPane = memo(function AgentsOverlayPane() { const { gw } = useGateway() const ui = useStore($uiState) const overlay = useStore($overlayState) return ( patchOverlayState({ agents: false, agentsInitialHistoryIndex: 0 })} t={ui.theme} /> ) }) const StatusRulePane = memo(function StatusRulePane({ at, composer, status }: Pick & { at: 'bottom' | 'top' }) { const ui = useStore($uiState) if (ui.statusBar !== at) { return null } return ( ) }) export const AppLayout = memo(function AppLayout({ actions, composer, mouseTracking, progress, status, transcript }: AppLayoutProps) { const overlay = useStore($overlayState) // Inline mode: skip 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 ( {overlay.agents ? ( ) : ( )} {!overlay.agents && ( <> {/* 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 && ( )} )} ) })