mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
fix(tui): keep streaming progress stable during interaction
This commit is contained in:
parent
1c964ed43f
commit
355e0ae960
15 changed files with 278 additions and 106 deletions
52
ui-tui/src/app/interactionMode.ts
Normal file
52
ui-tui/src/app/interactionMode.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { SCROLLING_IDLE_MS, TYPING_IDLE_MS } from '../config/timing.js'
|
||||
|
||||
export type InteractionMode = 'idle' | 'scrolling' | 'typing'
|
||||
|
||||
type Timer = null | ReturnType<typeof setTimeout>
|
||||
|
||||
let mode: InteractionMode = 'idle'
|
||||
let scrollingTimer: Timer = null
|
||||
let typingTimer: Timer = null
|
||||
|
||||
const clear = (t: Timer): null => {
|
||||
if (t) {
|
||||
clearTimeout(t)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getInteractionMode(): InteractionMode {
|
||||
return mode
|
||||
}
|
||||
|
||||
export function markTyping(): void {
|
||||
mode = 'typing'
|
||||
typingTimer = clear(typingTimer)
|
||||
scrollingTimer = clear(scrollingTimer)
|
||||
typingTimer = setTimeout(() => {
|
||||
typingTimer = null
|
||||
mode = 'idle'
|
||||
}, TYPING_IDLE_MS)
|
||||
}
|
||||
|
||||
export function markScrolling(): void {
|
||||
if (mode === 'typing') {
|
||||
return
|
||||
}
|
||||
|
||||
mode = 'scrolling'
|
||||
scrollingTimer = clear(scrollingTimer)
|
||||
scrollingTimer = setTimeout(() => {
|
||||
scrollingTimer = null
|
||||
if (mode === 'scrolling') {
|
||||
mode = 'idle'
|
||||
}
|
||||
}, SCROLLING_IDLE_MS)
|
||||
}
|
||||
|
||||
export function resetInteractionMode(): void {
|
||||
scrollingTimer = clear(scrollingTimer)
|
||||
typingTimer = clear(typingTimer)
|
||||
mode = 'idle'
|
||||
}
|
||||
|
|
@ -31,8 +31,12 @@ export interface StateSetter<T> {
|
|||
export type StatusBarMode = 'bottom' | 'off' | 'top'
|
||||
|
||||
export interface SelectionApi {
|
||||
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
|
||||
clearSelection: () => void
|
||||
copySelection: () => string
|
||||
getState: () => unknown
|
||||
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
||||
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
||||
}
|
||||
|
||||
export interface CompletionItem {
|
||||
|
|
|
|||
58
ui-tui/src/app/scroll.ts
Normal file
58
ui-tui/src/app/scroll.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { ScrollBoxHandle } from '@hermes/ink'
|
||||
|
||||
import type { SelectionApi } from './interfaces.js'
|
||||
import { markScrolling } from './interactionMode.js'
|
||||
|
||||
export interface SelectionSnap {
|
||||
anchor?: { row: number } | null
|
||||
focus?: { row: number } | null
|
||||
isDragging?: boolean
|
||||
}
|
||||
|
||||
export interface ScrollWithSelectionOptions {
|
||||
readonly scrollRef: { readonly current: ScrollBoxHandle | null }
|
||||
readonly selection: SelectionApi
|
||||
}
|
||||
|
||||
export function scrollWithSelectionBy(delta: number, { scrollRef, selection }: ScrollWithSelectionOptions): void {
|
||||
const s = scrollRef.current
|
||||
|
||||
if (!s) {
|
||||
return
|
||||
}
|
||||
|
||||
const cur = s.getScrollTop() + s.getPendingDelta()
|
||||
const viewport = Math.max(0, s.getViewportHeight())
|
||||
const max = Math.max(0, s.getScrollHeight() - viewport)
|
||||
const actual = Math.max(0, Math.min(max, cur + delta)) - cur
|
||||
|
||||
if (actual === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
markScrolling()
|
||||
|
||||
const sel = selection.getState() as null | SelectionSnap
|
||||
const top = s.getViewportTop()
|
||||
const bottom = top + viewport - 1
|
||||
|
||||
if (
|
||||
sel?.anchor &&
|
||||
sel.focus &&
|
||||
sel.anchor.row >= top &&
|
||||
sel.anchor.row <= bottom &&
|
||||
(sel.isDragging || (sel.focus.row >= top && sel.focus.row <= bottom))
|
||||
) {
|
||||
const shift = sel.isDragging ? selection.shiftAnchor : selection.shiftSelection
|
||||
|
||||
if (actual > 0) {
|
||||
selection.captureScrolledRows(top, top + actual - 1, 'above')
|
||||
} else {
|
||||
selection.captureScrolledRows(bottom + actual + 1, bottom, 'below')
|
||||
}
|
||||
|
||||
shift(-actual, top, bottom)
|
||||
}
|
||||
|
||||
s.scrollBy(actual)
|
||||
}
|
||||
|
|
@ -1,4 +1,10 @@
|
|||
import { REASONING_PULSE_MS, STREAM_BATCH_MS, STREAM_IDLE_BATCH_MS, STREAM_TYPING_BATCH_MS } from '../config/timing.js'
|
||||
import {
|
||||
REASONING_PULSE_MS,
|
||||
STREAM_BATCH_MS,
|
||||
STREAM_IDLE_BATCH_MS,
|
||||
STREAM_SCROLLING_BATCH_MS,
|
||||
STREAM_TYPING_BATCH_MS
|
||||
} from '../config/timing.js'
|
||||
import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js'
|
||||
import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
|
||||
import {
|
||||
|
|
@ -10,6 +16,7 @@ import {
|
|||
} from '../lib/text.js'
|
||||
import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js'
|
||||
|
||||
import { getInteractionMode } from './interactionMode.js'
|
||||
import { resetFlowOverlays } from './overlayStore.js'
|
||||
import { pushSnapshot } from './spawnHistoryStore.js'
|
||||
import { getTurnState, patchTurnState, resetTurnState } from './turnStore.js'
|
||||
|
|
@ -497,12 +504,15 @@ class TurnController {
|
|||
return
|
||||
}
|
||||
|
||||
const interaction = getInteractionMode()
|
||||
const delay = interaction === 'scrolling' ? STREAM_SCROLLING_BATCH_MS : interaction === 'typing' ? STREAM_TYPING_BATCH_MS : this.streamDelay
|
||||
|
||||
this.streamTimer = setTimeout(() => {
|
||||
this.streamTimer = null
|
||||
const raw = this.bufRef.trimStart()
|
||||
const visible = hasReasoningTag(raw) ? splitReasoning(raw).text : raw
|
||||
patchTurnState({ streaming: visible })
|
||||
}, this.streamDelay)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
startMessage() {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { useComposerState } from './useComposerState.js'
|
|||
import { useConfigSync } from './useConfigSync.js'
|
||||
import { useInputHandlers } from './useInputHandlers.js'
|
||||
import { useLongRunToolCharms } from './useLongRunToolCharms.js'
|
||||
import { scrollWithSelectionBy } from './scroll.js'
|
||||
import { useSessionLifecycle } from './useSessionLifecycle.js'
|
||||
import { useSubmission } from './useSubmission.js'
|
||||
|
||||
|
|
@ -64,12 +65,6 @@ const statusColorOf = (status: string, t: { dim: string; error: string; ok: stri
|
|||
return t.dim
|
||||
}
|
||||
|
||||
interface SelectionSnap {
|
||||
anchor?: { row: number }
|
||||
focus?: { row: number }
|
||||
isDragging?: boolean
|
||||
}
|
||||
|
||||
export function useMainApp(gw: GatewayClient) {
|
||||
const { exit } = useApp()
|
||||
const { stdout } = useStdout()
|
||||
|
|
@ -186,46 +181,7 @@ export function useMainApp(gw: GatewayClient) {
|
|||
const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols)
|
||||
|
||||
const scrollWithSelection = useCallback(
|
||||
(delta: number) => {
|
||||
const s = scrollRef.current
|
||||
|
||||
if (!s) {
|
||||
return
|
||||
}
|
||||
|
||||
const sel = selection.getState() as null | SelectionSnap
|
||||
const top = s.getViewportTop()
|
||||
const bottom = top + s.getViewportHeight() - 1
|
||||
|
||||
if (
|
||||
!sel?.anchor ||
|
||||
!sel.focus ||
|
||||
sel.anchor.row < top ||
|
||||
sel.anchor.row > bottom ||
|
||||
(!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom))
|
||||
) {
|
||||
return s.scrollBy(delta)
|
||||
}
|
||||
|
||||
const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight())
|
||||
const cur = s.getScrollTop() + s.getPendingDelta()
|
||||
const actual = Math.max(0, Math.min(max, cur + delta)) - cur
|
||||
|
||||
if (actual === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const shift = sel!.isDragging ? selection.shiftAnchor : selection.shiftSelection
|
||||
|
||||
if (actual > 0) {
|
||||
selection.captureScrolledRows(top, top + actual - 1, 'above')
|
||||
} else {
|
||||
selection.captureScrolledRows(bottom + actual + 1, bottom, 'below')
|
||||
}
|
||||
|
||||
shift(-actual, top, bottom)
|
||||
s.scrollBy(delta)
|
||||
},
|
||||
(delta: number) => scrollWithSelectionBy(delta, { scrollRef, selection }),
|
||||
[selection]
|
||||
)
|
||||
|
||||
|
|
@ -700,14 +656,12 @@ export function useMainApp(gw: GatewayClient) {
|
|||
[turn, showProgressArea]
|
||||
)
|
||||
|
||||
const frozenProgressRef = useRef(liveProgress)
|
||||
|
||||
// Freeze the offscreen live tail so scroll doesn't rebuild unseen streaming UI.
|
||||
if (liveTailVisible || !ui.busy) {
|
||||
frozenProgressRef.current = liveProgress
|
||||
}
|
||||
|
||||
const appProgress = liveTailVisible || !ui.busy ? liveProgress : frozenProgressRef.current
|
||||
// Always pass current progress through. Freezing this while offscreen looked
|
||||
// like a nice scroll optimization, but it also froze the live tail's
|
||||
// thinking/tool state at arbitrary intermediate snapshots. Streaming update
|
||||
// throttling now handles interaction load; progress state should remain
|
||||
// truthful so panels don't randomly disappear.
|
||||
const appProgress = liveProgress
|
||||
|
||||
const cwd = ui.info?.cwd || process.env.HERMES_CWD || process.cwd()
|
||||
const gitBranch = useGitBranch(cwd)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { TYPING_IDLE_MS } from '../config/timing.js'
|
||||
import { attachedImageNotice } from '../domain/messages.js'
|
||||
import { looksLikeSlashCommand } from '../domain/slash.js'
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
|
|
@ -11,6 +10,7 @@ import { PASTE_SNIPPET_RE } from '../protocol/paste.js'
|
|||
import type { Msg } from '../types.js'
|
||||
|
||||
import type { ComposerActions, ComposerRefs, ComposerState, PasteSnippet } from './interfaces.js'
|
||||
import { markTyping } from './interactionMode.js'
|
||||
import { turnController } from './turnController.js'
|
||||
import { getUiState, patchUiState } from './uiStore.js'
|
||||
|
||||
|
|
@ -48,28 +48,13 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
|||
} = opts
|
||||
|
||||
const lastEmptyAt = useRef(0)
|
||||
const typingIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (composerState.input || composerState.inputBuf.length) {
|
||||
markTyping()
|
||||
if (getUiState().busy) {
|
||||
turnController.boostStreamingForTyping()
|
||||
}
|
||||
|
||||
if (typingIdleTimer.current) {
|
||||
clearTimeout(typingIdleTimer.current)
|
||||
}
|
||||
|
||||
typingIdleTimer.current = setTimeout(() => {
|
||||
typingIdleTimer.current = null
|
||||
turnController.relaxStreaming()
|
||||
}, TYPING_IDLE_MS)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typingIdleTimer.current) {
|
||||
clearTimeout(typingIdleTimer.current)
|
||||
}
|
||||
}
|
||||
}, [composerState.input, composerState.inputBuf])
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue