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