diff --git a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx index 7805b4f902..64c181a031 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx @@ -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 { ) } 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 { } 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) { diff --git a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx index ed4239cef0..38f04b4faa 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx @@ -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 diff --git a/ui-tui/src/__tests__/interactionMode.test.ts b/ui-tui/src/__tests__/interactionMode.test.ts new file mode 100644 index 0000000000..1a44519ddb --- /dev/null +++ b/ui-tui/src/__tests__/interactionMode.test.ts @@ -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') + }) +}) diff --git a/ui-tui/src/__tests__/scroll.test.ts b/ui-tui/src/__tests__/scroll.test.ts new file mode 100644 index 0000000000..22f5d3f125 --- /dev/null +++ b/ui-tui/src/__tests__/scroll.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest' + +import { scrollWithSelectionBy } from '../app/scroll.js' + +function makeScroll(overrides: Partial> = {}) { + 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() + }) +}) diff --git a/ui-tui/src/__tests__/virtualHistoryClamp.test.ts b/ui-tui/src/__tests__/virtualHistoryClamp.test.ts new file mode 100644 index 0000000000..255fad7cb9 --- /dev/null +++ b/ui-tui/src/__tests__/virtualHistoryClamp.test.ts @@ -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) + }) +}) diff --git a/ui-tui/src/app/interactionMode.ts b/ui-tui/src/app/interactionMode.ts new file mode 100644 index 0000000000..f18033f81c --- /dev/null +++ b/ui-tui/src/app/interactionMode.ts @@ -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 + +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' +} diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 9049c17f9a..032eee87ab 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -31,8 +31,12 @@ export interface StateSetter { 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 { diff --git a/ui-tui/src/app/scroll.ts b/ui-tui/src/app/scroll.ts new file mode 100644 index 0000000000..2572e2808f --- /dev/null +++ b/ui-tui/src/app/scroll.ts @@ -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) +} diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 90c4ac12bf..bc40deba2c 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -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() { diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 7d87be1125..4d6dfc1957 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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) diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 9bca65815d..8e5f15c1f9 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -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 | 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]) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 35f1949b4b..9b916c4623 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -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) { diff --git a/ui-tui/src/config/limits.ts b/ui-tui/src/config/limits.ts index aa1090396b..875b6bacca 100644 --- a/ui-tui/src/config/limits.ts +++ b/ui-tui/src/config/limits.ts @@ -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 diff --git a/ui-tui/src/config/timing.ts b/ui-tui/src/config/timing.ts index 8fdf6b5fc5..083fa17f7f 100644 --- a/ui-tui/src/config/timing.ts +++ b/ui-tui/src/config/timing.ts @@ -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 diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index 388b5e5a48..e8565e8cb0 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -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) }