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

@ -372,6 +372,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
case 'tool.start':
turnController.recordTodos(ev.payload.todos)
turnController.recordToolStart(ev.payload.tool_id, ev.payload.name ?? 'tool', ev.payload.context ?? '')
return
@ -384,10 +385,18 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
inlineDiffText,
ev.payload.tool_id,
ev.payload.name,
ev.payload.error
ev.payload.error,
ev.payload.duration_s
)
} else {
turnController.recordToolComplete(ev.payload.tool_id, ev.payload.name, ev.payload.error, ev.payload.summary)
turnController.recordToolComplete(
ev.payload.tool_id,
ev.payload.name,
ev.payload.error,
ev.payload.summary,
ev.payload.duration_s,
ev.payload.todos
)
}
return

View file

@ -7,8 +7,6 @@ import type { ImageAttachResponse } from '../gatewayTypes.js'
import type { RpcResult } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import type {
ActiveTool,
ActivityItem,
ApprovalReq,
ClarifyReq,
ConfirmReq,
@ -19,7 +17,6 @@ import type {
SectionVisibility,
SessionInfo,
SlashCatalog,
SubagentProgress,
SudoReq,
Usage
} from '../types.js'
@ -308,21 +305,7 @@ export interface AppLayoutComposerProps {
}
export interface AppLayoutProgressProps {
activity: ActivityItem[]
outcome: string
reasoning: string
reasoningActive: boolean
reasoningStreaming: boolean
reasoningTokens: number
showProgressArea: boolean
showStreamingArea: boolean
streamPendingTools: string[]
streamSegments: Msg[]
streaming: string
subagents: SubagentProgress[]
toolTokens: number
tools: ActiveTool[]
turnTrail: string[]
}
export interface AppLayoutStatusProps {

View file

@ -260,7 +260,9 @@ export const coreCommands: SlashCommand[] = [
if (text) {
return sys(`copied ${text.length} characters`)
} else {
return sys('clipboard copy failed — try HERMES_TUI_FORCE_OSC52=1 to force the escape sequence; HERMES_TUI_DEBUG_CLIPBOARD=1 for details')
return sys(
'clipboard copy failed — try HERMES_TUI_FORCE_OSC52=1 to force the escape sequence; HERMES_TUI_DEBUG_CLIPBOARD=1 for details'
)
}
}

View file

@ -1,6 +1,7 @@
import { atom } from 'nanostores'
import { useSyncExternalStore } from 'react'
import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js'
import type { ActiveTool, ActivityItem, Msg, SubagentProgress, TodoItem } from '../types.js'
const buildTurnState = (): TurnState => ({
activity: [],
@ -13,6 +14,7 @@ const buildTurnState = (): TurnState => ({
streamSegments: [],
streaming: '',
subagents: [],
todos: [],
toolTokens: 0,
tools: [],
turnTrail: []
@ -22,6 +24,15 @@ export const $turnState = atom<TurnState>(buildTurnState())
export const getTurnState = () => $turnState.get()
const subscribeTurn = (cb: () => void) => $turnState.listen(() => cb())
export const useTurnSelector = <T>(selector: (state: TurnState) => T): T =>
useSyncExternalStore(
subscribeTurn,
() => selector($turnState.get()),
() => selector($turnState.get())
)
export const patchTurnState = (next: Partial<TurnState> | ((state: TurnState) => TurnState)) =>
$turnState.set(typeof next === 'function' ? next($turnState.get()) : { ...$turnState.get(), ...next })
@ -38,6 +49,7 @@ export interface TurnState {
streamSegments: Msg[]
streaming: string
subagents: SubagentProgress[]
todos: TodoItem[]
toolTokens: number
tools: ActiveTool[]
turnTrail: string[]

View file

@ -1,6 +1,8 @@
import { useInput } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useRef } from 'react'
import { TYPING_IDLE_MS } from '../config/timing.js'
import type {
ApprovalRespondResponse,
ConfigSetResponse,
@ -26,6 +28,24 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
const overlay = useStore($overlayState)
const isBlocked = useStore($isBlocked)
const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6)
const scrollIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const scrollTranscript = (delta: number) => {
if (getUiState().busy) {
turnController.boostStreamingForScroll()
if (scrollIdleTimer.current) {
clearTimeout(scrollIdleTimer.current)
}
scrollIdleTimer.current = setTimeout(() => {
scrollIdleTimer.current = null
turnController.relaxStreaming()
}, TYPING_IDLE_MS)
}
terminal.scrollWithSelection(delta)
}
const copySelection = () => {
// ink's copySelection() already calls setClipboard() which handles
@ -259,26 +279,26 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
}
if (key.wheelUp) {
return terminal.scrollWithSelection(-wheelStep)
return scrollTranscript(-wheelStep)
}
if (key.wheelDown) {
return terminal.scrollWithSelection(wheelStep)
return scrollTranscript(wheelStep)
}
if (key.shift && key.upArrow) {
return terminal.scrollWithSelection(-1)
return scrollTranscript(-1)
}
if (key.shift && key.downArrow) {
return terminal.scrollWithSelection(1)
return scrollTranscript(1)
}
if (key.pageUp || key.pageDown) {
const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, (terminal.stdout?.rows ?? 24) - 8)
const step = Math.max(4, viewport - 2)
return terminal.scrollWithSelection(key.pageUp ? -step : step)
return scrollTranscript(key.pageUp ? -step : step)
}
if (key.escape && terminal.hasSelection) {

View file

@ -28,7 +28,7 @@ import { type GatewayRpc, type TranscriptRow } from './interfaces.js'
import { $overlayState, patchOverlayState } from './overlayStore.js'
import { scrollWithSelectionBy } from './scroll.js'
import { turnController } from './turnController.js'
import { $turnState, patchTurnState } from './turnStore.js'
import { $turnState, patchTurnState, useTurnSelector } from './turnStore.js'
import { $uiState, getUiState, patchUiState } from './uiStore.js'
import { useComposerState } from './useComposerState.js'
import { useConfigSync } from './useConfigSync.js'
@ -108,6 +108,19 @@ export function useMainApp(gw: GatewayClient) {
const overlay = useStore($overlayState)
const turn = useStore($turnState)
const turnLiveTailActive = useTurnSelector(state =>
Boolean(
state.streaming ||
state.streamPendingTools.length ||
state.streamSegments.length ||
state.reasoning.trim() ||
state.reasoningActive ||
state.tools.length ||
state.subagents.length ||
state.todos.length
)
)
const slashFlightRef = useRef(0)
const slashRef = useRef<(cmd: string) => boolean>(() => false)
const colsRef = useRef(cols)
@ -178,7 +191,7 @@ export function useMainApp(gw: GatewayClient) {
[historyItems, messageId]
)
const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols)
const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols, { liveTailActive: turnLiveTailActive })
const scrollWithSelection = useCallback(
(delta: number) => scrollWithSelectionBy(delta, { scrollRef, selection }),
@ -587,7 +600,7 @@ export function useMainApp(gw: GatewayClient) {
slashRef.current(`/model ${value} --global`)
}, [])
const hasReasoning = Boolean(turn.reasoning.trim())
const hasReasoning = useTurnSelector(state => Boolean(state.reasoning.trim()))
// Per-section overrides win over the global mode — when every section is
// resolved to hidden, the only thing ToolTrail will surface is the
@ -597,19 +610,22 @@ export function useMainApp(gw: GatewayClient) {
s => sectionMode(s, ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
)
const showProgressArea = anyPanelVisible
? Boolean(
ui.busy ||
turn.outcome ||
turn.streamPendingTools.length ||
turn.streamSegments.length ||
turn.subagents.length ||
turn.tools.length ||
turn.turnTrail.length ||
hasReasoning ||
turn.activity.length
)
: turn.activity.some(item => item.tone !== 'info')
const showProgressArea = useTurnSelector(state =>
anyPanelVisible
? Boolean(
ui.busy ||
state.outcome ||
state.streamPendingTools.length ||
state.streamSegments.length ||
state.subagents.length ||
state.tools.length ||
state.todos.length ||
state.turnTrail.length ||
hasReasoning ||
state.activity.length
)
: state.activity.some(item => item.tone !== 'info')
)
const appActions = useMemo(
() => ({
@ -654,10 +670,7 @@ export function useMainApp(gw: GatewayClient) {
return bottom >= scrollHeight - 3
})()
const liveProgress = useMemo(
() => ({ ...turn, showProgressArea, showStreamingArea: Boolean(turn.streaming) }),
[turn, showProgressArea]
)
const liveProgress = useMemo(() => ({ showProgressArea }), [showProgressArea])
// Always pass current progress through. Freezing this while offscreen looked
// like a nice scroll optimization, but it also froze the live tail's

View file

@ -58,6 +58,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
if (!composerState.input && !composerState.inputBuf.length) {
turnController.relaxStreaming()
return
}
@ -92,9 +93,11 @@ export function useSubmission(opts: UseSubmissionOptions) {
turnController.clearStatusTimer()
maybeGoodVibes(submitText)
setLastUserMsg(text)
if (showUserMessage) {
appendMessage({ role: 'user', text: displayText })
}
patchUiState({ busy: true, status: 'running…' })
turnController.bufRef = ''
turnController.interrupted = false