Restore terminal scrollbar with an Orca-owned overlay

xterm's own scrollbar stays disabled (width 0): a non-zero width makes
FitAddon reserve a gutter that underfits wide table/TUI output by a
column (#4877), and its render pipeline destabilizes deferred scroll
restore after workspace switches. Instead, draw a slim overlay thumb
from public xterm scroll state that is completely inert while hidden
and reveals only on wheel/hover/drag.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-06-09 15:11:20 -07:00
co-authored by Orca
parent 7ed829ee67
commit cc89f2c22f
7 changed files with 440 additions and 6 deletions
+36 -3
View File
@@ -42,10 +42,43 @@
background-color: transparent;
}
/* Round the xterm DOM scrollbar thumb; xterm has no option for this, and its
default .xterm-slider has square corners. */
.xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider {
/* Orca overlay scrollbar — xterm's own scrollbar stays disabled (it reserves
a FitAddon gutter and destabilizes restore scroll sync; see
pane-overlay-scrollbar.ts). The track is a 10px hover/hit strip that only
accepts pointer input while visible so the terminal's right edge never
dead-zones when the bar is faded out. */
.pane-overlay-scrollbar {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 10px;
z-index: 9; /* above terminal content, below drag handle (z:10) */
opacity: 0;
pointer-events: none;
transition: opacity 150ms ease;
}
.pane-overlay-scrollbar.is-visible {
opacity: 1;
pointer-events: auto;
}
.pane-overlay-scrollbar-thumb {
position: absolute;
top: 0;
right: 1px;
width: 7px;
border-radius: 4px;
background: rgba(180, 180, 185, 0.4);
}
.pane-overlay-scrollbar-thumb:hover {
background: rgba(180, 180, 185, 0.6);
}
.pane-overlay-scrollbar.is-dragging .pane-overlay-scrollbar-thumb {
background: rgba(180, 180, 185, 0.8);
}
/* xterm.css sets position:absolute;top:0 on .xterm-helpers but omits left,
@@ -76,7 +76,10 @@ describe('buildDefaultTerminalOptions', () => {
expect(buildDefaultTerminalOptions().cursorInactiveStyle).toBe('outline')
})
it('does not reserve a classic scrollbar gutter when fitting terminal columns', () => {
it('keeps the xterm scrollbar disabled in favor of the Orca overlay scrollbar', () => {
// Why: a non-zero width makes FitAddon reserve a column-eating gutter
// (#4877) and destabilizes restore scroll sync; the visible scrollbar is
// Orca's own overlay (pane-overlay-scrollbar.ts).
expect(buildDefaultTerminalOptions().scrollbar?.width).toBe(0)
})
@@ -26,6 +26,7 @@ import { clearPendingSplitScrollRestore } from './pane-split-scroll'
import { buildDefaultTerminalOptions } from './pane-terminal-options'
import { activateOrcaTerminalUnicodeProvider } from './pane-terminal-unicode-provider'
import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync'
import { attachPaneOverlayScrollbar } from './pane-overlay-scrollbar'
import {
ENABLE_WEBGL_RENDERER,
attachWebgl,
@@ -151,6 +152,7 @@ export function createPaneDOM(
paneDragCleanup,
compositionHandler: null,
focusClassSyncCleanup: null,
overlayScrollbarCleanup: null,
pendingSplitScrollState: null,
pendingSplitScrollRafIds: [],
pendingSplitScrollTimerId: null,
@@ -244,6 +246,7 @@ export function openTerminal(pane: ManagedPaneInternal): void {
}
pane.focusClassSyncCleanup = attachDomRendererFocusClassSync(terminal.element)
pane.overlayScrollbarCleanup = attachPaneOverlayScrollbar(terminal, xtermContainer)
if (pane.gpuRenderingEnabled) {
attachWebgl(pane)
@@ -334,6 +337,8 @@ export function disposePane(
pane.paneDragCleanup = null
pane.focusClassSyncCleanup?.()
pane.focusClassSyncCleanup = null
pane.overlayScrollbarCleanup?.()
pane.overlayScrollbarCleanup = null
if (pane.compositionHandler) {
pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler, true)
pane.compositionHandler = null
@@ -133,6 +133,8 @@ export type ManagedPaneInternal = {
compositionHandler: (() => void) | null
// Stored so disposePane() can remove DOM-renderer focus synchronization.
focusClassSyncCleanup?: (() => void) | null
// Stored so disposePane() can tear down the Orca overlay scrollbar.
overlayScrollbarCleanup?: (() => void) | null
// Why: splitPane reparents DOM; its delayed restore owns scroll until the
// browser settles, so intermediate fits must not compete with it.
pendingSplitScrollState: ScrollState | null
@@ -0,0 +1,139 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import type { Terminal } from '@xterm/xterm'
import { attachPaneOverlayScrollbar } from './pane-overlay-scrollbar'
type ScrollListener = () => void
function createFakeTerminal({
baseY = 100,
viewportY = 100
}: { baseY?: number; viewportY?: number } = {}): {
terminal: Terminal
emitScroll: () => void
scrollLines: ReturnType<typeof vi.fn>
scrollToLine: ReturnType<typeof vi.fn>
buffer: { baseY: number; viewportY: number; type: string }
} {
const scrollListeners: ScrollListener[] = []
const buffer = { baseY, viewportY, type: 'normal' }
const scrollLines = vi.fn((delta: number) => {
buffer.viewportY = Math.max(0, Math.min(buffer.baseY, buffer.viewportY + delta))
})
const scrollToLine = vi.fn((line: number) => {
buffer.viewportY = Math.max(0, Math.min(buffer.baseY, line))
})
const subscribe = (listeners: ScrollListener[]) => (listener: ScrollListener) => {
listeners.push(listener)
return { dispose: () => listeners.splice(listeners.indexOf(listener), 1) }
}
const terminal = {
rows: 24,
buffer: { active: buffer },
onScroll: subscribe(scrollListeners),
onWriteParsed: subscribe([]),
onResize: subscribe([]),
scrollLines,
scrollToLine
} as unknown as Terminal
return {
terminal,
emitScroll: () => scrollListeners.forEach((listener) => listener()),
scrollLines,
scrollToLine,
buffer
}
}
function stubHeights(host: HTMLElement): { track: HTMLElement; thumb: HTMLElement } {
const track = host.querySelector<HTMLElement>('.pane-overlay-scrollbar')!
const thumb = host.querySelector<HTMLElement>('.pane-overlay-scrollbar-thumb')!
Object.defineProperty(track, 'clientHeight', { value: 400, configurable: true })
Object.defineProperty(thumb, 'clientHeight', { value: 80, configurable: true })
track.getBoundingClientRect = () =>
({ top: 0, height: 400, left: 0, width: 10, right: 10, bottom: 400, x: 0, y: 0 }) as DOMRect
return { track, thumb }
}
describe('attachPaneOverlayScrollbar', () => {
it('mounts the track in the host and removes it on cleanup', () => {
const host = document.createElement('div')
const { terminal } = createFakeTerminal()
const cleanup = attachPaneOverlayScrollbar(terminal, host)
expect(host.querySelector('.pane-overlay-scrollbar-thumb')).not.toBeNull()
cleanup()
expect(host.querySelector('.pane-overlay-scrollbar')).toBeNull()
})
it('reveals on wheel input and fades after the idle delay', () => {
vi.useFakeTimers()
try {
const host = document.createElement('div')
const { terminal } = createFakeTerminal()
const cleanup = attachPaneOverlayScrollbar(terminal, host)
const { track } = stubHeights(host)
host.dispatchEvent(new WheelEvent('wheel', { deltaY: -120 }))
expect(track.classList.contains('is-visible')).toBe(true)
vi.advanceTimersByTime(1500)
expect(track.classList.contains('is-visible')).toBe(false)
cleanup()
} finally {
vi.useRealTimers()
}
})
it('stays inert on programmatic scroll events while hidden', () => {
// Why: workspace-switch restores fire scroll storms; the hidden overlay
// must not add per-event work or reveal itself during them.
const host = document.createElement('div')
const { terminal, emitScroll } = createFakeTerminal()
const cleanup = attachPaneOverlayScrollbar(terminal, host)
const { track } = stubHeights(host)
emitScroll()
expect(track.classList.contains('is-visible')).toBe(false)
cleanup()
})
it('does not reveal when there is no scrollback', () => {
const host = document.createElement('div')
const { terminal } = createFakeTerminal({ baseY: 0, viewportY: 0 })
const cleanup = attachPaneOverlayScrollbar(terminal, host)
const { track } = stubHeights(host)
host.dispatchEvent(new WheelEvent('wheel', { deltaY: -120 }))
expect(track.classList.contains('is-visible')).toBe(false)
cleanup()
})
it('scrolls the terminal proportionally while dragging the thumb', () => {
const host = document.createElement('div')
const { terminal, scrollLines, buffer } = createFakeTerminal({ baseY: 100, viewportY: 0 })
const cleanup = attachPaneOverlayScrollbar(terminal, host)
const { thumb } = stubHeights(host)
thumb.dispatchEvent(new PointerEvent('pointerdown', { button: 0, clientY: 0, bubbles: true }))
// 320px of scrollable track maps to 100 lines of scrollback; 160px = 50 lines.
thumb.dispatchEvent(new PointerEvent('pointermove', { clientY: 160, bubbles: true }))
expect(scrollLines).toHaveBeenCalledWith(50)
expect(buffer.viewportY).toBe(50)
cleanup()
})
it('jump-scrolls when clicking the track outside the thumb', () => {
const host = document.createElement('div')
const { terminal, scrollToLine } = createFakeTerminal({ baseY: 100, viewportY: 100 })
const cleanup = attachPaneOverlayScrollbar(terminal, host)
const { track } = stubHeights(host)
track.dispatchEvent(new PointerEvent('pointerdown', { button: 0, clientY: 200, bubbles: true }))
// Click at the track midpoint: half of 124 total lines, centered on the viewport.
expect(scrollToLine).toHaveBeenCalledWith(50)
cleanup()
})
})
@@ -0,0 +1,248 @@
import type { Terminal } from '@xterm/xterm'
// Why: xterm's built-in scrollbar stays disabled (see pane-terminal-options.ts) —
// it makes FitAddon reserve a column-eating gutter and destabilizes scroll
// restore after workspace switches. This overlay only *reads* xterm scroll
// state and renders its own thumb, so it cannot feed back into xterm's
// viewport sync; dragging goes through the public scrollLines/scrollToLine API
// like any user scroll.
const HIDE_DELAY_MS = 1200
const MIN_THUMB_HEIGHT_PX = 20
export function attachPaneOverlayScrollbar(terminal: Terminal, host: HTMLElement): () => void {
if (typeof document === 'undefined') {
return () => {}
}
const track = document.createElement('div')
track.className = 'pane-overlay-scrollbar'
const thumb = document.createElement('div')
thumb.className = 'pane-overlay-scrollbar-thumb'
track.appendChild(thumb)
host.appendChild(track)
let rafId: number | null = null
let hideTimer: ReturnType<typeof setTimeout> | null = null
let hovering = false
let dragging = false
let disposed = false
// Why: reading clientHeight forces synchronous layout, and the thumb relays
// out on every scroll event during PTY output storms. Cache the height and
// let the observer refresh it so scroll-driven updates stay layout-free.
let trackHeight = track.clientHeight
const trackResizeObserver =
typeof ResizeObserver === 'function'
? new ResizeObserver(() => {
trackHeight = track.clientHeight
scheduleLayout()
})
: null
trackResizeObserver?.observe(track)
function totalLines(): number {
return terminal.buffer.active.baseY + terminal.rows
}
function hasScrollback(): boolean {
return terminal.buffer.active.type === 'normal' && terminal.buffer.active.baseY > 0
}
function layoutThumb(): void {
if (!hasScrollback() || trackHeight <= 0) {
track.classList.remove('is-visible')
return
}
const total = totalLines()
const thumbHeight = Math.max(
MIN_THUMB_HEIGHT_PX,
Math.round((terminal.rows / total) * trackHeight)
)
const maxTop = trackHeight - thumbHeight
const scrollFrac = terminal.buffer.active.viewportY / terminal.buffer.active.baseY
thumb.style.height = `${thumbHeight}px`
thumb.style.transform = `translateY(${Math.round(maxTop * scrollFrac)}px)`
}
function scheduleLayout(): void {
if (rafId !== null) {
return
}
rafId = requestAnimationFrame(() => {
rafId = null
layoutThumb()
})
}
function scheduleHide(): void {
if (hideTimer !== null) {
clearTimeout(hideTimer)
}
hideTimer = setTimeout(() => {
hideTimer = null
if (!hovering && !dragging) {
track.classList.remove('is-visible')
}
}, HIDE_DELAY_MS)
}
function isVisible(): boolean {
return track.classList.contains('is-visible')
}
function reveal(): void {
if (disposed || !hasScrollback()) {
return
}
if (!isVisible()) {
// One layout read per reveal, not per scroll event while visible.
trackHeight = track.clientHeight
track.classList.add('is-visible')
}
scheduleLayout()
scheduleHide()
}
// Why: while hidden the overlay must be completely inert. Workspace-switch
// restores fire scroll/write storms, and any per-event work in that window
// can starve xterm's deferred scroll-restore (the instability that got the
// previous xterm scrollbar removed). Reveal only on direct user input.
const onScroll = terminal.onScroll(() => {
if (isVisible()) {
scheduleLayout()
scheduleHide()
}
})
// Output growth shifts the thumb even when the viewport line is unchanged.
const onWrite = terminal.onWriteParsed(() => {
if (isVisible()) {
scheduleLayout()
}
})
const onResize = terminal.onResize(() => {
if (isVisible()) {
scheduleLayout()
}
})
// Capture phase: xterm's scrollable element stops propagation of consumed
// wheel events, so a bubble listener on the host would never fire.
const onHostWheel = (): void => reveal()
function lineAtTrackDelta(deltaPx: number, startLine: number): number {
const scrollableHeight = track.clientHeight - thumb.clientHeight
if (scrollableHeight <= 0) {
return startLine
}
const lineDelta = Math.round((deltaPx / scrollableHeight) * terminal.buffer.active.baseY)
return Math.max(0, Math.min(terminal.buffer.active.baseY, startLine + lineDelta))
}
let dragStartY = 0
let dragStartLine = 0
const onThumbPointerDown = (event: PointerEvent): void => {
if (event.button !== 0) {
return
}
event.preventDefault()
event.stopPropagation()
dragging = true
dragStartY = event.clientY
dragStartLine = terminal.buffer.active.viewportY
try {
thumb.setPointerCapture(event.pointerId)
} catch {
// Why: capture is best-effort; synthetic events carry no live pointerId.
}
track.classList.add('is-dragging')
}
const onThumbPointerMove = (event: PointerEvent): void => {
if (!dragging) {
return
}
const target = lineAtTrackDelta(event.clientY - dragStartY, dragStartLine)
terminal.scrollLines(target - terminal.buffer.active.viewportY)
}
const endThumbDrag = (): void => {
if (!dragging) {
return
}
dragging = false
track.classList.remove('is-dragging')
scheduleHide()
}
// Jump-scroll when clicking the track outside the thumb.
const onTrackPointerDown = (event: PointerEvent): void => {
if (event.button !== 0 || event.target === thumb || !hasScrollback()) {
return
}
event.preventDefault()
const rect = track.getBoundingClientRect()
const frac = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0
const target = Math.max(
0,
Math.min(terminal.buffer.active.baseY, Math.round(frac * totalLines() - terminal.rows / 2))
)
terminal.scrollToLine(target)
}
// Forward wheel input so the visible track doesn't dead-zone the right edge.
const onTrackWheel = (event: WheelEvent): void => {
event.preventDefault()
const cellHeight = host.clientHeight / Math.max(1, terminal.rows)
const lines = Math.round(event.deltaY / Math.max(1, cellHeight))
if (lines !== 0) {
terminal.scrollLines(lines)
}
}
const onTrackPointerEnter = (): void => {
hovering = true
reveal()
}
const onTrackPointerLeave = (): void => {
hovering = false
scheduleHide()
}
host.addEventListener('wheel', onHostWheel, { capture: true, passive: true })
thumb.addEventListener('pointerdown', onThumbPointerDown)
thumb.addEventListener('pointermove', onThumbPointerMove)
thumb.addEventListener('pointerup', endThumbDrag)
thumb.addEventListener('pointercancel', endThumbDrag)
track.addEventListener('pointerdown', onTrackPointerDown)
track.addEventListener('wheel', onTrackWheel, { passive: false })
track.addEventListener('pointerenter', onTrackPointerEnter)
track.addEventListener('pointerleave', onTrackPointerLeave)
return () => {
disposed = true
onScroll.dispose()
onWrite.dispose()
onResize.dispose()
trackResizeObserver?.disconnect()
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
if (hideTimer !== null) {
clearTimeout(hideTimer)
hideTimer = null
}
host.removeEventListener('wheel', onHostWheel, { capture: true })
thumb.removeEventListener('pointerdown', onThumbPointerDown)
thumb.removeEventListener('pointermove', onThumbPointerMove)
thumb.removeEventListener('pointerup', endThumbDrag)
thumb.removeEventListener('pointercancel', endThumbDrag)
track.removeEventListener('pointerdown', onTrackPointerDown)
track.removeEventListener('wheel', onTrackWheel)
track.removeEventListener('pointerenter', onTrackPointerEnter)
track.removeEventListener('pointerleave', onTrackPointerLeave)
track.remove()
}
}
@@ -32,8 +32,12 @@ export function buildDefaultTerminalOptions(): ITerminalOptions {
macOptionClickForcesSelection: true,
drawBoldTextInBrightColors: true,
scrollbar: {
// Why: xterm's DOM scrollbar overlays the terminal in Electron; reserving
// gutter width in FitAddon underfits wide table/TUI output by a column.
// Why: xterm's own scrollbar must stay disabled. A non-zero width makes
// FitAddon reserve a gutter (underfits wide table/TUI output by a
// column, #4877) and its render pipeline destabilizes scroll restore
// after workspace switches (terminal-long-table-scroll-restore e2e).
// Orca draws its own overlay scrollbar instead — see
// pane-overlay-scrollbar.ts.
width: 0
},
// Why: advertise kitty keyboard protocol support so CLIs that probe