fix(tui): keep streaming progress stable during interaction
This commit is contained in:
@@ -29,7 +29,7 @@ import {
|
|||||||
FOCUS_IN,
|
FOCUS_IN,
|
||||||
FOCUS_OUT
|
FOCUS_OUT
|
||||||
} from '../termio/csi.js'
|
} 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 AppContext from './AppContext.js'
|
||||||
import { ClockProvider } from './ClockContext.js'
|
import { ClockProvider } from './ClockContext.js'
|
||||||
@@ -206,10 +206,9 @@ export default class App extends PureComponent<Props, State> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
override componentDidMount() {
|
override componentDidMount() {
|
||||||
// In accessibility mode, keep the native cursor visible for screen magnifiers and other tools
|
// Keep the native terminal cursor visible. Ink parks it at the declared
|
||||||
if (this.props.stdout.isTTY) {
|
// input caret after each frame, so the terminal emulator provides the
|
||||||
this.props.stdout.write(HIDE_CURSOR)
|
// normal blinking block/bar without React-driven blink re-renders.
|
||||||
}
|
|
||||||
}
|
}
|
||||||
override componentWillUnmount() {
|
override componentWillUnmount() {
|
||||||
if (this.props.stdout.isTTY) {
|
if (this.props.stdout.isTTY) {
|
||||||
@@ -470,7 +469,7 @@ export default class App extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.props.stdout.isTTY) {
|
if (this.props.stdout.isTTY) {
|
||||||
this.props.stdout.write(HIDE_CURSOR + EFE)
|
this.props.stdout.write(EFE)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.inputEmitter.emit('resume')
|
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. */
|
/** Exported for testing. Mutates app.props.selection and click/hover state. */
|
||||||
export function handleMouseEvent(app: App, m: ParsedMouse): void {
|
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
|
const sel = app.props.selection
|
||||||
// Terminal coords are 1-indexed; screen buffer is 0-indexed
|
// Terminal coords are 1-indexed; screen buffer is 0-indexed
|
||||||
const col = m.col - 1
|
const col = m.col - 1
|
||||||
const row = m.row - 1
|
const row = m.row - 1
|
||||||
const baseButton = m.button & 0x03
|
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.action === 'press') {
|
||||||
if ((m.button & 0x20) !== 0 && baseButton === 3) {
|
if ((m.button & 0x20) !== 0 && baseButton === 3) {
|
||||||
if (app.mouseCaptureTarget) {
|
if (app.mouseCaptureTarget) {
|
||||||
|
|||||||
@@ -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(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
(): ScrollBoxHandle => ({
|
(): ScrollBoxHandle => ({
|
||||||
@@ -155,22 +168,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
|
|||||||
}
|
}
|
||||||
scrollMutated(box)
|
scrollMutated(box)
|
||||||
},
|
},
|
||||||
scrollBy(dy: number) {
|
scrollBy: scrollByNow,
|
||||||
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)
|
|
||||||
},
|
|
||||||
scrollToBottom() {
|
scrollToBottom() {
|
||||||
const el = domRef.current
|
const el = domRef.current
|
||||||
|
|
||||||
|
|||||||
28
ui-tui/src/__tests__/interactionMode.test.ts
Normal file
28
ui-tui/src/__tests__/interactionMode.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
53
ui-tui/src/__tests__/scroll.test.ts
Normal file
53
ui-tui/src/__tests__/scroll.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
13
ui-tui/src/__tests__/virtualHistoryClamp.test.ts
Normal file
13
ui-tui/src/__tests__/virtualHistoryClamp.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
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 type StatusBarMode = 'bottom' | 'off' | 'top'
|
||||||
|
|
||||||
export interface SelectionApi {
|
export interface SelectionApi {
|
||||||
|
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
|
||||||
clearSelection: () => void
|
clearSelection: () => void
|
||||||
copySelection: () => string
|
copySelection: () => string
|
||||||
|
getState: () => unknown
|
||||||
|
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
||||||
|
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompletionItem {
|
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 type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js'
|
||||||
import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
|
import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +16,7 @@ import {
|
|||||||
} from '../lib/text.js'
|
} from '../lib/text.js'
|
||||||
import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js'
|
import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js'
|
||||||
|
|
||||||
|
import { getInteractionMode } from './interactionMode.js'
|
||||||
import { resetFlowOverlays } from './overlayStore.js'
|
import { resetFlowOverlays } from './overlayStore.js'
|
||||||
import { pushSnapshot } from './spawnHistoryStore.js'
|
import { pushSnapshot } from './spawnHistoryStore.js'
|
||||||
import { getTurnState, patchTurnState, resetTurnState } from './turnStore.js'
|
import { getTurnState, patchTurnState, resetTurnState } from './turnStore.js'
|
||||||
@@ -497,12 +504,15 @@ class TurnController {
|
|||||||
return
|
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 = setTimeout(() => {
|
||||||
this.streamTimer = null
|
this.streamTimer = null
|
||||||
const raw = this.bufRef.trimStart()
|
const raw = this.bufRef.trimStart()
|
||||||
const visible = hasReasoningTag(raw) ? splitReasoning(raw).text : raw
|
const visible = hasReasoningTag(raw) ? splitReasoning(raw).text : raw
|
||||||
patchTurnState({ streaming: visible })
|
patchTurnState({ streaming: visible })
|
||||||
}, this.streamDelay)
|
}, delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
startMessage() {
|
startMessage() {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { useComposerState } from './useComposerState.js'
|
|||||||
import { useConfigSync } from './useConfigSync.js'
|
import { useConfigSync } from './useConfigSync.js'
|
||||||
import { useInputHandlers } from './useInputHandlers.js'
|
import { useInputHandlers } from './useInputHandlers.js'
|
||||||
import { useLongRunToolCharms } from './useLongRunToolCharms.js'
|
import { useLongRunToolCharms } from './useLongRunToolCharms.js'
|
||||||
|
import { scrollWithSelectionBy } from './scroll.js'
|
||||||
import { useSessionLifecycle } from './useSessionLifecycle.js'
|
import { useSessionLifecycle } from './useSessionLifecycle.js'
|
||||||
import { useSubmission } from './useSubmission.js'
|
import { useSubmission } from './useSubmission.js'
|
||||||
|
|
||||||
@@ -64,12 +65,6 @@ const statusColorOf = (status: string, t: { dim: string; error: string; ok: stri
|
|||||||
return t.dim
|
return t.dim
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectionSnap {
|
|
||||||
anchor?: { row: number }
|
|
||||||
focus?: { row: number }
|
|
||||||
isDragging?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMainApp(gw: GatewayClient) {
|
export function useMainApp(gw: GatewayClient) {
|
||||||
const { exit } = useApp()
|
const { exit } = useApp()
|
||||||
const { stdout } = useStdout()
|
const { stdout } = useStdout()
|
||||||
@@ -186,46 +181,7 @@ export function useMainApp(gw: GatewayClient) {
|
|||||||
const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols)
|
const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols)
|
||||||
|
|
||||||
const scrollWithSelection = useCallback(
|
const scrollWithSelection = useCallback(
|
||||||
(delta: number) => {
|
(delta: number) => scrollWithSelectionBy(delta, { scrollRef, selection }),
|
||||||
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)
|
|
||||||
},
|
|
||||||
[selection]
|
[selection]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -700,14 +656,12 @@ export function useMainApp(gw: GatewayClient) {
|
|||||||
[turn, showProgressArea]
|
[turn, showProgressArea]
|
||||||
)
|
)
|
||||||
|
|
||||||
const frozenProgressRef = useRef(liveProgress)
|
// Always pass current progress through. Freezing this while offscreen looked
|
||||||
|
// like a nice scroll optimization, but it also froze the live tail's
|
||||||
// Freeze the offscreen live tail so scroll doesn't rebuild unseen streaming UI.
|
// thinking/tool state at arbitrary intermediate snapshots. Streaming update
|
||||||
if (liveTailVisible || !ui.busy) {
|
// throttling now handles interaction load; progress state should remain
|
||||||
frozenProgressRef.current = liveProgress
|
// truthful so panels don't randomly disappear.
|
||||||
}
|
const appProgress = liveProgress
|
||||||
|
|
||||||
const appProgress = liveTailVisible || !ui.busy ? liveProgress : frozenProgressRef.current
|
|
||||||
|
|
||||||
const cwd = ui.info?.cwd || process.env.HERMES_CWD || process.cwd()
|
const cwd = ui.info?.cwd || process.env.HERMES_CWD || process.cwd()
|
||||||
const gitBranch = useGitBranch(cwd)
|
const gitBranch = useGitBranch(cwd)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
import { TYPING_IDLE_MS } from '../config/timing.js'
|
|
||||||
import { attachedImageNotice } from '../domain/messages.js'
|
import { attachedImageNotice } from '../domain/messages.js'
|
||||||
import { looksLikeSlashCommand } from '../domain/slash.js'
|
import { looksLikeSlashCommand } from '../domain/slash.js'
|
||||||
import type { GatewayClient } from '../gatewayClient.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 { Msg } from '../types.js'
|
||||||
|
|
||||||
import type { ComposerActions, ComposerRefs, ComposerState, PasteSnippet } from './interfaces.js'
|
import type { ComposerActions, ComposerRefs, ComposerState, PasteSnippet } from './interfaces.js'
|
||||||
|
import { markTyping } from './interactionMode.js'
|
||||||
import { turnController } from './turnController.js'
|
import { turnController } from './turnController.js'
|
||||||
import { getUiState, patchUiState } from './uiStore.js'
|
import { getUiState, patchUiState } from './uiStore.js'
|
||||||
|
|
||||||
@@ -48,28 +48,13 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
|||||||
} = opts
|
} = opts
|
||||||
|
|
||||||
const lastEmptyAt = useRef(0)
|
const lastEmptyAt = useRef(0)
|
||||||
const typingIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (composerState.input || composerState.inputBuf.length) {
|
if (composerState.input || composerState.inputBuf.length) {
|
||||||
|
markTyping()
|
||||||
if (getUiState().busy) {
|
if (getUiState().busy) {
|
||||||
turnController.boostStreamingForTyping()
|
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])
|
}, [composerState.input, composerState.inputBuf])
|
||||||
|
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ export function TextInput({
|
|||||||
active: focus && termFocus && !selected
|
active: focus && termFocus && !selected
|
||||||
})
|
})
|
||||||
|
|
||||||
const nativeCursor = focus && termFocus && !selected
|
const nativeCursor = focus && termFocus && !selected && !!stdout?.isTTY
|
||||||
|
|
||||||
const rendered = useMemo(() => {
|
const rendered = useMemo(() => {
|
||||||
if (!focus) {
|
if (!focus) {
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ export const LARGE_PASTE = { chars: 8000, lines: 80 }
|
|||||||
export const LONG_MSG = 300
|
export const LONG_MSG = 300
|
||||||
export const MAX_HISTORY = 800
|
export const MAX_HISTORY = 800
|
||||||
export const THINKING_COT_MAX = 160
|
export const THINKING_COT_MAX = 160
|
||||||
export const WHEEL_SCROLL_STEP = 3
|
export const WHEEL_SCROLL_STEP = 6
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export const STREAM_BATCH_MS = 16
|
export const STREAM_BATCH_MS = 16
|
||||||
export const STREAM_IDLE_BATCH_MS = 16
|
export const STREAM_IDLE_BATCH_MS = 16
|
||||||
export const STREAM_TYPING_BATCH_MS = 80
|
export const STREAM_SCROLLING_BATCH_MS = 250
|
||||||
export const TYPING_IDLE_MS = 120
|
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
|
export const REASONING_PULSE_MS = 700
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ const COLD_START = 40
|
|||||||
const QUANTUM = OVERSCAN >> 1
|
const QUANTUM = OVERSCAN >> 1
|
||||||
const FREEZE_RENDERS = 2
|
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) => {
|
const upperBound = (arr: number[], target: number) => {
|
||||||
let lo = 0
|
let lo = 0
|
||||||
let hi = arr.length
|
let hi = arr.length
|
||||||
@@ -173,11 +183,16 @@ export function useVirtualHistory(
|
|||||||
// Give the renderer the mounted-row coverage for passive scroll clamping.
|
// Give the renderer the mounted-row coverage for passive scroll clamping.
|
||||||
// Without this, burst wheel/page scroll can race past the React commit that
|
// Without this, burst wheel/page scroll can race past the React commit that
|
||||||
// updates the virtual range and paint spacer-only frames.
|
// 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 min = offsets[start] ?? 0
|
||||||
const max = Math.max(min, (offsets[end] ?? total) - vp)
|
const max = Math.max(min, (offsets[end] ?? total) - vp)
|
||||||
s.setClampBounds(min, max)
|
s.setClampBounds(min, max)
|
||||||
} else {
|
} 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)
|
s?.setClampBounds(undefined, undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user