mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
fix(tui): stabilize live progress rendering
This commit is contained in:
parent
d4dde6b5f2
commit
a7831b63db
28 changed files with 619 additions and 154 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue