mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
import { AlternateScreen, Box, NoSelect, ScrollBox, stringWidth, Text } from '@hermes/ink'
|
|
import { useStore } from '@nanostores/react'
|
|
import { Fragment, memo, useMemo, useRef } 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, type TextInputMouseApi } from './textInput.js'
|
|
|
|
const TranscriptPane = memo(function TranscriptPane({
|
|
actions,
|
|
composer,
|
|
progress,
|
|
transcript
|
|
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
|
|
const ui = useStore($uiState)
|
|
|
|
// LiveTodoPanel rides as a child of the latest user-message row so it
|
|
// visually belongs to the prompt and follows it during scroll. -1 when
|
|
// empty → row.index === -1 is always false → no render.
|
|
const lastUserIdx = useMemo(() => {
|
|
const items = transcript.historyItems
|
|
|
|
for (let i = items.length - 1; i >= 0; i--) {
|
|
if (items[i].role === 'user') {
|
|
return i
|
|
}
|
|
}
|
|
|
|
return -1
|
|
}, [transcript.historyItems])
|
|
|
|
return (
|
|
<>
|
|
<ScrollBox
|
|
flexDirection="column"
|
|
flexGrow={1}
|
|
flexShrink={1}
|
|
onClick={(e: { cellIsBlank?: boolean }) => {
|
|
if (e.cellIsBlank) {
|
|
actions.clearSelection()
|
|
}
|
|
}}
|
|
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 && <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 promptText = sh ? '$' : ui.theme.brand.prompt
|
|
const promptLabel = `${promptText} `
|
|
const promptWidth = Math.max(1, stringWidth(promptLabel))
|
|
const inputColumns = stableComposerColumns(composer.cols, promptWidth)
|
|
const inputHeight = inputVisualHeight(composer.input, inputColumns)
|
|
const inputMouseRef = useRef<null | TextInputMouseApi>(null)
|
|
|
|
const captureInputDrag = (e: GutterMouseEvent) => {
|
|
if (e.button !== 0) {
|
|
return
|
|
}
|
|
|
|
e.stopImmediatePropagation?.()
|
|
inputMouseRef.current?.startAtBeginning()
|
|
}
|
|
|
|
// Drag origin matches the input box's top-left, so localRow / localCol
|
|
// map directly into TextInput coords (after backing out the prompt cell).
|
|
const dragFromPromptRow = (e: GutterMouseEvent) => {
|
|
if (e.button !== 0) {
|
|
return
|
|
}
|
|
|
|
e.stopImmediatePropagation?.()
|
|
inputMouseRef.current?.dragAt(e.localRow ?? 0, (e.localCol ?? 0) - promptWidth)
|
|
}
|
|
|
|
// Spacer rows live on a different vertical origin; only the column is
|
|
// parent-aligned with the input. Force row=0 so vertical drags can't
|
|
// jump the cursor to the wrong wrapped line.
|
|
const dragFromSpacer = (e: GutterMouseEvent) => {
|
|
if (e.button !== 0) {
|
|
return
|
|
}
|
|
|
|
e.stopImmediatePropagation?.()
|
|
inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - promptWidth)
|
|
}
|
|
|
|
const endInputDrag = () => inputMouseRef.current?.end()
|
|
|
|
return (
|
|
<NoSelect
|
|
flexDirection="column"
|
|
flexShrink={0}
|
|
fromLeftEdge
|
|
onClick={(e: { cellIsBlank?: boolean }) => {
|
|
if (e.cellIsBlank) {
|
|
actions.clearSelection()
|
|
}
|
|
}}
|
|
paddingX={1}
|
|
>
|
|
<QueuedMessages
|
|
cols={composer.cols}
|
|
queued={composer.queuedDisplay}
|
|
queueEditIdx={composer.queueEditIdx}
|
|
t={ui.theme}
|
|
/>
|
|
|
|
{ui.bgTasks.size > 0 && (
|
|
<Text color={ui.theme.color.muted}>
|
|
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
|
|
</Text>
|
|
)}
|
|
|
|
{status.showStickyPrompt ? (
|
|
<Text color={ui.theme.color.muted} wrap="truncate-end">
|
|
<Text color={ui.theme.color.label}>↳ </Text>
|
|
|
|
{status.stickyPrompt}
|
|
</Text>
|
|
) : (
|
|
<Box height={1} onMouseDown={captureInputDrag} onMouseDrag={dragFromSpacer} onMouseUp={endInputDrag} />
|
|
)}
|
|
|
|
<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={promptWidth}>
|
|
<Text color={ui.theme.color.muted}>{i === 0 ? promptLabel : ' '.repeat(promptWidth)}</Text>
|
|
</Box>
|
|
|
|
<Text color={ui.theme.color.text}>{line || ' '}</Text>
|
|
</Box>
|
|
))}
|
|
|
|
<Box
|
|
onMouseDown={captureInputDrag}
|
|
onMouseDrag={dragFromPromptRow}
|
|
onMouseUp={endInputDrag}
|
|
position="relative"
|
|
>
|
|
<Box width={promptWidth}>
|
|
{sh ? (
|
|
<Text color={ui.theme.color.shellDollar}>{promptLabel}</Text>
|
|
) : (
|
|
<Text bold color={ui.theme.color.prompt}>
|
|
{composer.inputBuf.length ? ' '.repeat(promptWidth) : promptLabel}
|
|
</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}
|
|
mouseApiRef={inputMouseRef}
|
|
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.muted}>⚕ {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)
|
|
const ui = useStore($uiState)
|
|
|
|
// Inline mode skips AlternateScreen so the host terminal's native
|
|
// scrollback captures rows scrolled off the top; composer + progress
|
|
// stay anchored via normal flex-column flow.
|
|
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>
|
|
|
|
{SHOW_FPS && (
|
|
<Box flexShrink={0} justifyContent="flex-end" paddingRight={1}>
|
|
<FpsOverlay t={ui.theme} />
|
|
</Box>
|
|
)}
|
|
</>
|
|
)}
|
|
</Box>
|
|
</Shell>
|
|
)
|
|
})
|
|
|
|
type GutterMouseEvent = {
|
|
button: number
|
|
localCol?: number
|
|
localRow?: number
|
|
stopImmediatePropagation?: () => void
|
|
}
|