fix(tui): stabilize live progress rendering

This commit is contained in:
Brooklyn Nicholson 2026-04-26 15:23:43 -05:00
parent d4dde6b5f2
commit a7831b63db
28 changed files with 619 additions and 154 deletions

View file

@ -38,6 +38,7 @@ export type ScrollBoxHandle = {
* padding). Used for drag-to-scroll edge detection.
*/
getViewportTop: () => number
getLastManualScrollAt: () => number
/**
* True when scroll is pinned to the bottom. Set by scrollToBottom, the
* initial stickyScroll attribute, and by the renderer when positional
@ -94,6 +95,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
// forces a React render: sticky is attribute-observed, no DOM-only path.
const [, forceRender] = useState(0)
const listenersRef = useRef(new Set<() => void>())
const manualScrollAtRef = useRef(0)
const renderQueuedRef = useRef(false)
const notify = () => {
@ -130,6 +132,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
}
el.stickyScroll = false
manualScrollAtRef.current = Date.now()
el.scrollAnchor = undefined
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy)
scrollMutated(el)
@ -148,6 +151,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
// Explicit false overrides the DOM attribute so manual scroll
// breaks stickiness. Render code checks ?? precedence.
el.stickyScroll = false
manualScrollAtRef.current = Date.now()
el.pendingScrollDelta = undefined
el.scrollAnchor = undefined
el.scrollTop = Math.max(0, Math.floor(y))
@ -161,6 +165,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
}
box.stickyScroll = false
manualScrollAtRef.current = Date.now()
box.pendingScrollDelta = undefined
box.scrollAnchor = {
el,
@ -205,6 +210,9 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
getViewportTop() {
return domRef.current?.scrollViewportTop ?? 0
},
getLastManualScrollAt() {
return manualScrollAtRef.current
},
isSticky() {
const el = domRef.current

View file

@ -120,11 +120,7 @@ function parseKey(keypress: ParsedKey): [Key, string] {
// through key.return/key.escape, and processedAsSpecialSequence bypasses
// the nonAlphanumericKeys clear below, so clear them explicitly here.
input =
keypress.name === 'space'
? ' '
: keypress.name === 'return' || keypress.name === 'escape'
? ''
: keypress.name
keypress.name === 'space' ? ' ' : keypress.name === 'return' || keypress.name === 'escape' ? '' : keypress.name
}
processedAsSpecialSequence = true
@ -143,11 +139,7 @@ function parseKey(keypress: ParsedKey): [Key, string] {
input = ''
} else {
input =
keypress.name === 'space'
? ' '
: keypress.name === 'return' || keypress.name === 'escape'
? ''
: keypress.name
keypress.name === 'space' ? ' ' : keypress.name === 'return' || keypress.name === 'escape' ? '' : keypress.name
}
processedAsSpecialSequence = true

View file

@ -1328,7 +1328,9 @@ export default class Ink {
}
if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {
console.error('[clipboard] no path reached the clipboard (headless + no tmux?) — set HERMES_TUI_FORCE_OSC52=1 to force the escape sequence')
console.error(
'[clipboard] no path reached the clipboard (headless + no tmux?) — set HERMES_TUI_FORCE_OSC52=1 to force the escape sequence'
)
}
} catch (err) {
if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {
@ -1799,6 +1801,7 @@ export default class Ink {
if (this.selectionDragCell?.col === col && this.selectionDragCell.row === row) {
this.updateSelectionAutoScroll(row)
return
}
@ -1822,6 +1825,7 @@ export default class Ink {
private updateSelectionAutoScroll(row: number): void {
if (!this.selection.isDragging || !this.altScreenActive) {
this.stopSelectionAutoScroll()
return
}
@ -1829,6 +1833,7 @@ export default class Ink {
if (dir === 0) {
this.stopSelectionAutoScroll()
return
}
@ -1844,6 +1849,7 @@ export default class Ink {
private stepSelectionAutoScroll(): void {
if (!this.selection.isDragging || !this.altScreenActive || this.selectionAutoScrollDir === 0) {
this.stopSelectionAutoScroll()
return
}
@ -1851,6 +1857,7 @@ export default class Ink {
if (!box) {
this.stopSelectionAutoScroll()
return
}
@ -1889,7 +1896,10 @@ export default class Ink {
}
}
this.applySelectionDrag(this.selectionDragCell?.col ?? 0, this.selectionDragCell?.row ?? (this.selectionAutoScrollDir > 0 ? bottom : top))
this.applySelectionDrag(
this.selectionDragCell?.col ?? 0,
this.selectionDragCell?.row ?? (this.selectionAutoScrollDir > 0 ? bottom : top)
)
}
private stopSelectionAutoScroll(): void {
@ -1908,7 +1918,11 @@ export default class Ink {
while (stack.length) {
const node = stack.shift()!
if (node.style.overflowY === 'scroll' && node.scrollHeight !== undefined && node.scrollViewportHeight !== undefined) {
if (
node.style.overflowY === 'scroll' &&
node.scrollHeight !== undefined &&
node.scrollViewportHeight !== undefined
) {
return node
}

View file

@ -87,7 +87,8 @@ export function shouldEmitClipboardSequence(env: NodeJS.ProcessEnv = process.env
const override = (
env.HERMES_TUI_FORCE_OSC52 ??
env.HERMES_TUI_CLIPBOARD_OSC52 ??
env.HERMES_TUI_COPY_OSC52 ?? ''
env.HERMES_TUI_COPY_OSC52 ??
''
).trim()
if (ENV_ON_RE.test(override)) {
@ -196,16 +197,19 @@ export async function setClipboard(text: string): Promise<ClipboardResult> {
// forever but SSH_CONNECTION is in tmux's default update-environment and
// clears on local attach. Fire-and-forget, but `copyNativeAttempted`
// tells us whether ANY native path will be tried on this platform.
const nativeAttempted =
!process.env['SSH_CONNECTION'] && copyNative(text)
const nativeAttempted = !process.env['SSH_CONNECTION'] && copyNative(text)
const tmuxBufferLoaded = await tmuxLoadBuffer(text)
// Inner OSC uses BEL directly (not osc()) — ST's ESC would need doubling
// too, and BEL works everywhere for OSC 52.
const sequence = tmuxBufferLoaded
? (emitSequence ? tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`) : '')
: (emitSequence ? raw : '')
? emitSequence
? tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`)
: ''
: emitSequence
? raw
: ''
// Success if any path was taken. Native and tmux are fire-and-forget,
// so we can't truly confirm the clipboard was written — but if native