mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
feat(tui): port claude-code's wheel accel state machine
Replaces the static WHEEL_SCROLL_STEP=1 multiplier on wheel events
with an adaptive accel state machine that infers user intent from
inter-event timing.
Algorithm ported straight from claude-code's
src/components/ScrollKeybindingHandler.tsx. All tuning constants,
the native/xterm.js path split, the encoder-bounce detection, the
trackpad-burst signature → all theirs. This file is a mechanical
port into our module structure.
What it does:
precision click (>500ms gap) 1 row/event (deliberate scan)
sustained mouse (40-200ms) 2-6 rows (decay curve)
detected wheel bounce ramps to 15 (sticky wheel-mode)
trackpad flick (5+ <5ms) 1 row/event (burst detect)
direction reversal reset to base
Two implementation paths:
* native terminals (ghostty, iTerm2, Kitty, WezTerm) — linear
window-ramp + optional wheel-mode curve triggered by detected
encoder bounce. SGR proportional reporting handled via the
burst-count guard.
* xterm.js (VS Code / Cursor / browser terminals) — pure
exponential-decay curve with fractional carry. Events arrive
1-per-notch with no pre-amplification, so the curve is more
aggressive.
Selected at construction via isXtermJs() from @hermes/ink (now
exported). Per-user tune via HERMES_TUI_SCROLL_SPEED (alias
CLAUDE_CODE_SCROLL_SPEED for portability).
13 unit tests covering direction flip/bounce/reversal, idle
disengage, trackpad-burst disengage, frac invariants, and the
native vs xterm.js branches.
Profiled under --rate 30 (stress test) and --rate 10 (realistic
sustained scroll): accel ramps to cap=6 at 30Hz burst, decays to
1-3 rows at sparse 10Hz clicks. Perf is comparable to baseline
because accel IS multiplying step — the win is perceptual (fast
flicks cover distance, slow clicks keep precision), not raw fps.
Companion to the earlier WHEEL_SCROLL_STEP=1 change: that set the
base; this modulates around it.
This commit is contained in:
@@ -20,6 +20,7 @@ export { useTabStatus } from './ink/hooks/use-tab-status.js'
|
||||
export { useTerminalFocus } from './ink/hooks/use-terminal-focus.js'
|
||||
export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
|
||||
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js'
|
||||
export { isXtermJs } from './ink/terminal.js'
|
||||
export { default as measureElement } from './ink/measure-element.js'
|
||||
export {
|
||||
resetScrollFastPathStats,
|
||||
|
||||
169
ui-tui/src/__tests__/wheelAccel.test.ts
Normal file
169
ui-tui/src/__tests__/wheelAccel.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeWheelStep, initWheelAccel } from '../lib/wheelAccel.js'
|
||||
|
||||
describe('wheelAccel — native path', () => {
|
||||
it('first click after init returns base', () => {
|
||||
const s = initWheelAccel(false, 1)
|
||||
|
||||
expect(computeWheelStep(s, 1, 1000)).toBe(1)
|
||||
})
|
||||
|
||||
it('same-direction fast events ramp mult (window-mode)', () => {
|
||||
const s = initWheelAccel(false, 1)
|
||||
|
||||
// First click establishes dir. Subsequent clicks inside the 40ms
|
||||
// window ramp by +0.3 each (capped at 6).
|
||||
computeWheelStep(s, 1, 1000)
|
||||
computeWheelStep(s, 1, 1020)
|
||||
computeWheelStep(s, 1, 1040)
|
||||
const fourth = computeWheelStep(s, 1, 1060)
|
||||
|
||||
// After 3 window events: mult starts at 1 → stays 1 on first ramp
|
||||
// (first event just sets baseline), then +0.3 × 3 = 1.9 → floor=1.
|
||||
// The key property: doesn't shrink below base.
|
||||
expect(fourth).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('gap beyond window resets mult to base', () => {
|
||||
const s = initWheelAccel(false, 1)
|
||||
|
||||
// Ramp up
|
||||
for (let t = 1000; t < 1100; t += 20) {
|
||||
computeWheelStep(s, 1, t)
|
||||
}
|
||||
|
||||
// Long pause, then click
|
||||
const afterPause = computeWheelStep(s, 1, 2000)
|
||||
|
||||
expect(afterPause).toBe(1)
|
||||
})
|
||||
|
||||
it('direction flip defers one event for bounce detection', () => {
|
||||
const s = initWheelAccel(false, 1)
|
||||
|
||||
computeWheelStep(s, 1, 1000)
|
||||
// Flip — should defer
|
||||
expect(computeWheelStep(s, -1, 1050)).toBe(0)
|
||||
})
|
||||
|
||||
it('flip-back within bounce window engages wheelMode', () => {
|
||||
const s = initWheelAccel(false, 1)
|
||||
|
||||
computeWheelStep(s, 1, 1000)
|
||||
// Flip (deferred)
|
||||
computeWheelStep(s, -1, 1050)
|
||||
// Flip BACK within 200ms → bounce confirmed → wheelMode engaged
|
||||
computeWheelStep(s, 1, 1100)
|
||||
|
||||
expect(s.wheelMode).toBe(true)
|
||||
})
|
||||
|
||||
it('flip-back outside bounce window is a real reversal (no wheelMode)', () => {
|
||||
const s = initWheelAccel(false, 1)
|
||||
|
||||
computeWheelStep(s, 1, 1000)
|
||||
computeWheelStep(s, -1, 1050) // defer
|
||||
// Flip-back arrives 300ms later → too late → real reversal
|
||||
computeWheelStep(s, 1, 1400)
|
||||
|
||||
expect(s.wheelMode).toBe(false)
|
||||
})
|
||||
|
||||
it('5 consecutive sub-5ms events disengage wheelMode (trackpad signature)', () => {
|
||||
const s = initWheelAccel(false, 1)
|
||||
s.wheelMode = true
|
||||
s.dir = 1
|
||||
s.time = 1000
|
||||
|
||||
// 5 bursts <5ms apart (trackpad flick)
|
||||
computeWheelStep(s, 1, 1002)
|
||||
computeWheelStep(s, 1, 1004)
|
||||
computeWheelStep(s, 1, 1006)
|
||||
computeWheelStep(s, 1, 1008)
|
||||
computeWheelStep(s, 1, 1010)
|
||||
|
||||
expect(s.wheelMode).toBe(false)
|
||||
})
|
||||
|
||||
it('1.5s idle disengages wheelMode', () => {
|
||||
const s = initWheelAccel(false, 1)
|
||||
s.wheelMode = true
|
||||
s.dir = 1
|
||||
s.time = 1000
|
||||
|
||||
computeWheelStep(s, 1, 3000) // 2 second gap
|
||||
|
||||
expect(s.wheelMode).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wheelAccel — xterm.js path', () => {
|
||||
it('first click returns 2 after long idle', () => {
|
||||
const s = initWheelAccel(true, 1)
|
||||
|
||||
// First event — "sameDir && gap > WHEEL_DECAY_IDLE_MS" triggers
|
||||
// reset-to-2 branch since dir starts at 0 and 0 !== 1.
|
||||
const n = computeWheelStep(s, 1, 1000)
|
||||
|
||||
expect(n).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('sub-5ms burst returns 1 (same-direction, same-batch)', () => {
|
||||
const s = initWheelAccel(true, 1)
|
||||
|
||||
computeWheelStep(s, 1, 1000)
|
||||
const burst = computeWheelStep(s, 1, 1002)
|
||||
|
||||
expect(burst).toBe(1)
|
||||
})
|
||||
|
||||
it('slow steady scroll stays in precision range', () => {
|
||||
const s = initWheelAccel(true, 1)
|
||||
|
||||
// Simulated 30Hz sustained scroll: 33ms gap
|
||||
const results: number[] = []
|
||||
|
||||
for (let t = 1000; t < 2000; t += 33) {
|
||||
results.push(computeWheelStep(s, 1, t))
|
||||
}
|
||||
|
||||
// Every event should produce 1-6 rows. No runaway.
|
||||
for (const r of results) {
|
||||
expect(r).toBeGreaterThanOrEqual(1)
|
||||
expect(r).toBeLessThanOrEqual(6)
|
||||
}
|
||||
})
|
||||
|
||||
it('direction reversal resets mult', () => {
|
||||
const s = initWheelAccel(true, 1)
|
||||
|
||||
// Ramp up
|
||||
for (let t = 1000; t < 1100; t += 20) {
|
||||
computeWheelStep(s, 1, t)
|
||||
}
|
||||
const beforeFlip = s.mult
|
||||
|
||||
// Flip
|
||||
computeWheelStep(s, -1, 1200)
|
||||
|
||||
expect(s.mult).toBeLessThanOrEqual(beforeFlip)
|
||||
// Reset branch sets mult=2
|
||||
expect(s.mult).toBe(2)
|
||||
})
|
||||
|
||||
it('frac stays in [0,1) across events', () => {
|
||||
const s = initWheelAccel(true, 1)
|
||||
|
||||
// frac must never go negative or reach 1.0 — that's the correctness
|
||||
// invariant of the fractional carry. Whether a specific series of
|
||||
// inputs produces a nonzero frac depends on tuning constants; just
|
||||
// check the bound is maintained across a realistic scroll pattern.
|
||||
for (let t = 1000; t < 1200; t += 30) {
|
||||
computeWheelStep(s, 1, t)
|
||||
|
||||
expect(s.frac).toBeGreaterThanOrEqual(0)
|
||||
expect(s.frac).toBeLessThan(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
VoiceRecordResponse
|
||||
} from '../gatewayTypes.js'
|
||||
import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js'
|
||||
import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js'
|
||||
|
||||
import { getInputSelection } from './inputSelectionStore.js'
|
||||
import type { InputHandlerContext, InputHandlerResult } from './interfaces.js'
|
||||
@@ -30,6 +31,15 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6)
|
||||
const scrollIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Wheel acceleration state machine (ported from claude-code). Adapts
|
||||
// step size per wheel event based on inter-event timing: fast flicks
|
||||
// ramp up, slow clicks stay at 1 row, direction flips reset. See
|
||||
// lib/wheelAccel.ts for the full tuning rationale. The accel state
|
||||
// mutates in place and is kept across renders via a ref. wheelStep
|
||||
// (passed from useMainApp / the WHEEL_SCROLL_STEP constant) is used
|
||||
// as the BASE — final rows = wheelStep × accelMult.
|
||||
const wheelAccelRef = useRef(initWheelAccelForHost())
|
||||
|
||||
const scrollTranscript = (delta: number) => {
|
||||
if (getUiState().busy) {
|
||||
turnController.boostStreamingForScroll()
|
||||
@@ -278,12 +288,18 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
return
|
||||
}
|
||||
|
||||
if (key.wheelUp) {
|
||||
return scrollTranscript(-wheelStep)
|
||||
}
|
||||
if (key.wheelUp || key.wheelDown) {
|
||||
const dir: -1 | 1 = key.wheelUp ? -1 : 1
|
||||
const accelRows = computeWheelStep(wheelAccelRef.current, dir, Date.now())
|
||||
|
||||
if (key.wheelDown) {
|
||||
return scrollTranscript(wheelStep)
|
||||
// computeWheelStep returns 0 when a direction flip is deferred for
|
||||
// bounce detection — scrollBy(0) is a no-op; skip the call to avoid
|
||||
// needless render scheduling.
|
||||
if (accelRows === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
return scrollTranscript(dir * accelRows * wheelStep)
|
||||
}
|
||||
|
||||
if (key.shift && key.upArrow) {
|
||||
|
||||
241
ui-tui/src/lib/wheelAccel.ts
Normal file
241
ui-tui/src/lib/wheelAccel.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
// Wheel-scroll acceleration state machine.
|
||||
//
|
||||
// Ported from claude-code's src/components/ScrollKeybindingHandler.tsx
|
||||
// (commit cb7cfba6 of their research snapshot at ~/claude-code). The
|
||||
// algorithm is theirs; the tuning constants below are theirs; this file
|
||||
// is a straight port adapted to our module structure.
|
||||
//
|
||||
// Problem: one wheel event = 1 scrolled row feels sluggish on trackpads
|
||||
// (which can fire 200+ events/sec) and during deliberate mouse scrolls.
|
||||
// One wheel event = 6 rows (our old WHEEL_SCROLL_STEP=6) visually
|
||||
// teleports and ruins precision. The right answer depends on intent:
|
||||
//
|
||||
// precision click → 1 row/event
|
||||
// sustained mouse → ramp to ~15 rows/event, decay when slowing down
|
||||
// trackpad flick → 1 row/event per burst event (they come 100+)
|
||||
//
|
||||
// Heuristic: watch inter-event gaps and direction flips:
|
||||
// * gap < 5ms → same-batch burst (SGR proportional reporting
|
||||
// or trackpad flick) → 1 row/event
|
||||
// * gap < 40ms, same → ramp mult by +0.3/event, cap at 6 (native path)
|
||||
// * gap < 80-500ms → exponential decay curve (xterm.js path)
|
||||
// mult = 1 + (mult-1)*0.5^(gap/150ms) + 5*decay
|
||||
// capped at 3 for gaps ≥ 80ms, 6 for < 80ms
|
||||
// * gap > 500ms → reset to 2 (deliberate click feels responsive)
|
||||
// * direction flip + bounce-back within 200ms → encoder bounce,
|
||||
// engage wheel-mode
|
||||
// (sticky higher cap)
|
||||
// * 5 consecutive <5ms events → trackpad flick, disengage wheel-mode
|
||||
//
|
||||
// Two separate paths because native terminals (Ghostty, iTerm2) and
|
||||
// browser-embedded terminals (VS Code, Cursor) emit wheel events with
|
||||
// different cadences. Native sends 1 event per intended row, often
|
||||
// pre-amplified at the emulator level; xterm.js sends exactly 1 event
|
||||
// per notch, unamplified.
|
||||
|
||||
import { isXtermJs } from '@hermes/ink'
|
||||
|
||||
// ── Native path (ghostty, iTerm2, WezTerm, etc.) ───────────────────────
|
||||
const WHEEL_ACCEL_WINDOW_MS = 40
|
||||
const WHEEL_ACCEL_STEP = 0.3
|
||||
const WHEEL_ACCEL_MAX = 6
|
||||
|
||||
// ── Encoder bounce / wheel-mode path (detected mechanical wheels) ──────
|
||||
const WHEEL_BOUNCE_GAP_MAX_MS = 200
|
||||
const WHEEL_MODE_STEP = 15
|
||||
const WHEEL_MODE_CAP = 15
|
||||
const WHEEL_MODE_RAMP = 3
|
||||
const WHEEL_MODE_IDLE_DISENGAGE_MS = 1500
|
||||
|
||||
// ── xterm.js path (VS Code / Cursor / browser terminals) ───────────────
|
||||
const WHEEL_DECAY_HALFLIFE_MS = 150
|
||||
const WHEEL_DECAY_STEP = 5
|
||||
const WHEEL_BURST_MS = 5
|
||||
const WHEEL_DECAY_GAP_MS = 80
|
||||
const WHEEL_DECAY_CAP_SLOW = 3
|
||||
const WHEEL_DECAY_CAP_FAST = 6
|
||||
const WHEEL_DECAY_IDLE_MS = 500
|
||||
|
||||
export type WheelAccelState = {
|
||||
time: number
|
||||
mult: number
|
||||
dir: 0 | 1 | -1
|
||||
xtermJs: boolean
|
||||
/** Carried fractional scroll (xterm.js only). scrollBy floors, so
|
||||
* without this a mult of 1.5 gives 1 row every time. Carrying the
|
||||
* remainder gives 1,2,1,2 on average for mult=1.5 — correct
|
||||
* throughput over time. */
|
||||
frac: number
|
||||
/** Native-path baseline rows/event. Reset value on idle/reversal;
|
||||
* ramp builds on top. xterm.js path ignores this. */
|
||||
base: number
|
||||
/** Deferred direction flip (native only). Might be encoder bounce or
|
||||
* a real reversal — resolved by the NEXT event. */
|
||||
pendingFlip: boolean
|
||||
/** Confirmed once a bounce fired (flip-then-flip-back within the
|
||||
* bounce window). Sticky until idle disengage or trackpad burst. */
|
||||
wheelMode: boolean
|
||||
/** Consecutive <5ms events. Trackpad flick ≥5 → disengage wheelMode. */
|
||||
burstCount: number
|
||||
}
|
||||
|
||||
export function initWheelAccel(xtermJs = false, base = 1): WheelAccelState {
|
||||
return {
|
||||
burstCount: 0,
|
||||
base,
|
||||
dir: 0,
|
||||
frac: 0,
|
||||
mult: base,
|
||||
pendingFlip: false,
|
||||
time: 0,
|
||||
wheelMode: false,
|
||||
xtermJs
|
||||
}
|
||||
}
|
||||
|
||||
/** Read HERMES_TUI_SCROLL_SPEED (or CLAUDE_CODE_SCROLL_SPEED for
|
||||
* portability from claude-code users). Default 1, clamped (0, 20]. */
|
||||
export function readScrollSpeedBase(): number {
|
||||
const raw = process.env.HERMES_TUI_SCROLL_SPEED ?? process.env.CLAUDE_CODE_SCROLL_SPEED
|
||||
|
||||
if (!raw) {
|
||||
return 1
|
||||
}
|
||||
|
||||
const n = parseFloat(raw)
|
||||
|
||||
return Number.isNaN(n) || n <= 0 ? 1 : Math.min(n, 20)
|
||||
}
|
||||
|
||||
/** Initialize the accel state with environment-derived defaults. */
|
||||
export function initWheelAccelForHost(): WheelAccelState {
|
||||
return initWheelAccel(isXtermJs(), readScrollSpeedBase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute rows for one wheel event, MUTATING the accel state. Returns 0
|
||||
* when a direction flip is deferred for bounce detection — call sites
|
||||
* should no-op on 0 (scrollBy(0) is a no-op anyway, but explicit check
|
||||
* keeps the intent obvious).
|
||||
*/
|
||||
export function computeWheelStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
|
||||
if (!state.xtermJs) {
|
||||
return nativeStep(state, dir, now)
|
||||
}
|
||||
|
||||
return xtermJsStep(state, dir, now)
|
||||
}
|
||||
|
||||
function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
|
||||
// Device-switch guard ①: idle disengage. A pending bounce can mask
|
||||
// as a real reversal via the early return below — run this first so
|
||||
// "user stopped for 1.5s then mouse-click" restarts at baseline.
|
||||
if (state.wheelMode && now - state.time > WHEEL_MODE_IDLE_DISENGAGE_MS) {
|
||||
state.wheelMode = false
|
||||
state.burstCount = 0
|
||||
state.mult = state.base
|
||||
}
|
||||
|
||||
// Resolve any deferred flip before touching state.time/dir.
|
||||
if (state.pendingFlip) {
|
||||
state.pendingFlip = false
|
||||
|
||||
if (dir !== state.dir || now - state.time > WHEEL_BOUNCE_GAP_MAX_MS) {
|
||||
// Real reversal (flip persisted OR flip-back arrived too late).
|
||||
// Commit. The deferred event's 1 row is lost (acceptable latency).
|
||||
state.dir = dir
|
||||
state.time = now
|
||||
state.mult = state.base
|
||||
|
||||
return Math.floor(state.mult)
|
||||
}
|
||||
|
||||
// Bounce confirmed: flipped back to original dir in the window.
|
||||
// Engage wheel-mode for sustained mouse-wheel pattern.
|
||||
state.wheelMode = true
|
||||
}
|
||||
|
||||
const gap = now - state.time
|
||||
|
||||
if (dir !== state.dir && state.dir !== 0) {
|
||||
// Direction flip. Defer — next event decides bounce vs reversal.
|
||||
state.pendingFlip = true
|
||||
state.time = now
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
state.dir = dir
|
||||
state.time = now
|
||||
|
||||
if (state.wheelMode) {
|
||||
if (gap < WHEEL_BURST_MS) {
|
||||
// Same-batch burst (SGR proportional reporting) OR trackpad flick.
|
||||
// Give 1 row/event; trackpad flick hits the burst-count disengage.
|
||||
if (++state.burstCount >= 5) {
|
||||
state.wheelMode = false
|
||||
state.burstCount = 0
|
||||
state.mult = state.base
|
||||
} else {
|
||||
return 1
|
||||
}
|
||||
} else {
|
||||
state.burstCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Re-check after possible disengage above.
|
||||
if (state.wheelMode) {
|
||||
const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS)
|
||||
const cap = Math.max(WHEEL_MODE_CAP, state.base * 2)
|
||||
const next = 1 + (state.mult - 1) * m + WHEEL_MODE_STEP * m
|
||||
|
||||
state.mult = Math.min(cap, next, state.mult + WHEEL_MODE_RAMP)
|
||||
|
||||
return Math.floor(state.mult)
|
||||
}
|
||||
|
||||
// Trackpad / hi-res (native, non-wheel-mode). Tight 40ms window:
|
||||
// sub-40ms ramps, anything slower resets to baseline.
|
||||
if (gap > WHEEL_ACCEL_WINDOW_MS) {
|
||||
state.mult = state.base
|
||||
} else {
|
||||
const cap = Math.max(WHEEL_ACCEL_MAX, state.base * 2)
|
||||
|
||||
state.mult = Math.min(cap, state.mult + WHEEL_ACCEL_STEP)
|
||||
}
|
||||
|
||||
return Math.floor(state.mult)
|
||||
}
|
||||
|
||||
function xtermJsStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
|
||||
const gap = now - state.time
|
||||
const sameDir = dir === state.dir
|
||||
|
||||
state.time = now
|
||||
state.dir = dir
|
||||
|
||||
if (sameDir && gap < WHEEL_BURST_MS) {
|
||||
// Same-batch burst — 1 row/event, same philosophy as native.
|
||||
return 1
|
||||
}
|
||||
|
||||
if (!sameDir || gap > WHEEL_DECAY_IDLE_MS) {
|
||||
// Direction reversal or long idle: start at 2 so the first click
|
||||
// after a pause moves visibly.
|
||||
state.mult = 2
|
||||
state.frac = 0
|
||||
} else {
|
||||
const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS)
|
||||
const cap = gap >= WHEEL_DECAY_GAP_MS ? WHEEL_DECAY_CAP_SLOW : WHEEL_DECAY_CAP_FAST
|
||||
|
||||
state.mult = Math.min(cap, 1 + (state.mult - 1) * m + WHEEL_DECAY_STEP * m)
|
||||
}
|
||||
|
||||
const total = state.mult + state.frac
|
||||
const rows = Math.floor(total)
|
||||
|
||||
state.frac = total - rows
|
||||
|
||||
return rows
|
||||
}
|
||||
1
ui-tui/src/types/hermes-ink.d.ts
vendored
1
ui-tui/src/types/hermes-ink.d.ts
vendored
@@ -104,6 +104,7 @@ declare module '@hermes/ink' {
|
||||
export const Text: React.ComponentType<any>
|
||||
export const TextInput: React.ComponentType<any>
|
||||
export const stringWidth: (s: string) => number
|
||||
export function isXtermJs(): boolean
|
||||
|
||||
export type ScrollFastPathStats = {
|
||||
captured: number
|
||||
|
||||
Reference in New Issue
Block a user