FloatingOverlays (SessionPicker, ModelPicker, SkillsHub, pager, completions) was nested inside the !isBlocked guard in ComposerPane. When any overlay opened, isBlocked became true, which removed the entire composer box from the tree — including the overlay that was trying to render. This made /resume with no args appear to do nothing (the input line vanished and no picker appeared). Since99d859ce(feat: refactor by splitting up app and doing proper state), isBlocked gated only the text input lines so that approval/clarify prompts and pickers rendered above a hidden composer. The regression happened in408fc893(fix(tui): tighten composer — status sits directly above input, overlays anchor to input) when FloatingOverlays was moved into the input row for anchoring but accidentally kept inside the !isBlocked guard. so here, we render FloatingOverlays outside the !isBlocked guard inside the same position:relative Box, so overlays stay visible even when text input is hidden. Only the actual input buffer lines and TextInput are gated now. Fixes: /resume, /history, /logs, /model, /skills, and completion dropdowns when blocked overlays are active.
342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
|
|
import { useStore } from '@nanostores/react'
|
|
import { memo } from 'react'
|
|
|
|
import { useGateway } from '../app/gatewayContext.js'
|
|
import type { AppLayoutProgressProps, AppLayoutProps } from '../app/interfaces.js'
|
|
import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js'
|
|
import { $uiState } from '../app/uiStore.js'
|
|
import { PLACEHOLDER } from '../content/placeholders.js'
|
|
import type { Theme } from '../theme.js'
|
|
import type { DetailsMode } from '../types.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 { MessageLine } from './messageLine.js'
|
|
import { QueuedMessages } from './queuedMessages.js'
|
|
import { TextInput } from './textInput.js'
|
|
import { ToolTrail } from './thinking.js'
|
|
|
|
const StreamingAssistant = memo(function StreamingAssistant({
|
|
busy,
|
|
cols,
|
|
compact,
|
|
detailsMode,
|
|
progress,
|
|
t
|
|
}: StreamingAssistantProps) {
|
|
if (!progress.showProgressArea && !progress.showStreamingArea) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{progress.streamSegments.map((msg, i) => (
|
|
<MessageLine cols={cols} compact={compact} detailsMode={detailsMode} key={`seg:${i}`} msg={msg} t={t} />
|
|
))}
|
|
|
|
{progress.showProgressArea && (
|
|
<Box flexDirection="column" marginBottom={progress.showStreamingArea ? 1 : 0}>
|
|
<ToolTrail
|
|
activity={progress.activity}
|
|
busy={busy}
|
|
detailsMode={detailsMode}
|
|
outcome={progress.outcome}
|
|
reasoning={progress.reasoning}
|
|
reasoningActive={progress.reasoningActive}
|
|
reasoningStreaming={progress.reasoningStreaming}
|
|
reasoningTokens={progress.reasoningTokens}
|
|
subagents={progress.subagents}
|
|
t={t}
|
|
tools={progress.tools}
|
|
toolTokens={progress.toolTokens}
|
|
trail={progress.turnTrail}
|
|
/>
|
|
</Box>
|
|
)}
|
|
|
|
{progress.showStreamingArea && (
|
|
<MessageLine
|
|
cols={cols}
|
|
compact={compact}
|
|
detailsMode={detailsMode}
|
|
isStreaming
|
|
msg={{
|
|
role: 'assistant',
|
|
text: progress.streaming,
|
|
...(progress.streamPendingTools.length && { tools: progress.streamPendingTools })
|
|
}}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{!progress.showStreamingArea && !!progress.streamPendingTools.length && (
|
|
<MessageLine
|
|
cols={cols}
|
|
compact={compact}
|
|
detailsMode={detailsMode}
|
|
msg={{ kind: 'trail', role: 'system', text: '', tools: progress.streamPendingTools }}
|
|
t={t}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
})
|
|
|
|
const TranscriptPane = memo(function TranscriptPane({
|
|
actions,
|
|
composer,
|
|
progress,
|
|
transcript
|
|
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
|
|
const ui = useStore($uiState)
|
|
|
|
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}
|
|
msg={row.msg}
|
|
t={ui.theme}
|
|
/>
|
|
)}
|
|
</Box>
|
|
))}
|
|
|
|
{transcript.virtualHistory.bottomSpacer > 0 ? <Box height={transcript.virtualHistory.bottomSpacer} /> : null}
|
|
|
|
<StreamingAssistant
|
|
busy={ui.busy}
|
|
cols={composer.cols}
|
|
compact={ui.compact}
|
|
detailsMode={ui.detailsMode}
|
|
progress={progress}
|
|
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>
|
|
)}
|
|
|
|
<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={1} position="relative">
|
|
{/* subtract NoSelect paddingX={1} (2 cols) + pw so wrap-ansi and cursorLayout agree */}
|
|
<TextInput
|
|
columns={Math.max(20, composer.cols - pw - 2)}
|
|
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?.split('/').pop() ?? ''}
|
|
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)
|
|
|
|
return (
|
|
<AlternateScreen mouseTracking={mouseTracking}>
|
|
<Box flexDirection="column" flexGrow={1}>
|
|
<Box flexDirection="row" flexGrow={1}>
|
|
{overlay.agents ? (
|
|
<AgentsOverlayPane />
|
|
) : (
|
|
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
|
|
)}
|
|
</Box>
|
|
|
|
{!overlay.agents && (
|
|
<>
|
|
<PromptZone
|
|
cols={composer.cols}
|
|
onApprovalChoice={actions.answerApproval}
|
|
onClarifyAnswer={actions.answerClarify}
|
|
onSecretSubmit={actions.answerSecret}
|
|
onSudoSubmit={actions.answerSudo}
|
|
/>
|
|
|
|
<ComposerPane actions={actions} composer={composer} status={status} />
|
|
</>
|
|
)}
|
|
</Box>
|
|
</AlternateScreen>
|
|
)
|
|
})
|
|
|
|
interface StreamingAssistantProps {
|
|
busy: boolean
|
|
cols: number
|
|
compact?: boolean
|
|
detailsMode: DetailsMode
|
|
progress: AppLayoutProgressProps
|
|
t: Theme
|
|
}
|