fix(tui): keep streaming progress stable during interaction

This commit is contained in:
Brooklyn Nicholson 2026-04-26 04:23:57 -05:00
parent 1c964ed43f
commit 355e0ae960
15 changed files with 278 additions and 106 deletions

View File

@ -29,7 +29,7 @@ import {
FOCUS_IN,
FOCUS_OUT
} from '../termio/csi.js'
import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, HIDE_CURSOR, SHOW_CURSOR } from '../termio/dec.js'
import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, SHOW_CURSOR } from '../termio/dec.js'
import AppContext from './AppContext.js'
import { ClockProvider } from './ClockContext.js'
@ -206,10 +206,9 @@ export default class App extends PureComponent<Props, State> {
)
}
override componentDidMount() {
// In accessibility mode, keep the native cursor visible for screen magnifiers and other tools
if (this.props.stdout.isTTY) {
this.props.stdout.write(HIDE_CURSOR)
}
// Keep the native terminal cursor visible. Ink parks it at the declared
// input caret after each frame, so the terminal emulator provides the
// normal blinking block/bar without React-driven blink re-renders.
}
override componentWillUnmount() {
if (this.props.stdout.isTTY) {
@ -470,7 +469,7 @@ export default class App extends PureComponent<Props, State> {
}
if (this.props.stdout.isTTY) {
this.props.stdout.write(HIDE_CURSOR + EFE)
this.props.stdout.write(EFE)
}
this.inputEmitter.emit('resume')
@ -569,18 +568,19 @@ function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined,
/** Exported for testing. Mutates app.props.selection and click/hover state. */
export function handleMouseEvent(app: App, m: ParsedMouse): void {
// Allow disabling click handling while keeping wheel scroll (which goes
// through the keybinding system as 'wheelup'/'wheeldown', not here).
if (isMouseClicksDisabled()) {
return
}
const sel = app.props.selection
// Terminal coords are 1-indexed; screen buffer is 0-indexed
const col = m.col - 1
const row = m.row - 1
const baseButton = m.button & 0x03
// Allow disabling app click/selection handling while keeping wheel scroll
// and DOM mouse dispatch alive. Put this after coordinate/button decoding
// and exempt non-left buttons so scrollbar/right-click handlers still work.
if (isMouseClicksDisabled() && baseButton === 0) {
return
}
if (m.action === 'press') {
if ((m.button & 0x20) !== 0 && baseButton === 3) {
if (app.mouseCaptureTarget) {

View File

@ -122,6 +122,19 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
})
}
const scrollByNow = (dy: number) => {
const el = domRef.current
if (!el) {
return
}
el.stickyScroll = false
el.scrollAnchor = undefined
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy)
scrollMutated(el)
}
useImperativeHandle(
ref,
(): ScrollBoxHandle => ({
@ -155,22 +168,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
}
scrollMutated(box)
},
scrollBy(dy: number) {
const el = domRef.current
if (!el) {
return
}
el.stickyScroll = false
// Wheel input cancels any in-flight anchor seek — user override.
el.scrollAnchor = undefined
// Accumulate in pendingScrollDelta; renderer drains it at a capped
// rate so fast flicks show intermediate frames. Pure accumulator:
// scroll-up followed by scroll-down naturally cancels.
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy)
scrollMutated(el)
},
scrollBy: scrollByNow,
scrollToBottom() {
const el = domRef.current

View File

@ -0,0 +1,28 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getInteractionMode, markScrolling, markTyping, resetInteractionMode } from '../app/interactionMode.js'
import { SCROLLING_IDLE_MS, TYPING_IDLE_MS } from '../config/timing.js'
describe('interactionMode', () => {
afterEach(() => {
resetInteractionMode()
vi.useRealTimers()
})
it('holds scrolling mode briefly then returns idle', () => {
vi.useFakeTimers()
markScrolling()
expect(getInteractionMode()).toBe('scrolling')
vi.advanceTimersByTime(SCROLLING_IDLE_MS)
expect(getInteractionMode()).toBe('idle')
})
it('typing takes priority over scrolling', () => {
vi.useFakeTimers()
markTyping()
markScrolling()
expect(getInteractionMode()).toBe('typing')
vi.advanceTimersByTime(TYPING_IDLE_MS)
expect(getInteractionMode()).toBe('idle')
})
})

View File

@ -0,0 +1,53 @@
import { describe, expect, it, vi } from 'vitest'
import { scrollWithSelectionBy } from '../app/scroll.js'
function makeScroll(overrides: Partial<Record<string, unknown>> = {}) {
return {
getPendingDelta: vi.fn(() => 0),
getScrollHeight: vi.fn(() => 100),
getScrollTop: vi.fn(() => 10),
getViewportHeight: vi.fn(() => 20),
getViewportTop: vi.fn(() => 0),
scrollBy: vi.fn(),
...overrides
}
}
describe('scrollWithSelectionBy', () => {
it('clamps to the actual remaining scroll distance before calling scrollBy', () => {
const s = makeScroll({
getScrollHeight: vi.fn(() => 30),
getScrollTop: vi.fn(() => 9),
getViewportHeight: vi.fn(() => 20)
})
const selection = {
captureScrolledRows: vi.fn(),
getState: vi.fn(() => null),
shiftAnchor: vi.fn(),
shiftSelection: vi.fn()
}
scrollWithSelectionBy(10, { scrollRef: { current: s as never }, selection })
expect(s.scrollBy).toHaveBeenCalledWith(1)
})
it('does nothing at the edge instead of queueing dead pending deltas', () => {
const s = makeScroll({
getScrollHeight: vi.fn(() => 30),
getScrollTop: vi.fn(() => 10),
getViewportHeight: vi.fn(() => 20)
})
const selection = {
captureScrolledRows: vi.fn(),
getState: vi.fn(() => null),
shiftAnchor: vi.fn(),
shiftSelection: vi.fn()
}
scrollWithSelectionBy(10, { scrollRef: { current: s as never }, selection })
expect(s.scrollBy).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { shouldSetVirtualClamp } from '../hooks/useVirtualHistory.js'
describe('virtual history clamp bounds', () => {
it('does not clamp sticky live tail content', () => {
expect(shouldSetVirtualClamp({ itemCount: 20, sticky: true, viewportHeight: 10 })).toBe(false)
})
it('sets clamp bounds after manual scroll breaks sticky mode', () => {
expect(shouldSetVirtualClamp({ itemCount: 20, sticky: false, viewportHeight: 10 })).toBe(true)
})
})

View 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'
}

View File

@ -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
View 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)
}

View File

@ -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() {

View File

@ -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)

View File

@ -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])

View File

@ -336,7 +336,7 @@ export function TextInput({
active: focus && termFocus && !selected
})
const nativeCursor = focus && termFocus && !selected
const nativeCursor = focus && termFocus && !selected && !!stdout?.isTTY
const rendered = useMemo(() => {
if (!focus) {

View File

@ -2,4 +2,4 @@ export const LARGE_PASTE = { chars: 8000, lines: 80 }
export const LONG_MSG = 300
export const MAX_HISTORY = 800
export const THINKING_COT_MAX = 160
export const WHEEL_SCROLL_STEP = 3
export const WHEEL_SCROLL_STEP = 6

View File

@ -1,5 +1,7 @@
export const STREAM_BATCH_MS = 16
export const STREAM_IDLE_BATCH_MS = 16
export const STREAM_TYPING_BATCH_MS = 80
export const TYPING_IDLE_MS = 120
export const STREAM_SCROLLING_BATCH_MS = 250
export const STREAM_TYPING_BATCH_MS = 120
export const TYPING_IDLE_MS = 250
export const SCROLLING_IDLE_MS = 450
export const REASONING_PULSE_MS = 700

View File

@ -17,6 +17,16 @@ const COLD_START = 40
const QUANTUM = OVERSCAN >> 1
const FREEZE_RENDERS = 2
export const shouldSetVirtualClamp = ({
itemCount,
sticky,
viewportHeight
}: {
itemCount: number
sticky: boolean
viewportHeight: number
}) => itemCount > 0 && viewportHeight > 0 && !sticky
const upperBound = (arr: number[], target: number) => {
let lo = 0
let hi = arr.length
@ -173,11 +183,16 @@ export function useVirtualHistory(
// Give the renderer the mounted-row coverage for passive scroll clamping.
// Without this, burst wheel/page scroll can race past the React commit that
// updates the virtual range and paint spacer-only frames.
if (s && n > 0 && vp > 0) {
if (s && shouldSetVirtualClamp({ itemCount: n, sticky, viewportHeight: vp })) {
const min = offsets[start] ?? 0
const max = Math.max(min, (offsets[end] ?? total) - vp)
s.setClampBounds(min, max)
} else {
// Sticky bottom often has live, non-virtualized tail content after the
// virtual transcript (streaming answer / thinking / tools). A clamp based
// only on virtual history would cap rendering before that tail and make
// live thinking appear to vanish. No burst-scroll clamp is needed while
// sticky anyway.
s?.setClampBounds(undefined, undefined)
}