fix(tui): stabilize live progress rendering

This commit is contained in:
Brooklyn Nicholson 2026-04-26 15:23:43 -05:00
parent d4dde6b5f2
commit a7831b63db
28 changed files with 619 additions and 154 deletions

View file

@ -139,6 +139,27 @@ function SessionDuration({ startedAt }: { startedAt: number }) {
return fmtDuration(now - startedAt)
}
const effortLabel = (effort?: string) => {
const value = String(effort ?? '')
.trim()
.toLowerCase()
return value && value !== 'medium' && value !== 'normal' && value !== 'default' ? value : ''
}
const shortModelLabel = (model: string) =>
model
.split('/')
.pop()!
.replace(/^claude[-_]/, '')
.replace(/^anthropic[-_]/, '')
.replace(/[-_]/g, ' ')
.replace(/\b(\d+)\s+(\d+)\b/g, '$1.$2')
.trim()
const modelLabel = (model: string, effort?: string, fast?: boolean) =>
[shortModelLabel(model), effortLabel(effort), fast ? 'fast' : ''].filter(Boolean).join(' ')
export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
const [active, setActive] = useState(false)
const [color, setColor] = useState(t.color.amber)
@ -171,6 +192,8 @@ export function StatusRule({
status,
statusColor,
model,
modelFast,
modelReasoningEffort,
usage,
bgCount,
sessionStartedAt,
@ -201,7 +224,7 @@ export function StatusRule({
) : (
<Text color={statusColor}>{status}</Text>
)}
<Text color={t.color.dim}> {model}</Text>
<Text color={t.color.dim}> {modelLabel(model, modelReasoningEffort, modelFast)}</Text>
{ctxLabel ? <Text color={t.color.dim}> {ctxLabel}</Text> : null}
{bar ? (
<Text color={t.color.dim}>
@ -337,6 +360,8 @@ interface StatusRuleProps {
cols: number
cwdLabel: string
model: string
modelFast?: boolean
modelReasoningEffort?: string
sessionStartedAt?: null | number
showCost: boolean
status: string

View file

@ -3,13 +3,11 @@ import { useStore } from '@nanostores/react'
import { memo } from 'react'
import { useGateway } from '../app/gatewayContext.js'
import type { AppLayoutProgressProps, AppLayoutProps } from '../app/interfaces.js'
import type { 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 { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
import type { Theme } from '../theme.js'
import type { DetailsMode, SectionVisibility } from '../types.js'
import { AgentsOverlay } from './agentsOverlay.js'
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
@ -17,69 +15,9 @@ import { FloatingOverlays, PromptZone } from './appOverlays.js'
import { Banner, Panel, SessionPanel } from './branding.js'
import { MessageLine } from './messageLine.js'
import { QueuedMessages } from './queuedMessages.js'
import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js'
import { TextInput } from './textInput.js'
const StreamingAssistant = memo(function StreamingAssistant({
busy,
cols,
compact,
detailsMode,
detailsModeCommandOverride,
progress,
sections,
t
}: StreamingAssistantProps) {
if (!progress.showProgressArea && !progress.showStreamingArea) {
return null
}
return (
<>
{progress.streamSegments.map((msg, i) => (
<MessageLine
cols={cols}
compact={compact}
detailsMode={detailsMode}
detailsModeCommandOverride={detailsModeCommandOverride}
key={`seg:${i}`}
msg={msg}
sections={sections}
t={t}
/>
))}
{progress.showStreamingArea && (
<MessageLine
cols={cols}
compact={compact}
detailsMode={detailsMode}
detailsModeCommandOverride={detailsModeCommandOverride}
isStreaming
msg={{
role: 'assistant',
text: progress.streaming,
...(progress.streamPendingTools.length && { tools: progress.streamPendingTools })
}}
sections={sections}
t={t}
/>
)}
{!progress.showStreamingArea && !!progress.streamPendingTools.length && (
<MessageLine
cols={cols}
compact={compact}
detailsMode={detailsMode}
detailsModeCommandOverride={detailsModeCommandOverride}
msg={{ kind: 'trail', role: 'system', text: '', tools: progress.streamPendingTools }}
sections={sections}
t={t}
/>
)}
</>
)
})
const TranscriptPane = memo(function TranscriptPane({
actions,
composer,
@ -120,15 +58,15 @@ const TranscriptPane = memo(function TranscriptPane({
{transcript.virtualHistory.bottomSpacer > 0 ? <Box height={transcript.virtualHistory.bottomSpacer} /> : null}
<LiveTodoPanel />
<StreamingAssistant
busy={ui.busy}
cols={composer.cols}
compact={ui.compact}
detailsMode={ui.detailsMode}
detailsModeCommandOverride={ui.detailsModeCommandOverride}
progress={progress}
sections={ui.sections}
t={ui.theme}
/>
</Box>
</ScrollBox>
@ -279,7 +217,9 @@ const StatusRulePane = memo(function StatusRulePane({
busy={ui.busy}
cols={composer.cols}
cwdLabel={status.cwdLabel}
model={ui.info?.model?.split('/').pop() ?? ''}
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}
@ -331,14 +271,3 @@ export const AppLayout = memo(function AppLayout({
</AlternateScreen>
)
})
interface StreamingAssistantProps {
busy: boolean
cols: number
compact?: boolean
detailsMode: DetailsMode
detailsModeCommandOverride: boolean
progress: AppLayoutProgressProps
sections?: SectionVisibility
t: Theme
}

View file

@ -0,0 +1,119 @@
import { useStore } from '@nanostores/react'
import { memo } from 'react'
import type { AppLayoutProgressProps } from '../app/interfaces.js'
import { useTurnSelector } from '../app/turnStore.js'
import { $uiState } from '../app/uiStore.js'
import type { DetailsMode, Msg, SectionVisibility } from '../types.js'
import { MessageLine } from './messageLine.js'
import { TodoPanel } from './todoPanel.js'
const isToolOnly = (msg: Msg | undefined) =>
Boolean(msg && msg.kind === 'trail' && !msg.thinking?.trim() && !msg.text && msg.tools?.length)
const groupedSegments = (segments: Msg[]) =>
segments.reduce<Msg[]>((acc, msg) => {
if (isToolOnly(msg) && isToolOnly(acc.at(-1))) {
const prev = acc.at(-1)!
return [...acc.slice(0, -1), { ...prev, tools: [...(prev.tools ?? []), ...(msg.tools ?? [])] }]
}
return [...acc, msg]
}, [])
export const StreamingAssistant = memo(function StreamingAssistant({
cols,
compact,
detailsMode,
detailsModeCommandOverride,
progress,
sections
}: StreamingAssistantProps) {
const ui = useStore($uiState)
const streamSegments = useTurnSelector(state => state.streamSegments)
const streamPendingTools = useTurnSelector(state => state.streamPendingTools)
const streaming = useTurnSelector(state => state.streaming)
const activeTools = useTurnSelector(state => state.tools)
const showStreamingArea = Boolean(streaming)
if (!progress.showProgressArea && !showStreamingArea && !activeTools.length) {
return null
}
return (
<>
{groupedSegments(streamSegments).map((msg, i) => (
<MessageLine
cols={cols}
compact={compact}
detailsMode={detailsMode}
detailsModeCommandOverride={detailsModeCommandOverride}
key={`seg:${i}`}
msg={msg}
sections={sections}
t={ui.theme}
/>
))}
{!!activeTools.length && (
<MessageLine
cols={cols}
compact={compact}
detailsMode={detailsMode}
detailsModeCommandOverride={detailsModeCommandOverride}
msg={{ kind: 'trail', role: 'system', text: '' }}
sections={sections}
t={ui.theme}
tools={activeTools}
/>
)}
{showStreamingArea && (
<MessageLine
cols={cols}
compact={compact}
detailsMode={detailsMode}
detailsModeCommandOverride={detailsModeCommandOverride}
isStreaming
msg={{
role: 'assistant',
text: streaming,
...(streamPendingTools.length && { tools: streamPendingTools })
}}
sections={sections}
t={ui.theme}
/>
)}
{!showStreamingArea && !!streamPendingTools.length && (
<MessageLine
cols={cols}
compact={compact}
detailsMode={detailsMode}
detailsModeCommandOverride={detailsModeCommandOverride}
msg={{ kind: 'trail', role: 'system', text: '', tools: streamPendingTools }}
sections={sections}
t={ui.theme}
/>
)}
</>
)
})
export const LiveTodoPanel = memo(function LiveTodoPanel() {
const ui = useStore($uiState)
const todos = useTurnSelector(state => state.todos)
return <TodoPanel t={ui.theme} todos={todos} />
})
interface StreamingAssistantProps {
cols: number
compact?: boolean
detailsMode: DetailsMode
detailsModeCommandOverride: boolean
progress: AppLayoutProgressProps
sections?: SectionVisibility
}

View file

@ -508,7 +508,8 @@ export function TextInput({
curRef.current = c
vRef.current = next
lineWidthRef.current = nextLineWidth ?? stringWidth(next.includes('\n') ? next.slice(next.lastIndexOf('\n') + 1) : next)
lineWidthRef.current =
nextLineWidth ?? stringWidth(next.includes('\n') ? next.slice(next.lastIndexOf('\n') + 1) : next)
if (next !== prev) {
if (syncParent) {

View file

@ -0,0 +1,46 @@
import { Box, Text } from '@hermes/ink'
import { memo } from 'react'
import { todoGlyph } from '../lib/todo.js'
import type { Theme } from '../theme.js'
import type { TodoItem } from '../types.js'
export const TodoPanel = memo(function TodoPanel({ t, todos }: { t: Theme; todos: TodoItem[] }) {
if (!todos.length) {
return null
}
return (
<Box flexDirection="column" marginBottom={1}>
<Text color={t.color.dim}>
<Text color={t.color.amber}> </Text>
<Text bold color={t.color.cornsilk}>
Todo
</Text>{' '}
<Text color={t.color.statusFg} dim>
({todos.filter(todo => todo.status === 'completed').length}/{todos.length})
</Text>
</Text>
<Box flexDirection="column" marginLeft={2}>
{todos.map(todo => {
const done = todo.status === 'completed'
const cancel = todo.status === 'cancelled'
const active = todo.status === 'in_progress'
return (
<Text
color={done || cancel ? t.color.dim : active ? t.color.cornsilk : t.color.statusFg}
dim={done || cancel}
key={todo.id}
>
<Text color={active ? t.color.amber : done ? t.color.ok : cancel ? t.color.error : t.color.dim}>
{todoGlyph(todo.status)}{' '}
</Text>
{todo.content}
</Text>
)
})}
</Box>
</Box>
)
})