mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
chore(tui): /clean recent perf work — KISS/DRY pass
24 files, -319 LoC. Behaviour preserved, 369/369 tests green. - hermes-ink caches: shared lruEvict helper for the four parallel LRU caches (stringWidth, wrapText, sliceAnsi, lineWidth); touch-on-read stays inlined per cache; tightened output.ts skip-slice fast path. - wheelAccel: trimmed provenance header, collapsed env parsing, ternary dispatch in computeWheelStep. - perfPane: folded ensureLogDir into once-flag, spread-with-overrides for fastPath/phases instead of full rebuilds. - env: extracted truthy() (used 4×). - virtualHeights: collapsed user/diff/slash height bumps; trail+todos estimate. - useInputHandlers: scrollIdleTimer cleanup on unmount, ?? undefined shorthand. - useMainApp: dropped dead liveTailVisible IIFE and liveProgress indirection. - appLayout, markdown, messageLine, entry: vertical rhythm, dropped narration comments, inlined one-shot vars. - fix: empty catch blocks → /* best-effort */ for no-empty lint.
This commit is contained in:
parent
527ac351b4
commit
b1c49d5e73
32 changed files with 259 additions and 547 deletions
|
|
@ -1,12 +1,7 @@
|
||||||
export { default as useStderr } from './hooks/use-stderr.js'
|
export { default as useStderr } from './hooks/use-stderr.js'
|
||||||
export { default as useStdout } from './hooks/use-stdout.js'
|
export { default as useStdout } from './hooks/use-stdout.js'
|
||||||
export { Ansi } from './ink/Ansi.js'
|
export { Ansi } from './ink/Ansi.js'
|
||||||
export {
|
export { evictInkCaches, type EvictLevel, type InkCacheSizes, inkCacheSizes } from './ink/cache-eviction.js'
|
||||||
evictInkCaches,
|
|
||||||
type EvictLevel,
|
|
||||||
type InkCacheSizes,
|
|
||||||
inkCacheSizes
|
|
||||||
} from './ink/cache-eviction.js'
|
|
||||||
export { AlternateScreen } from './ink/components/AlternateScreen.js'
|
export { AlternateScreen } from './ink/components/AlternateScreen.js'
|
||||||
export { default as Box } from './ink/components/Box.js'
|
export { default as Box } from './ink/components/Box.js'
|
||||||
export { default as Link } from './ink/components/Link.js'
|
export { default as Link } from './ink/components/Link.js'
|
||||||
|
|
@ -26,13 +21,9 @@ export { useTabStatus } from './ink/hooks/use-tab-status.js'
|
||||||
export { useTerminalFocus } from './ink/hooks/use-terminal-focus.js'
|
export { useTerminalFocus } from './ink/hooks/use-terminal-focus.js'
|
||||||
export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
|
export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
|
||||||
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.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 { default as measureElement } from './ink/measure-element.js'
|
||||||
export {
|
export { resetScrollFastPathStats, scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js'
|
||||||
resetScrollFastPathStats,
|
|
||||||
scrollFastPathStats,
|
|
||||||
type ScrollFastPathStats
|
|
||||||
} from './ink/render-node-to-output.js'
|
|
||||||
export { createRoot, default as render, renderSync } from './ink/root.js'
|
export { createRoot, default as render, renderSync } from './ink/root.js'
|
||||||
export { stringWidth } from './ink/stringWidth.js'
|
export { stringWidth } from './ink/stringWidth.js'
|
||||||
|
export { isXtermJs } from './ink/terminal.js'
|
||||||
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
|
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,12 @@
|
||||||
// (not session-keyed), so cross-session sharing is normally beneficial —
|
// (not session-keyed), so cross-session sharing is normally beneficial —
|
||||||
// only evict when memory tightens or when the user explicitly resets.
|
// only evict when memory tightens or when the user explicitly resets.
|
||||||
|
|
||||||
|
import { evictSliceCache, sliceCacheSize } from '../utils/sliceAnsi.js'
|
||||||
|
|
||||||
import { evictLineWidthCache, lineWidthCacheSize } from './line-width-cache.js'
|
import { evictLineWidthCache, lineWidthCacheSize } from './line-width-cache.js'
|
||||||
import { evictWidthCache, widthCacheSize } from './stringWidth.js'
|
import { evictWidthCache, widthCacheSize } from './stringWidth.js'
|
||||||
import { evictWrapCache, wrapCacheSize } from './wrap-text.js'
|
import { evictWrapCache, wrapCacheSize } from './wrap-text.js'
|
||||||
|
|
||||||
import { evictSliceCache, sliceCacheSize } from '../utils/sliceAnsi.js'
|
|
||||||
|
|
||||||
export interface InkCacheSizes {
|
export interface InkCacheSizes {
|
||||||
lineWidth: number
|
lineWidth: number
|
||||||
slice: number
|
slice: number
|
||||||
|
|
|
||||||
|
|
@ -979,15 +979,13 @@ export default class Ink {
|
||||||
}
|
}
|
||||||
|
|
||||||
const tWrite = performance.now()
|
const tWrite = performance.now()
|
||||||
|
|
||||||
// Capture any stale pending write BEFORE starting this frame's write —
|
// Capture any stale pending write BEFORE starting this frame's write —
|
||||||
// if the callback already fired, pendingWriteStart is null and lastDrainMs
|
// if the callback already fired, pendingWriteStart is null and lastDrainMs
|
||||||
// already reflects the previous frame's drain. If it hasn't fired, we
|
// already reflects the previous frame's drain. If it hasn't fired, we
|
||||||
// report "still pending" via a non-zero duration based on now-then so
|
// report "still pending" via a non-zero duration based on now-then so
|
||||||
// backpressure shows up even if Node never flushes this session.
|
// backpressure shows up even if Node never flushes this session.
|
||||||
const staleDrain =
|
const staleDrain = this.pendingWriteStart !== null ? performance.now() - this.pendingWriteStart : this.lastDrainMs
|
||||||
this.pendingWriteStart !== null
|
|
||||||
? performance.now() - this.pendingWriteStart
|
|
||||||
: this.lastDrainMs
|
|
||||||
|
|
||||||
const prevFrameDrainMs = Math.round(staleDrain * 100) / 100
|
const prevFrameDrainMs = Math.round(staleDrain * 100) / 100
|
||||||
this.lastDrainMs = 0
|
this.lastDrainMs = 0
|
||||||
|
|
@ -1016,6 +1014,7 @@ export default class Ink {
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
)
|
)
|
||||||
|
|
||||||
const writeMs = performance.now() - tWrite
|
const writeMs = performance.now() - tWrite
|
||||||
|
|
||||||
// Update blit safety for the NEXT frame. The frame just rendered
|
// Update blit safety for the NEXT frame. The frame just rendered
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { lruEvict } from './lru.js'
|
||||||
import { stringWidth } from './stringWidth.js'
|
import { stringWidth } from './stringWidth.js'
|
||||||
|
|
||||||
// During streaming, text grows but completed lines are immutable.
|
// During streaming, text grows but completed lines are immutable.
|
||||||
|
|
@ -13,6 +14,7 @@ export function lineWidth(line: string): number {
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
cache.delete(line)
|
cache.delete(line)
|
||||||
cache.set(line, cached)
|
cache.set(line, cached)
|
||||||
|
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,14 +34,5 @@ export function lineWidthCacheSize(): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function evictLineWidthCache(keepRatio = 0): void {
|
export function evictLineWidthCache(keepRatio = 0): void {
|
||||||
if (keepRatio <= 0) {
|
lruEvict(cache, keepRatio)
|
||||||
cache.clear()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = Math.floor(cache.size * keepRatio)
|
|
||||||
|
|
||||||
while (cache.size > target) {
|
|
||||||
cache.delete(cache.keys().next().value!)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
ui-tui/packages/hermes-ink/src/ink/lru.ts
Normal file
14
ui-tui/packages/hermes-ink/src/ink/lru.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// Shared eviction for the hot Ink LRU caches (widthCache, wrapCache,
|
||||||
|
// sliceCache, lineWidthCache). Hot-path touch-on-read stays inlined per
|
||||||
|
// cache — only the bulk eviction is factored here.
|
||||||
|
export function lruEvict<K, V>(cache: Map<K, V>, keepRatio: number): void {
|
||||||
|
if (keepRatio <= 0) {
|
||||||
|
return cache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = Math.floor(cache.size * keepRatio)
|
||||||
|
|
||||||
|
while (cache.size > target) {
|
||||||
|
cache.delete(cache.keys().next().value!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -467,15 +467,15 @@ export default class Output {
|
||||||
|
|
||||||
if (clipHorizontally) {
|
if (clipHorizontally) {
|
||||||
lines = lines.map(line => {
|
lines = lines.map(line => {
|
||||||
const startsBefore = x < clip.x1!
|
|
||||||
const width = stringWidth(line)
|
const width = stringWidth(line)
|
||||||
|
const startsBefore = x < clip.x1!
|
||||||
const endsAfter = x + width > clip.x2!
|
const endsAfter = x + width > clip.x2!
|
||||||
|
|
||||||
// Fast path: line fits entirely within the clip box — skip
|
// Fast path: line fits entirely within the clip box — skip
|
||||||
// the tokenize/slice. This is the common case for transcript
|
// tokenize/slice. Common case for transcript text where
|
||||||
// text where containers are wider than the rendered content.
|
// containers are wider than rendered content. CPU profile
|
||||||
// CPU profile (Apr 2026) showed sliceAnsi at 18% total time;
|
// (Apr 2026): sliceAnsi at 18% total during scroll, mostly
|
||||||
// most calls were no-op slices like (line, 0, width).
|
// no-op (line, 0, width) slices.
|
||||||
if (!startsBefore && !endsAfter) {
|
if (!startsBefore && !endsAfter) {
|
||||||
return line
|
return line
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,6 @@ export function resetScrollFastPathStats(): void {
|
||||||
scrollFastPathStats.lastPrevHeight = undefined
|
scrollFastPathStats.lastPrevHeight = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function getScrollHint(): ScrollHint | null {
|
export function getScrollHint(): ScrollHint | null {
|
||||||
return scrollHint
|
return scrollHint
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import stripAnsi from 'strip-ansi'
|
||||||
|
|
||||||
import { getGraphemeSegmenter } from '../utils/intl.js'
|
import { getGraphemeSegmenter } from '../utils/intl.js'
|
||||||
|
|
||||||
|
import { lruEvict } from './lru.js'
|
||||||
|
|
||||||
const EMOJI_REGEX = emojiRegex()
|
const EMOJI_REGEX = emojiRegex()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -299,6 +301,7 @@ export const stringWidth: (str: string) => number = str => {
|
||||||
|
|
||||||
if (code >= 127 || code === 0x1b) {
|
if (code >= 127 || code === 0x1b) {
|
||||||
asciiOnly = false
|
asciiOnly = false
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -334,14 +337,5 @@ export function widthCacheSize(): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function evictWidthCache(keepRatio = 0): void {
|
export function evictWidthCache(keepRatio = 0): void {
|
||||||
if (keepRatio <= 0) {
|
lruEvict(widthCache, keepRatio)
|
||||||
widthCache.clear()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = Math.floor(widthCache.size * keepRatio)
|
|
||||||
|
|
||||||
while (widthCache.size > target) {
|
|
||||||
widthCache.delete(widthCache.keys().next().value!)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -289,9 +289,7 @@ export function writeDiffToTerminal(
|
||||||
// The 2-arg form attaches a drain callback that fires once the chunk
|
// The 2-arg form attaches a drain callback that fires once the chunk
|
||||||
// is actually flushed to the OS socket/pipe — giving us end-to-end
|
// is actually flushed to the OS socket/pipe — giving us end-to-end
|
||||||
// drain timing, not just "queued in Node".
|
// drain timing, not just "queued in Node".
|
||||||
const wrote = onDrain
|
const wrote = onDrain ? terminal.stdout.write(buffer, () => onDrain()) : terminal.stdout.write(buffer)
|
||||||
? terminal.stdout.write(buffer, () => onDrain())
|
|
||||||
: terminal.stdout.write(buffer)
|
|
||||||
|
|
||||||
return { bytes: Buffer.byteLength(buffer, 'utf8'), backpressure: !wrote }
|
return { bytes: Buffer.byteLength(buffer, 'utf8'), backpressure: !wrote }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import sliceAnsi from '../utils/sliceAnsi.js'
|
import sliceAnsi from '../utils/sliceAnsi.js'
|
||||||
|
|
||||||
|
import { lruEvict } from './lru.js'
|
||||||
import { stringWidth } from './stringWidth.js'
|
import { stringWidth } from './stringWidth.js'
|
||||||
import type { Styles } from './styles.js'
|
import type { Styles } from './styles.js'
|
||||||
import { wrapAnsi } from './wrapAnsi.js'
|
import { wrapAnsi } from './wrapAnsi.js'
|
||||||
|
|
@ -113,14 +114,5 @@ export function wrapCacheSize(): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function evictWrapCache(keepRatio = 0): void {
|
export function evictWrapCache(keepRatio = 0): void {
|
||||||
if (keepRatio <= 0) {
|
lruEvict(wrapCache, keepRatio)
|
||||||
wrapCache.clear()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = Math.floor(wrapCache.size * keepRatio)
|
|
||||||
|
|
||||||
while (wrapCache.size > target) {
|
|
||||||
wrapCache.delete(wrapCache.keys().next().value!)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { type AnsiCode, ansiCodesToString, reduceAnsiCodes, tokenize, undoAnsiCodes } from '@alcalzone/ansi-tokenize'
|
import { type AnsiCode, ansiCodesToString, reduceAnsiCodes, tokenize, undoAnsiCodes } from '@alcalzone/ansi-tokenize'
|
||||||
|
|
||||||
|
import { lruEvict } from '../ink/lru.js'
|
||||||
import { stringWidth } from '../ink/stringWidth.js'
|
import { stringWidth } from '../ink/stringWidth.js'
|
||||||
|
|
||||||
function isEndCode(code: AnsiCode): boolean {
|
function isEndCode(code: AnsiCode): boolean {
|
||||||
|
|
@ -19,7 +20,9 @@ const sliceCache = new Map<string, string>()
|
||||||
const SLICE_CACHE_LIMIT = 4096
|
const SLICE_CACHE_LIMIT = 4096
|
||||||
|
|
||||||
export default function sliceAnsi(str: string, start: number, end?: number): string {
|
export default function sliceAnsi(str: string, start: number, end?: number): string {
|
||||||
if (!str) return ''
|
if (!str) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
// Hot-path: only cache when end is defined (the Output.get() use-case).
|
// Hot-path: only cache when end is defined (the Output.get() use-case).
|
||||||
if (end !== undefined) {
|
if (end !== undefined) {
|
||||||
|
|
@ -29,6 +32,7 @@ export default function sliceAnsi(str: string, start: number, end?: number): str
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
sliceCache.delete(key)
|
sliceCache.delete(key)
|
||||||
sliceCache.set(key, cached)
|
sliceCache.set(key, cached)
|
||||||
|
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,6 +43,7 @@ export default function sliceAnsi(str: string, start: number, end?: number): str
|
||||||
}
|
}
|
||||||
|
|
||||||
sliceCache.set(key, result)
|
sliceCache.set(key, result)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,16 +55,7 @@ export function sliceCacheSize(): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function evictSliceCache(keepRatio = 0): void {
|
export function evictSliceCache(keepRatio = 0): void {
|
||||||
if (keepRatio <= 0) {
|
lruEvict(sliceCache, keepRatio)
|
||||||
sliceCache.clear()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = Math.floor(sliceCache.size * keepRatio)
|
|
||||||
|
|
||||||
while (sliceCache.size > target) {
|
|
||||||
sliceCache.delete(sliceCache.keys().next().value!)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeSlice(str: string, start: number, end?: number): string {
|
function computeSlice(str: string, start: number, end?: number): string {
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,9 @@ import type { Msg } from '../types.js'
|
||||||
|
|
||||||
describe('virtual height estimates', () => {
|
describe('virtual height estimates', () => {
|
||||||
it('uses stable content keys across resumed message objects', () => {
|
it('uses stable content keys across resumed message objects', () => {
|
||||||
const a: Msg = { role: 'assistant', text: 'same text', tools: ['Search Files [long message]'] }
|
const msg: Msg = { role: 'assistant', text: 'same text', tools: ['Search Files [long message]'] }
|
||||||
const b: Msg = { role: 'assistant', text: 'same text', tools: ['Search Files [long message]'] }
|
|
||||||
|
|
||||||
expect(messageHeightKey(a)).toBe(messageHeightKey(b))
|
expect(messageHeightKey(msg)).toBe(messageHeightKey({ ...msg }))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accounts for wrapping and preserved blank-block rhythm', () => {
|
it('accounts for wrapping and preserved blank-block rhythm', () => {
|
||||||
|
|
|
||||||
|
|
@ -12,38 +12,29 @@ describe('wheelAccel — native path', () => {
|
||||||
it('same-direction fast events ramp mult (window-mode)', () => {
|
it('same-direction fast events ramp mult (window-mode)', () => {
|
||||||
const s = initWheelAccel(false, 1)
|
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, 1000)
|
||||||
computeWheelStep(s, 1, 1020)
|
computeWheelStep(s, 1, 1020)
|
||||||
computeWheelStep(s, 1, 1040)
|
computeWheelStep(s, 1, 1040)
|
||||||
const fourth = computeWheelStep(s, 1, 1060)
|
|
||||||
|
|
||||||
// After 3 window events: mult starts at 1 → stays 1 on first ramp
|
// Key property: doesn't shrink below base.
|
||||||
// (first event just sets baseline), then +0.3 × 3 = 1.9 → floor=1.
|
expect(computeWheelStep(s, 1, 1060)).toBeGreaterThanOrEqual(1)
|
||||||
// The key property: doesn't shrink below base.
|
|
||||||
expect(fourth).toBeGreaterThanOrEqual(1)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('gap beyond window resets mult to base', () => {
|
it('gap beyond window resets mult to base', () => {
|
||||||
const s = initWheelAccel(false, 1)
|
const s = initWheelAccel(false, 1)
|
||||||
|
|
||||||
// Ramp up
|
|
||||||
for (let t = 1000; t < 1100; t += 20) {
|
for (let t = 1000; t < 1100; t += 20) {
|
||||||
computeWheelStep(s, 1, t)
|
computeWheelStep(s, 1, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Long pause, then click
|
expect(computeWheelStep(s, 1, 2000)).toBe(1)
|
||||||
const afterPause = computeWheelStep(s, 1, 2000)
|
|
||||||
|
|
||||||
expect(afterPause).toBe(1)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('direction flip defers one event for bounce detection', () => {
|
it('direction flip defers one event for bounce detection', () => {
|
||||||
const s = initWheelAccel(false, 1)
|
const s = initWheelAccel(false, 1)
|
||||||
|
|
||||||
computeWheelStep(s, 1, 1000)
|
computeWheelStep(s, 1, 1000)
|
||||||
// Flip — should defer
|
|
||||||
expect(computeWheelStep(s, -1, 1050)).toBe(0)
|
expect(computeWheelStep(s, -1, 1050)).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -51,9 +42,7 @@ describe('wheelAccel — native path', () => {
|
||||||
const s = initWheelAccel(false, 1)
|
const s = initWheelAccel(false, 1)
|
||||||
|
|
||||||
computeWheelStep(s, 1, 1000)
|
computeWheelStep(s, 1, 1000)
|
||||||
// Flip (deferred)
|
|
||||||
computeWheelStep(s, -1, 1050)
|
computeWheelStep(s, -1, 1050)
|
||||||
// Flip BACK within 200ms → bounce confirmed → wheelMode engaged
|
|
||||||
computeWheelStep(s, 1, 1100)
|
computeWheelStep(s, 1, 1100)
|
||||||
|
|
||||||
expect(s.wheelMode).toBe(true)
|
expect(s.wheelMode).toBe(true)
|
||||||
|
|
@ -63,8 +52,7 @@ describe('wheelAccel — native path', () => {
|
||||||
const s = initWheelAccel(false, 1)
|
const s = initWheelAccel(false, 1)
|
||||||
|
|
||||||
computeWheelStep(s, 1, 1000)
|
computeWheelStep(s, 1, 1000)
|
||||||
computeWheelStep(s, -1, 1050) // defer
|
computeWheelStep(s, -1, 1050)
|
||||||
// Flip-back arrives 300ms later → too late → real reversal
|
|
||||||
computeWheelStep(s, 1, 1400)
|
computeWheelStep(s, 1, 1400)
|
||||||
|
|
||||||
expect(s.wheelMode).toBe(false)
|
expect(s.wheelMode).toBe(false)
|
||||||
|
|
@ -76,12 +64,9 @@ describe('wheelAccel — native path', () => {
|
||||||
s.dir = 1
|
s.dir = 1
|
||||||
s.time = 1000
|
s.time = 1000
|
||||||
|
|
||||||
// 5 bursts <5ms apart (trackpad flick)
|
for (let t = 1002; t <= 1010; t += 2) {
|
||||||
computeWheelStep(s, 1, 1002)
|
computeWheelStep(s, 1, t)
|
||||||
computeWheelStep(s, 1, 1004)
|
}
|
||||||
computeWheelStep(s, 1, 1006)
|
|
||||||
computeWheelStep(s, 1, 1008)
|
|
||||||
computeWheelStep(s, 1, 1010)
|
|
||||||
|
|
||||||
expect(s.wheelMode).toBe(false)
|
expect(s.wheelMode).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
@ -92,7 +77,7 @@ describe('wheelAccel — native path', () => {
|
||||||
s.dir = 1
|
s.dir = 1
|
||||||
s.time = 1000
|
s.time = 1000
|
||||||
|
|
||||||
computeWheelStep(s, 1, 3000) // 2 second gap
|
computeWheelStep(s, 1, 3000)
|
||||||
|
|
||||||
expect(s.wheelMode).toBe(false)
|
expect(s.wheelMode).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
@ -102,34 +87,23 @@ describe('wheelAccel — xterm.js path', () => {
|
||||||
it('first click returns 2 after long idle', () => {
|
it('first click returns 2 after long idle', () => {
|
||||||
const s = initWheelAccel(true, 1)
|
const s = initWheelAccel(true, 1)
|
||||||
|
|
||||||
// First event — "sameDir && gap > WHEEL_DECAY_IDLE_MS" triggers
|
expect(computeWheelStep(s, 1, 1000)).toBeGreaterThanOrEqual(1)
|
||||||
// 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)', () => {
|
it('sub-5ms burst returns 1 (same-direction, same-batch)', () => {
|
||||||
const s = initWheelAccel(true, 1)
|
const s = initWheelAccel(true, 1)
|
||||||
|
|
||||||
computeWheelStep(s, 1, 1000)
|
computeWheelStep(s, 1, 1000)
|
||||||
const burst = computeWheelStep(s, 1, 1002)
|
|
||||||
|
|
||||||
expect(burst).toBe(1)
|
expect(computeWheelStep(s, 1, 1002)).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('slow steady scroll stays in precision range', () => {
|
it('slow steady scroll stays in precision range', () => {
|
||||||
const s = initWheelAccel(true, 1)
|
const s = initWheelAccel(true, 1)
|
||||||
|
|
||||||
// Simulated 30Hz sustained scroll: 33ms gap
|
|
||||||
const results: number[] = []
|
|
||||||
|
|
||||||
for (let t = 1000; t < 2000; t += 33) {
|
for (let t = 1000; t < 2000; t += 33) {
|
||||||
results.push(computeWheelStep(s, 1, t))
|
const r = computeWheelStep(s, 1, t)
|
||||||
}
|
|
||||||
|
|
||||||
// Every event should produce 1-6 rows. No runaway.
|
|
||||||
for (const r of results) {
|
|
||||||
expect(r).toBeGreaterThanOrEqual(1)
|
expect(r).toBeGreaterThanOrEqual(1)
|
||||||
expect(r).toBeLessThanOrEqual(6)
|
expect(r).toBeLessThanOrEqual(6)
|
||||||
}
|
}
|
||||||
|
|
@ -138,27 +112,22 @@ describe('wheelAccel — xterm.js path', () => {
|
||||||
it('direction reversal resets mult', () => {
|
it('direction reversal resets mult', () => {
|
||||||
const s = initWheelAccel(true, 1)
|
const s = initWheelAccel(true, 1)
|
||||||
|
|
||||||
// Ramp up
|
|
||||||
for (let t = 1000; t < 1100; t += 20) {
|
for (let t = 1000; t < 1100; t += 20) {
|
||||||
computeWheelStep(s, 1, t)
|
computeWheelStep(s, 1, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
const beforeFlip = s.mult
|
const beforeFlip = s.mult
|
||||||
|
|
||||||
// Flip
|
|
||||||
computeWheelStep(s, -1, 1200)
|
computeWheelStep(s, -1, 1200)
|
||||||
|
|
||||||
expect(s.mult).toBeLessThanOrEqual(beforeFlip)
|
expect(s.mult).toBeLessThanOrEqual(beforeFlip)
|
||||||
// Reset branch sets mult=2
|
|
||||||
expect(s.mult).toBe(2)
|
expect(s.mult).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('frac stays in [0,1) across events', () => {
|
it('frac stays in [0,1) across events', () => {
|
||||||
const s = initWheelAccel(true, 1)
|
const s = initWheelAccel(true, 1)
|
||||||
|
|
||||||
// frac must never go negative or reach 1.0 — that's the correctness
|
// Correctness invariant of fractional carry: never negative, never reaches 1.
|
||||||
// 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) {
|
for (let t = 1000; t < 1200; t += 30) {
|
||||||
computeWheelStep(s, 1, t)
|
computeWheelStep(s, 1, t)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ export const archiveTodosAtTurnEnd = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const done = isTodoDone(state.todos)
|
const done = isTodoDone(state.todos)
|
||||||
|
|
||||||
const msg: Msg = {
|
const msg: Msg = {
|
||||||
kind: 'trail',
|
kind: 'trail',
|
||||||
role: 'system',
|
role: 'system',
|
||||||
|
|
|
||||||
|
|
@ -29,35 +29,19 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||||
const overlay = useStore($overlayState)
|
const overlay = useStore($overlayState)
|
||||||
const isBlocked = useStore($isBlocked)
|
const isBlocked = useStore($isBlocked)
|
||||||
const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6)
|
const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6)
|
||||||
const scrollIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const scrollIdleTimer = useRef<null | ReturnType<typeof setTimeout>>(null)
|
||||||
|
|
||||||
// Wheel acceleration state machine (ported from claude-code). Adapts
|
// Wheel accel ported from claude-code: inter-event timing drives step size,
|
||||||
// step size per wheel event based on inter-event timing: fast flicks
|
// direction flips reset. wheelStep (WHEEL_SCROLL_STEP) is the base; final
|
||||||
// ramp up, slow clicks stay at 1 row, direction flips reset. See
|
// rows = wheelStep × accelMult. State mutates in place across renders.
|
||||||
// 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 wheelAccelRef = useRef(initWheelAccelForHost())
|
||||||
|
|
||||||
useEffect(
|
useEffect(() => () => clearTimeout(scrollIdleTimer.current ?? undefined), [])
|
||||||
() => () => {
|
|
||||||
if (scrollIdleTimer.current) {
|
|
||||||
clearTimeout(scrollIdleTimer.current)
|
|
||||||
scrollIdleTimer.current = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const scrollTranscript = (delta: number) => {
|
const scrollTranscript = (delta: number) => {
|
||||||
if (getUiState().busy) {
|
if (getUiState().busy) {
|
||||||
turnController.boostStreamingForScroll()
|
turnController.boostStreamingForScroll()
|
||||||
|
clearTimeout(scrollIdleTimer.current ?? undefined)
|
||||||
if (scrollIdleTimer.current) {
|
|
||||||
clearTimeout(scrollIdleTimer.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollIdleTimer.current = setTimeout(() => {
|
scrollIdleTimer.current = setTimeout(() => {
|
||||||
scrollIdleTimer.current = null
|
scrollIdleTimer.current = null
|
||||||
turnController.relaxStreaming()
|
turnController.relaxStreaming()
|
||||||
|
|
@ -300,16 +284,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||||
|
|
||||||
if (key.wheelUp || key.wheelDown) {
|
if (key.wheelUp || key.wheelDown) {
|
||||||
const dir: -1 | 1 = key.wheelUp ? -1 : 1
|
const dir: -1 | 1 = key.wheelUp ? -1 : 1
|
||||||
const accelRows = computeWheelStep(wheelAccelRef.current, dir, Date.now())
|
// 0 = direction-flip bounce deferred; skip the no-op scroll.
|
||||||
|
const rows = computeWheelStep(wheelAccelRef.current, dir, Date.now())
|
||||||
|
|
||||||
// computeWheelStep returns 0 when a direction flip is deferred for
|
return rows ? scrollTranscript(dir * rows * wheelStep) : undefined
|
||||||
// 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) {
|
if (key.shift && key.upArrow) {
|
||||||
|
|
@ -321,14 +299,9 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key.pageUp || key.pageDown) {
|
if (key.pageUp || key.pageDown) {
|
||||||
|
// Half-viewport keeps 50% continuity and stays under Ink's
|
||||||
|
// `delta < innerHeight` DECSTBM fast-path threshold.
|
||||||
const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, (terminal.stdout?.rows ?? 24) - 8)
|
const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, (terminal.stdout?.rows ?? 24) - 8)
|
||||||
// Half-viewport per keystroke. A whole-viewport jump (our old
|
|
||||||
// `viewport - 2`) fully replaces what's on screen — no visual
|
|
||||||
// continuity, the user can't scan — AND it lands right at Ink's
|
|
||||||
// `delta < innerHeight` fast-path threshold, disqualifying the
|
|
||||||
// DECSTBM blit on every press. Half-viewport keeps 50% continuity,
|
|
||||||
// well under the threshold, and two presses still scroll the same
|
|
||||||
// total distance.
|
|
||||||
const step = Math.max(4, Math.floor(viewport / 2))
|
const step = Math.max(4, Math.floor(viewport / 2))
|
||||||
|
|
||||||
return scrollTranscript(key.pageUp ? -step : step)
|
return scrollTranscript(key.pageUp ? -step : step)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useApp, useHasSelection, useSelection, useStdout, useTerminalTitle, type ScrollBoxHandle } from '@hermes/ink'
|
import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
|
@ -20,7 +20,6 @@ import { appendTranscriptMessage } from '../lib/messages.js'
|
||||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||||
import { terminalParityHints } from '../lib/terminalParity.js'
|
import { terminalParityHints } from '../lib/terminalParity.js'
|
||||||
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
|
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
|
||||||
import { getViewportSnapshot } from '../lib/viewportStore.js'
|
|
||||||
import { estimatedMsgHeight, messageHeightKey } from '../lib/virtualHeights.js'
|
import { estimatedMsgHeight, messageHeightKey } from '../lib/virtualHeights.js'
|
||||||
import type { Msg, PanelSection, SlashCatalog } from '../types.js'
|
import type { Msg, PanelSection, SlashCatalog } from '../types.js'
|
||||||
|
|
||||||
|
|
@ -199,8 +198,10 @@ export function useMainApp(gw: GatewayClient) {
|
||||||
|
|
||||||
return `${thinking}:${tools}`
|
return `${thinking}:${tools}`
|
||||||
}, [ui.detailsMode, ui.detailsModeCommandOverride, ui.sections])
|
}, [ui.detailsMode, ui.detailsModeCommandOverride, ui.sections])
|
||||||
|
|
||||||
const detailsVisible = detailsLayoutKey !== 'hidden:hidden'
|
const detailsVisible = detailsLayoutKey !== 'hidden:hidden'
|
||||||
const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}`
|
const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}`
|
||||||
|
|
||||||
const heightCache = useMemo(() => {
|
const heightCache = useMemo(() => {
|
||||||
let cache = heightCachesRef.current.get(heightCacheKey)
|
let cache = heightCachesRef.current.get(heightCacheKey)
|
||||||
|
|
||||||
|
|
@ -215,6 +216,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||||
|
|
||||||
return cache
|
return cache
|
||||||
}, [heightCacheKey])
|
}, [heightCacheKey])
|
||||||
|
|
||||||
const initialHeights = useMemo(() => {
|
const initialHeights = useMemo(() => {
|
||||||
const out = new Map<string, number>()
|
const out = new Map<string, number>()
|
||||||
|
|
||||||
|
|
@ -232,6 +234,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||||
|
|
||||||
return out
|
return out
|
||||||
}, [cols, detailsVisible, heightCache, ui.compact, virtualRows])
|
}, [cols, detailsVisible, heightCache, ui.compact, virtualRows])
|
||||||
|
|
||||||
const syncHeightCache = useCallback(
|
const syncHeightCache = useCallback(
|
||||||
(heights: ReadonlyMap<string, number>) => {
|
(heights: ReadonlyMap<string, number>) => {
|
||||||
for (const row of virtualRows) {
|
for (const row of virtualRows) {
|
||||||
|
|
@ -719,26 +722,10 @@ export function useMainApp(gw: GatewayClient) {
|
||||||
[cols, composerActions, composerState, empty, pagerPageSize, submit]
|
[cols, composerActions, composerState, empty, pagerPageSize, submit]
|
||||||
)
|
)
|
||||||
|
|
||||||
const liveTailVisible = (() => {
|
// Pass current progress through unfrozen — streaming update throttling
|
||||||
const s = scrollRef.current
|
// handles interaction load; progress must stay truthful so panels don't
|
||||||
|
// randomly disappear when the live tail scrolls offscreen.
|
||||||
if (!s) {
|
const appProgress = useMemo(() => ({ showProgressArea }), [showProgressArea])
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const { bottom, scrollHeight } = getViewportSnapshot(s)
|
|
||||||
|
|
||||||
return bottom >= scrollHeight - 3
|
|
||||||
})()
|
|
||||||
|
|
||||||
const liveProgress = useMemo(() => ({ showProgressArea }), [showProgressArea])
|
|
||||||
|
|
||||||
// Always pass current progress through. Freezing this while offscreen looked
|
|
||||||
// like a nice scroll optimization, but it also froze the live tail's
|
|
||||||
// thinking/tool state at arbitrary intermediate snapshots. Streaming update
|
|
||||||
// throttling now handles interaction load; progress state should remain
|
|
||||||
// truthful so panels don't randomly disappear.
|
|
||||||
const appProgress = liveProgress
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,11 @@ export const writeActiveSessionFile = (sessionId: null | string, file = process.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort shell-epilogue hint; never break live session changes.
|
||||||
try {
|
try {
|
||||||
writeFileSync(file, JSON.stringify({ session_id: sessionId }), { mode: 0o600 })
|
writeFileSync(file, JSON.stringify({ session_id: sessionId }), { mode: 0o600 })
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort shell epilogue hint only; never break live session changes.
|
/* best-effort */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,8 +99,8 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
||||||
setLastUserMsg('')
|
setLastUserMsg('')
|
||||||
setStickyPrompt('')
|
setStickyPrompt('')
|
||||||
composerActions.setPasteSnips([])
|
composerActions.setPasteSnips([])
|
||||||
// Half-prune Ink content caches: new session has new keys, but a partial
|
// Half-prune: new session has new keys, but keep a warm pool in case
|
||||||
// warm pool helps if the user resumes back to the prior session.
|
// the user resumes back to the prior session.
|
||||||
evictInkCaches('half')
|
evictInkCaches('half')
|
||||||
}, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording])
|
}, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,18 @@ const TranscriptPane = memo(function TranscriptPane({
|
||||||
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
|
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
|
||||||
const ui = useStore($uiState)
|
const ui = useStore($uiState)
|
||||||
|
|
||||||
// Index of the latest user message — LiveTodoPanel is rendered as a child
|
// LiveTodoPanel rides as a child of the latest user-message row so it
|
||||||
// of that row so it visually belongs to the user's prompt and follows it
|
// visually belongs to the prompt and follows it during scroll. -1 when
|
||||||
// during scroll. Falls back to -1 when no user message exists yet (empty
|
// empty → row.index === -1 is always false → no render.
|
||||||
// session); LiveTodoPanel then doesn't render at all.
|
|
||||||
const lastUserIdx = useMemo(() => {
|
const lastUserIdx = useMemo(() => {
|
||||||
for (let i = transcript.historyItems.length - 1; i >= 0; i--) {
|
const items = transcript.historyItems
|
||||||
if (transcript.historyItems[i].role === 'user') return i
|
|
||||||
|
for (let i = items.length - 1; i >= 0; i--) {
|
||||||
|
if (items[i].role === 'user') {
|
||||||
|
return i
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return -1
|
return -1
|
||||||
}, [transcript.historyItems])
|
}, [transcript.historyItems])
|
||||||
|
|
||||||
|
|
@ -259,18 +263,9 @@ export const AppLayout = memo(function AppLayout({
|
||||||
}: AppLayoutProps) {
|
}: AppLayoutProps) {
|
||||||
const overlay = useStore($overlayState)
|
const overlay = useStore($overlayState)
|
||||||
|
|
||||||
// Inline mode: skip <AlternateScreen> so the TUI renders into the
|
// Inline mode skips AlternateScreen so the host terminal's native
|
||||||
// primary buffer and the terminal's native scrollback can capture rows
|
// scrollback captures rows scrolled off the top; composer + progress
|
||||||
// that scroll off the top. Mouse tracking is still enabled via
|
// stay anchored via normal flex-column flow.
|
||||||
// AlternateScreen when the wrapper is on; in inline mode we leave it
|
|
||||||
// to the host terminal, which typically does wheel → scrollback.
|
|
||||||
//
|
|
||||||
// `Fragment` (via alias so the JSX stays legible) drops the alt-screen
|
|
||||||
// constraint while keeping the inner layout identical. Content height
|
|
||||||
// will then follow flex-column growth, which means the ScrollBox below
|
|
||||||
// grows beyond the viewport — the terminal's primary buffer scrolls
|
|
||||||
// old rows off the top into native scrollback. Composer + progress
|
|
||||||
// stay at the bottom via normal flow (they're the last siblings).
|
|
||||||
const Shell = INLINE_MODE ? Fragment : AlternateScreen
|
const Shell = INLINE_MODE ? Fragment : AlternateScreen
|
||||||
const shellProps = INLINE_MODE ? {} : { mouseTracking }
|
const shellProps = INLINE_MODE ? {} : { mouseTracking }
|
||||||
|
|
||||||
|
|
@ -305,9 +300,6 @@ export const AppLayout = memo(function AppLayout({
|
||||||
<ComposerPane actions={actions} composer={composer} status={status} />
|
<ComposerPane actions={actions} composer={composer} status={status} />
|
||||||
</PerfPane>
|
</PerfPane>
|
||||||
|
|
||||||
{/* FPS counter overlay: pinned to the bottom row, right
|
|
||||||
aligned, gated on HERMES_TUI_FPS. Returns null + skips
|
|
||||||
this subtree when disabled (zero cost). */}
|
|
||||||
{SHOW_FPS && (
|
{SHOW_FPS && (
|
||||||
<Box flexShrink={0} justifyContent="flex-end" paddingRight={1}>
|
<Box flexShrink={0} justifyContent="flex-end" paddingRight={1}>
|
||||||
<FpsOverlay />
|
<FpsOverlay />
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
// FPS counter overlay — renders in the bottom-right corner when
|
// FPS counter overlay (HERMES_TUI_FPS=1). Zero-cost when disabled.
|
||||||
// HERMES_TUI_FPS=1. Zero-cost when disabled (returns null at the
|
|
||||||
// top of the component; React skips the whole subtree).
|
|
||||||
//
|
|
||||||
// Subscribes to $fpsState via nanostores. The store is only updated
|
|
||||||
// when the env flag is on (trackFrame is undefined otherwise), so we
|
|
||||||
// also gate the subscription on SHOW_FPS to avoid a useless listener.
|
|
||||||
|
|
||||||
import { Text } from '@hermes/ink'
|
import { Text } from '@hermes/ink'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
|
|
@ -12,17 +6,7 @@ import { useStore } from '@nanostores/react'
|
||||||
import { SHOW_FPS } from '../config/env.js'
|
import { SHOW_FPS } from '../config/env.js'
|
||||||
import { $fpsState } from '../lib/fpsStore.js'
|
import { $fpsState } from '../lib/fpsStore.js'
|
||||||
|
|
||||||
const fpsColor = (fps: number) => {
|
const fpsColor = (fps: number) => (fps >= 50 ? 'green' : fps >= 30 ? 'yellow' : 'red')
|
||||||
if (fps >= 50) {
|
|
||||||
return 'green'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fps >= 30) {
|
|
||||||
return 'yellow'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'red'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FpsOverlay() {
|
export function FpsOverlay() {
|
||||||
if (!SHOW_FPS) {
|
if (!SHOW_FPS) {
|
||||||
|
|
@ -35,14 +19,10 @@ export function FpsOverlay() {
|
||||||
function FpsOverlayInner() {
|
function FpsOverlayInner() {
|
||||||
const { fps, lastDurationMs, totalFrames } = useStore($fpsState)
|
const { fps, lastDurationMs, totalFrames } = useStore($fpsState)
|
||||||
|
|
||||||
// Zero-pad to stable width so the corner doesn't jitter as digits
|
// Zero-pad widths so digit churn doesn't jitter the corner.
|
||||||
// come and go. Format: " 62fps 0.3ms #12345"
|
|
||||||
const fpsStr = fps.toFixed(1).padStart(5)
|
|
||||||
const durStr = lastDurationMs.toFixed(1).padStart(5)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text color={fpsColor(fps)}>
|
<Text color={fpsColor(fps)}>
|
||||||
{fpsStr}fps · {durStr}ms · #{totalFrames}
|
{fps.toFixed(1).padStart(5)}fps · {lastDurationMs.toFixed(1).padStart(5)}ms · #{totalFrames}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Box, Link, Text } from '@hermes/ink'
|
import { Box, Link, Text } from '@hermes/ink'
|
||||||
import { memo, useMemo, type ReactNode } from 'react'
|
import { memo, type ReactNode, useMemo } from 'react'
|
||||||
|
|
||||||
import { ensureEmojiPresentation } from '../lib/emoji.js'
|
import { ensureEmojiPresentation } from '../lib/emoji.js'
|
||||||
import { highlightLine, isHighlightable } from '../lib/syntax.js'
|
import { highlightLine, isHighlightable } from '../lib/syntax.js'
|
||||||
|
|
@ -213,26 +213,23 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
|
||||||
return <Text>{parts.length ? parts : <Text>{text}</Text>}</Text>
|
return <Text>{parts.length ? parts : <Text>{text}</Text>}</Text>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-instance parsed-children cache. `Md` is mounted fresh whenever a
|
// Cross-instance parsed-children cache: useMemo's per-instance cache dies
|
||||||
// virtualized row enters the mount window — useMemo's per-instance cache
|
// on remount, so virtualization re-parses every row that scrolls back into
|
||||||
// doesn't survive remounts, so PageUp into cold/resumed history reparses
|
// view. Theme-keyed WeakMap drops stale palettes; inner Map is LRU-bounded.
|
||||||
// every row (markdown scan + per-line syntax highlight).
|
|
||||||
//
|
|
||||||
// Outer WeakMap keyed by theme so palette swaps drop stale baked-in colors
|
|
||||||
// without code intervention. Inner Map is LRU-bounded; key folds `compact`
|
|
||||||
// in so the two layout modes don't poison each other.
|
|
||||||
const MD_CACHE_LIMIT = 512
|
const MD_CACHE_LIMIT = 512
|
||||||
const mdCache = new WeakMap<Theme, Map<string, ReactNode[]>>()
|
const mdCache = new WeakMap<Theme, Map<string, ReactNode[]>>()
|
||||||
|
|
||||||
const cacheBucket = (t: Theme) => {
|
const cacheBucket = (t: Theme) => {
|
||||||
let b = mdCache.get(t)
|
const b = mdCache.get(t)
|
||||||
|
|
||||||
if (!b) {
|
if (b) {
|
||||||
b = new Map()
|
return b
|
||||||
mdCache.set(t, b)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return b
|
const fresh = new Map<string, ReactNode[]>()
|
||||||
|
mdCache.set(t, fresh)
|
||||||
|
|
||||||
|
return fresh
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheGet = (b: Map<string, ReactNode[]>, key: string) => {
|
const cacheGet = (b: Map<string, ReactNode[]>, key: string) => {
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,15 @@
|
||||||
|
const truthy = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim())
|
||||||
|
|
||||||
export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
|
export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
|
||||||
export const MOUSE_TRACKING = !/^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim())
|
export const MOUSE_TRACKING = !truthy(process.env.HERMES_TUI_DISABLE_MOUSE)
|
||||||
export const NO_CONFIRM_DESTRUCTIVE = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_NO_CONFIRM ?? '').trim())
|
export const NO_CONFIRM_DESTRUCTIVE = truthy(process.env.HERMES_TUI_NO_CONFIRM)
|
||||||
// Inline mode: skip the alt-screen wrapper. The TUI renders into the
|
|
||||||
// primary buffer so the terminal's native scrollback captures whatever
|
// Skip AlternateScreen — TUI renders into the primary buffer so the host
|
||||||
// scrolls off the top. Wheel + PageUp are then handled by the host
|
// terminal's native scrollback captures whatever scrolls off the top.
|
||||||
// terminal, not by our virtual-scroll logic. The live composer/progress
|
// Experiment gate: lets us measure native scroll vs our virtualization on
|
||||||
// area still pins to the bottom via Ink's normal flow.
|
// the same pipeline.
|
||||||
//
|
export const INLINE_MODE = truthy(process.env.HERMES_TUI_INLINE)
|
||||||
// This is an experiment gate — the full "inline layout" (plain-text
|
|
||||||
// transcript with composer pinned below) is a bigger change; the env var
|
// Live FPS counter overlay, fed by ink's onFrame (real render rate, not a
|
||||||
// here just disables AlternateScreen so we can measure whether native
|
// synthetic timer).
|
||||||
// scrolling beats our virtualization on the same pipeline.
|
export const SHOW_FPS = truthy(process.env.HERMES_TUI_FPS)
|
||||||
export const INLINE_MODE = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_INLINE ?? '').trim())
|
|
||||||
// Show a small FPS counter overlay in the bottom-right corner. Fed by
|
|
||||||
// ink's onFrame callback (so it's the REAL render rate, not a synthetic
|
|
||||||
// timer). Useful during scroll-perf tuning to watch behavior in real
|
|
||||||
// time instead of running a separate profile harness.
|
|
||||||
export const SHOW_FPS = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_FPS ?? '').trim())
|
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,22 @@
|
||||||
export const LARGE_PASTE = { chars: 8000, lines: 80 }
|
export const LARGE_PASTE = { chars: 8000, lines: 80 }
|
||||||
|
|
||||||
export const LIVE_RENDER_MAX_CHARS = 16_000
|
export const LIVE_RENDER_MAX_CHARS = 16_000
|
||||||
export const LIVE_RENDER_MAX_LINES = 240
|
export const LIVE_RENDER_MAX_LINES = 240
|
||||||
// History-render bounds for messages outside the FULL_RENDER_TAIL window.
|
|
||||||
// Each rendered line becomes ≥1 Yoga/Text node + inline spans, so this is
|
// History-render bounds for messages outside FULL_RENDER_TAIL. Each rendered
|
||||||
// the dominant lever on cold-mount cost during PageUp catch-up. 16 lines
|
// line ≈ 1 Yoga/Text node + inline spans, so this is the dominant lever on
|
||||||
// × 25 mounted items ≈ 400 nodes total — small enough that the per-frame
|
// cold-mount cost during PageUp catch-up. 16 lines × 25 mounted ≈ 400 nodes
|
||||||
// buffer-compose stays well inside the 16ms budget. User pages back to
|
// — comfortably inside the 16ms per-frame budget. User pages back to
|
||||||
// recognize where they were, not to read; stopping near a message
|
// recognize, not to read; full re-render once it falls inside the tail.
|
||||||
// re-renders it in full once it falls inside the tail window.
|
|
||||||
export const HISTORY_RENDER_MAX_CHARS = 800
|
export const HISTORY_RENDER_MAX_CHARS = 800
|
||||||
export const HISTORY_RENDER_MAX_LINES = 16
|
export const HISTORY_RENDER_MAX_LINES = 16
|
||||||
export const FULL_RENDER_TAIL_ITEMS = 8
|
export const FULL_RENDER_TAIL_ITEMS = 8
|
||||||
|
|
||||||
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
|
||||||
// Rows scrolled per wheel-notch event.
|
|
||||||
//
|
// Rows per wheel event (pre-accel). 1 keeps Ink's DECSTBM fast path live
|
||||||
// One notch of a mechanical wheel emits multiple wheel events (3-5 per
|
// (each scroll < viewport-1) and produces smooth motion. wheelAccel.ts
|
||||||
// click in most terminals; trackpad flicks emit 100+). Each event scrolls
|
// ramps this on sustained scrolls.
|
||||||
// WHEEL_SCROLL_STEP rows. The product = rows-per-click.
|
|
||||||
//
|
|
||||||
// 1 = pure line-by-line. Small per-event delta keeps Ink's DECSTBM fast
|
|
||||||
// path firing (each scroll < viewport-1) and produces smooth visible
|
|
||||||
// motion — the user can scan content mid-scroll. We were at 6 before
|
|
||||||
// (= ~20-30 rows per notch) which visually teleported and forced the
|
|
||||||
// virtualization to reshape the mount range on every event.
|
|
||||||
//
|
|
||||||
// If this feels sluggish on precision scrolls, porting claude-code's
|
|
||||||
// wheel accel state machine (ScrollKeybindingHandler.tsx) is the right
|
|
||||||
// next step — it ramps step up during sustained fast clicks and decays
|
|
||||||
// on pause.
|
|
||||||
export const WHEEL_SCROLL_STEP = 1
|
export const WHEEL_SCROLL_STEP = 1
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc
|
#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc
|
||||||
|
import type { FrameEvent } from '@hermes/ink'
|
||||||
|
|
||||||
import { GatewayClient } from './gatewayClient.js'
|
import { GatewayClient } from './gatewayClient.js'
|
||||||
import { setupGracefulExit } from './lib/gracefulExit.js'
|
import { setupGracefulExit } from './lib/gracefulExit.js'
|
||||||
import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js'
|
import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js'
|
||||||
|
|
@ -41,26 +43,21 @@ if (process.env.HERMES_HEAPDUMP_ON_START === '1') {
|
||||||
|
|
||||||
process.on('beforeExit', () => stopMemoryMonitor())
|
process.on('beforeExit', () => stopMemoryMonitor())
|
||||||
|
|
||||||
const [{ render }, { App }, { logFrameEvent }, { trackFrame }] = await Promise.all([
|
const [ink, { App }, { logFrameEvent }, { trackFrame }] = await Promise.all([
|
||||||
import('@hermes/ink'),
|
import('@hermes/ink'),
|
||||||
import('./app.js'),
|
import('./app.js'),
|
||||||
import('./lib/perfPane.js'),
|
import('./lib/perfPane.js'),
|
||||||
import('./lib/fpsStore.js')
|
import('./lib/fpsStore.js')
|
||||||
])
|
])
|
||||||
|
|
||||||
// Compose onFrame from the two opt-in consumers (HERMES_DEV_PERF and
|
// Both consumers are undefined when their env flags are off; only attach
|
||||||
// HERMES_TUI_FPS). Each is undefined when its env flag is off; we only
|
// onFrame when at least one is on so ink skips timing in the default case.
|
||||||
// attach onFrame at all when at least one is on, so ink skips the
|
const onFrame =
|
||||||
// handler entirely in the default disabled case.
|
|
||||||
type InkFrameEvent = { durationMs: number }
|
|
||||||
type OnFrame = (event: InkFrameEvent) => void
|
|
||||||
|
|
||||||
const onFrame: OnFrame | undefined =
|
|
||||||
logFrameEvent || trackFrame
|
logFrameEvent || trackFrame
|
||||||
? (event: InkFrameEvent) => {
|
? (event: FrameEvent) => {
|
||||||
logFrameEvent?.(event as Parameters<NonNullable<typeof logFrameEvent>>[0])
|
logFrameEvent?.(event)
|
||||||
trackFrame?.(event.durationMs)
|
trackFrame?.(event.durationMs)
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
render(<App gw={gw} />, { exitOnCtrlC: false, onFrame })
|
ink.render(<App gw={gw} />, { exitOnCtrlC: false, onFrame })
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import type { ScrollBoxHandle } from '@hermes/ink'
|
import type { ScrollBoxHandle } from '@hermes/ink'
|
||||||
import {
|
import {
|
||||||
|
type RefObject,
|
||||||
useCallback,
|
useCallback,
|
||||||
useDeferredValue,
|
useDeferredValue,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
useSyncExternalStore,
|
useSyncExternalStore
|
||||||
type RefObject
|
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
|
||||||
const ESTIMATE = 4
|
const ESTIMATE = 4
|
||||||
|
|
@ -98,6 +98,7 @@ export function useVirtualHistory(
|
||||||
// Bump whenever heightCache mutates so offsets rebuild on next read.
|
// Bump whenever heightCache mutates so offsets rebuild on next read.
|
||||||
// Ref (not state) — checked during render phase, zero extra commits.
|
// Ref (not state) — checked during render phase, zero extra commits.
|
||||||
const offsetVersion = useRef(0)
|
const offsetVersion = useRef(0)
|
||||||
|
|
||||||
// Cached offsets: reused Float64Array keyed on (itemCount, version) so we
|
// Cached offsets: reused Float64Array keyed on (itemCount, version) so we
|
||||||
// only rebuild when something actually changed. Previous approach allocated
|
// only rebuild when something actually changed. Previous approach allocated
|
||||||
// a fresh Array(n+1) every render — at n=10k that's ~80KB/render of GC
|
// a fresh Array(n+1) every render — at n=10k that's ~80KB/render of GC
|
||||||
|
|
@ -107,6 +108,7 @@ export function useVirtualHistory(
|
||||||
n: -1,
|
n: -1,
|
||||||
version: -1
|
version: -1
|
||||||
})
|
})
|
||||||
|
|
||||||
const [hasScrollRef, setHasScrollRef] = useState(false)
|
const [hasScrollRef, setHasScrollRef] = useState(false)
|
||||||
const metrics = useRef({ sticky: true, top: 0, vp: 0 })
|
const metrics = useRef({ sticky: true, top: 0, vp: 0 })
|
||||||
const lastScrollTopRef = useRef(0)
|
const lastScrollTopRef = useRef(0)
|
||||||
|
|
@ -158,6 +160,7 @@ export function useVirtualHistory(
|
||||||
(cb: () => void) => (hasScrollRef ? scrollRef.current?.subscribe(cb) : null) ?? NOOP,
|
(cb: () => void) => (hasScrollRef ? scrollRef.current?.subscribe(cb) : null) ?? NOOP,
|
||||||
[hasScrollRef, scrollRef]
|
[hasScrollRef, scrollRef]
|
||||||
)
|
)
|
||||||
|
|
||||||
useSyncExternalStore(
|
useSyncExternalStore(
|
||||||
subscribe,
|
subscribe,
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -310,13 +313,8 @@ export function useVirtualHistory(
|
||||||
if (velocity > vp * 2) {
|
if (velocity > vp * 2) {
|
||||||
const [pS, pE] = prevRange.current
|
const [pS, pE] = prevRange.current
|
||||||
|
|
||||||
if (start < pS - SLIDE_STEP) {
|
start = Math.max(start, pS - SLIDE_STEP)
|
||||||
start = pS - SLIDE_STEP
|
end = Math.min(end, pE + SLIDE_STEP)
|
||||||
}
|
|
||||||
|
|
||||||
if (end > pE + SLIDE_STEP) {
|
|
||||||
end = pE + SLIDE_STEP
|
|
||||||
}
|
|
||||||
|
|
||||||
// A large jump past the capped end can invert (start > end); mount
|
// A large jump past the capped end can invert (start > end); mount
|
||||||
// SLIDE_STEP items from the new start so the viewport isn't blank
|
// SLIDE_STEP items from the new start so the viewport isn't blank
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,32 @@
|
||||||
// Tiny FPS tracker fed by ink's onFrame callback.
|
// Tiny FPS tracker fed by ink's onFrame callback. Each entry is an Ink
|
||||||
|
// frame (React commit + drain-only frames) — the right notion for
|
||||||
|
// user-perceived motion.
|
||||||
//
|
//
|
||||||
// Keeps a ring buffer of the last N frame timestamps and derives fps
|
// Zero-cost when HERMES_TUI_FPS is unset: trackFrame is undefined so the
|
||||||
// from the rolling window. Updates a nanostore so a corner-overlay
|
// onFrame callback short-circuits at the optional chain.
|
||||||
// component can subscribe without pulling it through props.
|
|
||||||
//
|
|
||||||
// FPS here means "Ink render rate" — each entry is an ink frame, which
|
|
||||||
// includes both React commits and drain-only frames (Ink re-rendering
|
|
||||||
// with an updated scrollTop without a React commit). That's the right
|
|
||||||
// notion for user-perceived motion: it's how often the screen buffer
|
|
||||||
// actually changes, not how often React reconciles.
|
|
||||||
//
|
|
||||||
// Zero-cost when HERMES_TUI_FPS is unset: trackFrame is undefined so
|
|
||||||
// the onFrame callback short-circuits at the optional chain.
|
|
||||||
|
|
||||||
import { atom } from 'nanostores'
|
import { atom } from 'nanostores'
|
||||||
|
|
||||||
import { SHOW_FPS } from '../config/env.js'
|
import { SHOW_FPS } from '../config/env.js'
|
||||||
|
|
||||||
const WINDOW_SIZE = 30 // last 30 frames
|
const WINDOW_SIZE = 30
|
||||||
|
|
||||||
export type FpsState = {
|
export type FpsState = {
|
||||||
/** Frames per second averaged over the last WINDOW_SIZE frames. */
|
|
||||||
fps: number
|
fps: number
|
||||||
/** Total frames counted since start (wraps at JS-safe int so you can
|
/** Wraps at JS-safe int — diff pairs in a debug overlay safely. */
|
||||||
* diff pairs in a debug overlay without worrying about precision). */
|
|
||||||
totalFrames: number
|
totalFrames: number
|
||||||
/** Last frame's durationMs (ink render phase total). */
|
/** Ink render-phase total for the last frame. */
|
||||||
lastDurationMs: number
|
lastDurationMs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const $fpsState = atom<FpsState>({
|
export const $fpsState = atom<FpsState>({ fps: 0, lastDurationMs: 0, totalFrames: 0 })
|
||||||
fps: 0,
|
|
||||||
lastDurationMs: 0,
|
|
||||||
totalFrames: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const timestamps: number[] = []
|
const timestamps: number[] = []
|
||||||
let totalFrames = 0
|
let totalFrames = 0
|
||||||
|
|
||||||
export const trackFrame = SHOW_FPS
|
export const trackFrame = SHOW_FPS
|
||||||
? (durationMs: number) => {
|
? (durationMs: number) => {
|
||||||
const now = performance.now()
|
timestamps.push(performance.now())
|
||||||
|
|
||||||
timestamps.push(now)
|
|
||||||
|
|
||||||
if (timestamps.length > WINDOW_SIZE) {
|
if (timestamps.length > WINDOW_SIZE) {
|
||||||
timestamps.shift()
|
timestamps.shift()
|
||||||
|
|
@ -50,20 +34,18 @@ export const trackFrame = SHOW_FPS
|
||||||
|
|
||||||
totalFrames++
|
totalFrames++
|
||||||
|
|
||||||
// FPS = frames-in-window / seconds-in-window. Needs at least 2
|
if (timestamps.length < 2) {
|
||||||
// timestamps to compute a gap.
|
return
|
||||||
if (timestamps.length >= 2) {
|
}
|
||||||
|
|
||||||
const elapsed = (timestamps[timestamps.length - 1]! - timestamps[0]!) / 1000
|
const elapsed = (timestamps[timestamps.length - 1]! - timestamps[0]!) / 1000
|
||||||
|
|
||||||
if (elapsed > 0) {
|
if (elapsed > 0) {
|
||||||
const fps = (timestamps.length - 1) / elapsed
|
|
||||||
|
|
||||||
$fpsState.set({
|
$fpsState.set({
|
||||||
fps: Math.round(fps * 10) / 10,
|
fps: Math.round(((timestamps.length - 1) / elapsed) * 10) / 10,
|
||||||
lastDurationMs: Math.round(durationMs * 100) / 100,
|
lastDurationMs: Math.round(durationMs * 100) / 100,
|
||||||
totalFrames
|
totalFrames
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
: undefined
|
: undefined
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,10 @@ describe('appendToolShelfMessage', () => {
|
||||||
|
|
||||||
it('starts a new shelf across assistant text boundaries', () => {
|
it('starts a new shelf across assistant text boundaries', () => {
|
||||||
const merged = appendToolShelfMessage(
|
const merged = appendToolShelfMessage(
|
||||||
[{ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] }, { role: 'assistant', text: 'done' }],
|
[
|
||||||
|
{ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] },
|
||||||
|
{ role: 'assistant', text: 'done' }
|
||||||
|
],
|
||||||
{ kind: 'trail', role: 'system', text: '', tools: ['two ✓'] }
|
{ kind: 'trail', role: 'system', text: '', tools: ['two ✓'] }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,7 @@ const isBarrierMessage = (msg: Msg | undefined) => {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const isToolCarryingTrail = (msg: Msg | undefined) =>
|
const isToolCarryingTrail = (msg: Msg | undefined) => Boolean(msg?.kind === 'trail' && !msg.text && msg.tools?.length)
|
||||||
Boolean(msg?.kind === 'trail' && !msg.text && msg.tools?.length)
|
|
||||||
|
|
||||||
export const appendToolShelfMessage = (prev: readonly Msg[], msg: Msg): Msg[] => {
|
export const appendToolShelfMessage = (prev: readonly Msg[], msg: Msg): Msg[] => {
|
||||||
if (!isToolShelfMessage(msg)) {
|
if (!isToolShelfMessage(msg)) {
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,8 @@ export function startMemoryMonitor({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defensive eviction: prune Ink content caches before dumping/exiting.
|
// Prune Ink content caches before dump/exit — half on 'high' (recoverable),
|
||||||
// 'high' = half-prune (still warm enough to recover quickly);
|
// full on 'critical' (post-dump RSS reduction, keeps user running).
|
||||||
// 'critical' = full drop. Reduces post-dump RSS and gives the user a
|
|
||||||
// chance to keep running rather than auto-restart.
|
|
||||||
evictInkCaches(level === 'critical' ? 'all' : 'half')
|
evictInkCaches(level === 'critical' ? 'all' : 'half')
|
||||||
|
|
||||||
dumped.add(level)
|
dumped.add(level)
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,15 @@
|
||||||
// Perf instrumentation for the full render pipeline.
|
// Perf instrumentation for the full render pipeline.
|
||||||
//
|
//
|
||||||
// Two sources of timing:
|
// PerfPane (React.Profiler) → per-pane commit times
|
||||||
// 1. React.Profiler wrapper (PerfPane) → per-pane commit times. Shows
|
// logFrameEvent (ink.onFrame) → yoga / renderer / diff / optimize / write
|
||||||
// which subtree is reconciling and for how long.
|
// phases + yoga counters + scroll fast-path
|
||||||
// 2. Ink onFrame callback (logFrameEvent) → per-frame pipeline phases:
|
|
||||||
// yoga (calculateLayout), renderer (DOM → screen buffer), diff
|
|
||||||
// (prev vs current screen → patches), optimize (patch merge/dedupe),
|
|
||||||
// write (serialize → ANSI → stdout), plus yoga counters (visited,
|
|
||||||
// measured, cacheHits, live). Shows where the time goes BELOW React.
|
|
||||||
//
|
//
|
||||||
// Both sources gate on HERMES_DEV_PERF=1 and dump JSON-lines to the same
|
// Both gate on HERMES_DEV_PERF=1 and dump JSON-lines (default ~/.hermes/perf.log,
|
||||||
// log (default ~/.hermes/perf.log, override via HERMES_DEV_PERF_LOG).
|
// override HERMES_DEV_PERF_LOG). Tagged { src: 'react' | 'frame' } for jq.
|
||||||
// Events are tagged { src: 'react' | 'frame' } so jq can split them.
|
// HERMES_DEV_PERF_MS (default 2) skips sub-ms idle frames; set 0 to capture all.
|
||||||
//
|
//
|
||||||
// Threshold HERMES_DEV_PERF_MS (default 2ms) skips sub-millisecond idle
|
// Zero cost when unset: PerfPane returns children directly, logFrameEvent is
|
||||||
// frames. For the 2fps-during-PageUp investigation, set
|
// undefined so ink doesn't pay the timing cost.
|
||||||
// HERMES_DEV_PERF_MS=0 to capture everything, then filter with jq.
|
|
||||||
//
|
|
||||||
// Zero cost when the env var is unset: PerfPane returns children
|
|
||||||
// directly (no Profiler fiber), logFrameEvent is a noop on the onFrame
|
|
||||||
// callback — the ink instance isn't given the callback at all.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// # entry.tsx wires logFrameEvent into render()
|
|
||||||
// import { logFrameEvent, PerfPane } from './lib/perfPane.js'
|
|
||||||
// render(<App/>, { onFrame: logFrameEvent })
|
|
||||||
//
|
|
||||||
// Analysis helpers (once you've captured a session):
|
|
||||||
// tail -f ~/.hermes/perf.log | jq -c 'select(.src=="frame" and .durationMs > 16)'
|
|
||||||
// # p50/p99 per phase across frame events:
|
|
||||||
// jq -s '[.[] | select(.src=="frame")] |
|
|
||||||
// {n: length,
|
|
||||||
// dur_p50: (sort_by(.durationMs) | .[length/2|floor].durationMs),
|
|
||||||
// dur_p99: (sort_by(.durationMs) | .[length*0.99|floor].durationMs),
|
|
||||||
// yoga_p99: (sort_by(.phases.yoga) | .[length*0.99|floor].phases.yoga),
|
|
||||||
// write_p99: (sort_by(.phases.write) | .[length*0.99|floor].phases.write),
|
|
||||||
// diff_p99: (sort_by(.phases.diff) | .[length*0.99|floor].phases.diff),
|
|
||||||
// patches_p99: (sort_by(.phases.patches) | .[length*0.99|floor].phases.patches)}' \
|
|
||||||
// ~/.hermes/perf.log
|
|
||||||
|
|
||||||
import { appendFileSync, mkdirSync } from 'node:fs'
|
import { appendFileSync, mkdirSync } from 'node:fs'
|
||||||
import { homedir } from 'node:os'
|
import { homedir } from 'node:os'
|
||||||
|
|
@ -51,31 +23,23 @@ const ENABLED = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_DEV_PERF ?? '').
|
||||||
const THRESHOLD_MS = Number(process.env.HERMES_DEV_PERF_MS ?? '2') || 0
|
const THRESHOLD_MS = Number(process.env.HERMES_DEV_PERF_MS ?? '2') || 0
|
||||||
const LOG_PATH = process.env.HERMES_DEV_PERF_LOG?.trim() || join(homedir(), '.hermes', 'perf.log')
|
const LOG_PATH = process.env.HERMES_DEV_PERF_LOG?.trim() || join(homedir(), '.hermes', 'perf.log')
|
||||||
|
|
||||||
let initialized = false
|
let logReady = false
|
||||||
|
|
||||||
const ensureLogDir = () => {
|
const writeRow = (row: Record<string, unknown>) => {
|
||||||
if (initialized) {
|
if (!logReady) {
|
||||||
return
|
logReady = true
|
||||||
}
|
|
||||||
|
|
||||||
initialized = true
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
mkdirSync(dirname(LOG_PATH), { recursive: true })
|
mkdirSync(dirname(LOG_PATH), { recursive: true })
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort — if we can't create the dir (readonly fs, /tmp, etc.)
|
// Best-effort — never crash the TUI to log a sample.
|
||||||
// the appendFileSync calls below will throw silently and we drop the
|
|
||||||
// sample. Perf logging should never crash the TUI.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const writeRow = (row: Record<string, unknown>) => {
|
|
||||||
ensureLogDir()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
appendFileSync(LOG_PATH, `${JSON.stringify(row)}\n`)
|
appendFileSync(LOG_PATH, `${JSON.stringify(row)}\n`)
|
||||||
} catch {
|
} catch {
|
||||||
// Same rationale as ensureLogDir — never crash the UI to log a sample.
|
/* best-effort */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,59 +74,27 @@ export function PerfPane({ children, id }: { children: ReactNode; id: string })
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ink onFrame handler. Captures the FULL render pipeline: yoga calculateLayout,
|
|
||||||
* DOM → screen buffer, screen diff, patch optimize, and stdout write.
|
|
||||||
*
|
|
||||||
* Returns `undefined` when disabled so `render()` doesn't attach the callback —
|
|
||||||
* ink only pays the timing cost when the callback is truthy.
|
|
||||||
*/
|
|
||||||
export const logFrameEvent = ENABLED
|
export const logFrameEvent = ENABLED
|
||||||
? (event: FrameEvent) => {
|
? (event: FrameEvent) => {
|
||||||
if (event.durationMs < THRESHOLD_MS) {
|
if (event.durationMs < THRESHOLD_MS) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot the fast-path counters each frame. Cumulative values —
|
|
||||||
// consumers diff pairs to get per-frame deltas. Written verbatim
|
|
||||||
// so we can also see "last*" fields (which decline reason fired,
|
|
||||||
// and what the height math looked like).
|
|
||||||
const fastPath = {
|
|
||||||
captured: scrollFastPathStats.captured,
|
|
||||||
taken: scrollFastPathStats.taken,
|
|
||||||
declined: {
|
|
||||||
heightDeltaMismatch: scrollFastPathStats.declined.heightDeltaMismatch,
|
|
||||||
noPrevScreen: scrollFastPathStats.declined.noPrevScreen,
|
|
||||||
other: scrollFastPathStats.declined.other
|
|
||||||
},
|
|
||||||
lastDeclineReason: scrollFastPathStats.lastDeclineReason,
|
|
||||||
lastHeightDelta: scrollFastPathStats.lastHeightDelta,
|
|
||||||
lastHintDelta: scrollFastPathStats.lastHintDelta,
|
|
||||||
lastPrevHeight: scrollFastPathStats.lastPrevHeight,
|
|
||||||
lastScrollHeight: scrollFastPathStats.lastScrollHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
writeRow({
|
writeRow({
|
||||||
durationMs: round2(event.durationMs),
|
durationMs: round2(event.durationMs),
|
||||||
fastPath,
|
// Cumulative counters — consumers diff pairs to get per-frame deltas.
|
||||||
|
fastPath: { ...scrollFastPathStats, declined: { ...scrollFastPathStats.declined } },
|
||||||
flickers: event.flickers.length ? event.flickers : undefined,
|
flickers: event.flickers.length ? event.flickers : undefined,
|
||||||
phases: event.phases
|
phases: event.phases
|
||||||
? {
|
? {
|
||||||
backpressure: event.phases.backpressure,
|
...event.phases,
|
||||||
commit: round2(event.phases.commit),
|
commit: round2(event.phases.commit),
|
||||||
diff: round2(event.phases.diff),
|
diff: round2(event.phases.diff),
|
||||||
optimize: round2(event.phases.optimize),
|
optimize: round2(event.phases.optimize),
|
||||||
optimizedPatches: event.phases.optimizedPatches,
|
|
||||||
patches: event.phases.patches,
|
|
||||||
prevFrameDrainMs: round2(event.phases.prevFrameDrainMs),
|
prevFrameDrainMs: round2(event.phases.prevFrameDrainMs),
|
||||||
renderer: round2(event.phases.renderer),
|
renderer: round2(event.phases.renderer),
|
||||||
write: round2(event.phases.write),
|
write: round2(event.phases.write),
|
||||||
writeBytes: event.phases.writeBytes,
|
yoga: round2(event.phases.yoga)
|
||||||
yoga: round2(event.phases.yoga),
|
|
||||||
yogaCacheHits: event.phases.yogaCacheHits,
|
|
||||||
yogaLive: event.phases.yogaLive,
|
|
||||||
yogaMeasured: event.phases.yogaMeasured,
|
|
||||||
yogaVisited: event.phases.yogaVisited
|
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
src: 'frame',
|
src: 'frame',
|
||||||
|
|
|
||||||
|
|
@ -80,20 +80,15 @@ export const pasteTokenLabel = (text: string, lineCount: number) => {
|
||||||
const THINKING_STATUS_RE = new RegExp(`^(?:${VERBS.join('|')})\\.{0,3}$`, 'i')
|
const THINKING_STATUS_RE = new RegExp(`^(?:${VERBS.join('|')})\\.{0,3}$`, 'i')
|
||||||
const THINKING_STATUS_CHUNK_RE = new RegExp(`[^A-Za-z\n]+\\s*(?:${VERBS.join('|')})\\.{0,3}\\s*`, 'giu')
|
const THINKING_STATUS_CHUNK_RE = new RegExp(`[^A-Za-z\n]+\\s*(?:${VERBS.join('|')})\\.{0,3}\\s*`, 'giu')
|
||||||
|
|
||||||
const normalizeThinkingParagraphs = (text: string) =>
|
|
||||||
text
|
|
||||||
.replace(/([^\n])(?=\*\*[^*\n][^\n]*?\*\*)/g, '$1\n\n')
|
|
||||||
.replace(/\n{3,}/g, '\n\n')
|
|
||||||
.trim()
|
|
||||||
|
|
||||||
export const cleanThinkingText = (reasoning: string) =>
|
export const cleanThinkingText = (reasoning: string) =>
|
||||||
normalizeThinkingParagraphs(
|
|
||||||
reasoning
|
reasoning
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map(line => line.replace(THINKING_STATUS_CHUNK_RE, '').trim())
|
.map(line => line.replace(THINKING_STATUS_CHUNK_RE, '').trim())
|
||||||
.filter(line => line && !THINKING_STATUS_RE.test(line.replace(/\.\.\.$/, '').trim()))
|
.filter(line => line && !THINKING_STATUS_RE.test(line.replace(/\.\.\.$/, '').trim()))
|
||||||
.join('\n')
|
.join('\n')
|
||||||
)
|
.replace(/([^\n])(?=\*\*[^*\n][^\n]*?\*\*)/g, '$1\n\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim()
|
||||||
|
|
||||||
export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number = THINKING_COT_MAX) => {
|
export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number = THINKING_COT_MAX) => {
|
||||||
const raw = cleanThinkingText(reasoning)
|
const raw = cleanThinkingText(reasoning)
|
||||||
|
|
|
||||||
|
|
@ -14,18 +14,18 @@ export const hashText = (text: string) => {
|
||||||
|
|
||||||
export const messageHeightKey = (msg: Msg) => {
|
export const messageHeightKey = (msg: Msg) => {
|
||||||
const todoSig = msg.todos?.map(t => `${t.status}:${t.content}`).join('\u0001') ?? ''
|
const todoSig = msg.todos?.map(t => `${t.status}:${t.content}`).join('\u0001') ?? ''
|
||||||
|
|
||||||
const panelSig =
|
const panelSig =
|
||||||
msg.panelData?.sections
|
msg.panelData?.sections
|
||||||
.map(s => `${s.title ?? ''}:${s.text?.length ?? 0}:${s.items?.length ?? 0}:${s.rows?.length ?? 0}`)
|
.map(s => `${s.title ?? ''}:${s.text?.length ?? 0}:${s.items?.length ?? 0}:${s.rows?.length ?? 0}`)
|
||||||
.join('\u0001') ?? ''
|
.join('\u0001') ?? ''
|
||||||
|
|
||||||
const introSig = msg.kind === 'intro' ? (msg.info?.version ?? '') : ''
|
const introSig = msg.kind === 'intro' ? (msg.info?.version ?? '') : ''
|
||||||
|
|
||||||
return [
|
return [
|
||||||
msg.role,
|
msg.role,
|
||||||
msg.kind ?? '',
|
msg.kind ?? '',
|
||||||
hashText(
|
hashText([msg.text, msg.thinking ?? '', msg.tools?.join('\n') ?? '', todoSig, panelSig, introSig].join('\0'))
|
||||||
[msg.text, msg.thinking ?? '', msg.tools?.join('\n') ?? '', todoSig, panelSig, introSig].join('\0')
|
|
||||||
)
|
|
||||||
].join(':')
|
].join(':')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,11 +68,9 @@ export const estimatedMsgHeight = (
|
||||||
h += (msg.tools?.length ?? 0) + wrappedLines(msg.thinking ?? '', bodyWidth)
|
h += (msg.tools?.length ?? 0) + wrappedLines(msg.thinking ?? '', bodyWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.role === 'user' || msg.kind === 'slash' || msg.kind === 'diff') {
|
|
||||||
h++
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.role === 'user' || msg.kind === 'diff') {
|
if (msg.role === 'user' || msg.kind === 'diff') {
|
||||||
|
h += 2
|
||||||
|
} else if (msg.kind === 'slash') {
|
||||||
h++
|
h++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,35 @@
|
||||||
// Wheel-scroll acceleration state machine.
|
// Wheel-scroll acceleration state machine.
|
||||||
//
|
//
|
||||||
// Algorithm and tuning constants adapted from a reference implementation
|
// One event = 1 row feels sluggish on trackpads (200+ ev/s) and sustained
|
||||||
// of trackpad/wheel-event acceleration in TUI scroll handlers; this file
|
// mouse-wheel; one event = 6 rows teleports and ruins precision.
|
||||||
// is the port adapted to our module structure.
|
// Heuristic on inter-event gap + direction flips:
|
||||||
//
|
//
|
||||||
// Problem: one wheel event = 1 scrolled row feels sluggish on trackpads
|
// gap < 5ms → same-batch burst → 1 row/event
|
||||||
// (which can fire 200+ events/sec) and during deliberate mouse scrolls.
|
// gap < 40ms (native) → ramp +0.3, cap 6
|
||||||
// One wheel event = 6 rows (our old WHEEL_SCROLL_STEP=6) visually
|
// gap 80-500ms (xterm.js) → mult = 1 + (mult-1)·0.5^(gap/150) + 5·decay
|
||||||
// teleports and ruins precision. The right answer depends on intent:
|
// cap 3 slow / 6 fast
|
||||||
|
// gap > 500ms → reset (deliberate click stays responsive)
|
||||||
|
// flip + flip-back ≤200ms → encoder bounce → engage wheel-mode (sticky cap)
|
||||||
|
// 5 consecutive <5ms events → trackpad flick → disengage wheel-mode
|
||||||
//
|
//
|
||||||
// precision click → 1 row/event
|
// Native terminals (Ghostty, iTerm2) and xterm.js embedders (VS Code,
|
||||||
// sustained mouse → ramp to ~15 rows/event, decay when slowing down
|
// Cursor) emit wheel events with different cadences, hence two paths.
|
||||||
// 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'
|
import { isXtermJs } from '@hermes/ink'
|
||||||
|
|
||||||
// ── Native path (ghostty, iTerm2, WezTerm, etc.) ───────────────────────
|
// ── Native (ghostty, iTerm2, WezTerm, …) ───────────────────────────────
|
||||||
const WHEEL_ACCEL_WINDOW_MS = 40
|
const WHEEL_ACCEL_WINDOW_MS = 40
|
||||||
const WHEEL_ACCEL_STEP = 0.3
|
const WHEEL_ACCEL_STEP = 0.3
|
||||||
const WHEEL_ACCEL_MAX = 6
|
const WHEEL_ACCEL_MAX = 6
|
||||||
|
|
||||||
// ── Encoder bounce / wheel-mode path (detected mechanical wheels) ──────
|
// ── Encoder bounce / wheel-mode (mechanical wheels) ────────────────────
|
||||||
const WHEEL_BOUNCE_GAP_MAX_MS = 200
|
const WHEEL_BOUNCE_GAP_MAX_MS = 200
|
||||||
const WHEEL_MODE_STEP = 15
|
const WHEEL_MODE_STEP = 15
|
||||||
const WHEEL_MODE_CAP = 15
|
const WHEEL_MODE_CAP = 15
|
||||||
const WHEEL_MODE_RAMP = 3
|
const WHEEL_MODE_RAMP = 3
|
||||||
const WHEEL_MODE_IDLE_DISENGAGE_MS = 1500
|
const WHEEL_MODE_IDLE_DISENGAGE_MS = 1500
|
||||||
|
|
||||||
// ── xterm.js path (VS Code / Cursor / browser terminals) ───────────────
|
// ── xterm.js (VS Code / Cursor / browser terminals) ────────────────────
|
||||||
const WHEEL_DECAY_HALFLIFE_MS = 150
|
const WHEEL_DECAY_HALFLIFE_MS = 150
|
||||||
const WHEEL_DECAY_STEP = 5
|
const WHEEL_DECAY_STEP = 5
|
||||||
const WHEEL_BURST_MS = 5
|
const WHEEL_BURST_MS = 5
|
||||||
|
|
@ -60,88 +43,61 @@ export type WheelAccelState = {
|
||||||
mult: number
|
mult: number
|
||||||
dir: 0 | 1 | -1
|
dir: 0 | 1 | -1
|
||||||
xtermJs: boolean
|
xtermJs: boolean
|
||||||
/** Carried fractional scroll (xterm.js only). scrollBy floors, so
|
/** Carried fractional scroll (xterm.js). scrollBy floors, so without
|
||||||
* without this a mult of 1.5 gives 1 row every time. Carrying the
|
* this a mult of 1.5 always gives 1 row; carrying the remainder gives
|
||||||
* remainder gives 1,2,1,2 on average for mult=1.5 — correct
|
* 1,2,1,2 — correct throughput over time. */
|
||||||
* throughput over time. */
|
|
||||||
frac: number
|
frac: number
|
||||||
/** Native-path baseline rows/event. Reset value on idle/reversal;
|
/** Native baseline rows/event. Reset on idle/reversal; ramp builds on
|
||||||
* ramp builds on top. xterm.js path ignores this. */
|
* top. xterm.js path ignores. */
|
||||||
base: number
|
base: number
|
||||||
/** Deferred direction flip (native only). Might be encoder bounce or
|
/** Deferred direction flip (native): bounce vs reversal — next event
|
||||||
* a real reversal — resolved by the NEXT event. */
|
* decides. */
|
||||||
pendingFlip: boolean
|
pendingFlip: boolean
|
||||||
/** Confirmed once a bounce fired (flip-then-flip-back within the
|
/** Sticky once a flip-then-flip-back fires within the bounce window.
|
||||||
* bounce window). Sticky until idle disengage or trackpad burst. */
|
* Cleared by idle disengage or trackpad burst. */
|
||||||
wheelMode: boolean
|
wheelMode: boolean
|
||||||
/** Consecutive <5ms events. Trackpad flick ≥5 → disengage wheelMode. */
|
/** Consecutive <5ms events. ≥5 → trackpad flick → disengage. */
|
||||||
burstCount: number
|
burstCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initWheelAccel(xtermJs = false, base = 1): WheelAccelState {
|
export function initWheelAccel(xtermJs = false, base = 1): WheelAccelState {
|
||||||
return {
|
return { burstCount: 0, base, dir: 0, frac: 0, mult: base, pendingFlip: false, time: 0, wheelMode: false, xtermJs }
|
||||||
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
|
/** HERMES_TUI_SCROLL_SPEED (or CLAUDE_CODE_SCROLL_SPEED for portability).
|
||||||
* portability from claude-code users). Default 1, clamped (0, 20]. */
|
* Default 1, clamped (0, 20]. */
|
||||||
export function readScrollSpeedBase(): number {
|
export function readScrollSpeedBase(): number {
|
||||||
const raw = process.env.HERMES_TUI_SCROLL_SPEED ?? process.env.CLAUDE_CODE_SCROLL_SPEED
|
const n = parseFloat(process.env.HERMES_TUI_SCROLL_SPEED ?? process.env.CLAUDE_CODE_SCROLL_SPEED ?? '')
|
||||||
|
|
||||||
if (!raw) {
|
return Number.isFinite(n) && n > 0 ? Math.min(n, 20) : 1
|
||||||
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 {
|
export function initWheelAccelForHost(): WheelAccelState {
|
||||||
return initWheelAccel(isXtermJs(), readScrollSpeedBase())
|
return initWheelAccel(isXtermJs(), readScrollSpeedBase())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Compute rows for one wheel event, mutating `state`. Returns 0 when a
|
||||||
* Compute rows for one wheel event, MUTATING the accel state. Returns 0
|
* direction flip is deferred for bounce detection — call sites should
|
||||||
* when a direction flip is deferred for bounce detection — call sites
|
* no-op on 0. */
|
||||||
* 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 {
|
export function computeWheelStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
|
||||||
if (!state.xtermJs) {
|
return state.xtermJs ? xtermJsStep(state, dir, now) : nativeStep(state, dir, now)
|
||||||
return nativeStep(state, dir, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
return xtermJsStep(state, dir, now)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
|
function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
|
||||||
// Device-switch guard ①: idle disengage. A pending bounce can mask
|
// Idle disengage runs first so a pending bounce can't mask "user paused
|
||||||
// as a real reversal via the early return below — run this first so
|
// 1.5s then mouse-clicked" as a real reversal.
|
||||||
// "user stopped for 1.5s then mouse-click" restarts at baseline.
|
|
||||||
if (state.wheelMode && now - state.time > WHEEL_MODE_IDLE_DISENGAGE_MS) {
|
if (state.wheelMode && now - state.time > WHEEL_MODE_IDLE_DISENGAGE_MS) {
|
||||||
state.wheelMode = false
|
state.wheelMode = false
|
||||||
state.burstCount = 0
|
state.burstCount = 0
|
||||||
state.mult = state.base
|
state.mult = state.base
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve any deferred flip before touching state.time/dir.
|
|
||||||
if (state.pendingFlip) {
|
if (state.pendingFlip) {
|
||||||
state.pendingFlip = false
|
state.pendingFlip = false
|
||||||
|
|
||||||
if (dir !== state.dir || now - state.time > WHEEL_BOUNCE_GAP_MAX_MS) {
|
if (dir !== state.dir || now - state.time > WHEEL_BOUNCE_GAP_MAX_MS) {
|
||||||
// Real reversal (flip persisted OR flip-back arrived too late).
|
// Real reversal (flip persisted OR flip-back too late). Commit.
|
||||||
// Commit. The deferred event's 1 row is lost (acceptable latency).
|
// The deferred event's 1 row is lost — acceptable latency.
|
||||||
state.dir = dir
|
state.dir = dir
|
||||||
state.time = now
|
state.time = now
|
||||||
state.mult = state.base
|
state.mult = state.base
|
||||||
|
|
@ -149,15 +105,12 @@ function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
|
||||||
return Math.floor(state.mult)
|
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
|
state.wheelMode = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const gap = now - state.time
|
const gap = now - state.time
|
||||||
|
|
||||||
if (dir !== state.dir && state.dir !== 0) {
|
if (dir !== state.dir && state.dir !== 0) {
|
||||||
// Direction flip. Defer — next event decides bounce vs reversal.
|
|
||||||
state.pendingFlip = true
|
state.pendingFlip = true
|
||||||
state.time = now
|
state.time = now
|
||||||
|
|
||||||
|
|
@ -169,8 +122,8 @@ function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
|
||||||
|
|
||||||
if (state.wheelMode) {
|
if (state.wheelMode) {
|
||||||
if (gap < WHEEL_BURST_MS) {
|
if (gap < WHEEL_BURST_MS) {
|
||||||
// Same-batch burst (SGR proportional reporting) OR trackpad flick.
|
// Same-batch burst (SGR proportional) OR trackpad flick. 1 row/event;
|
||||||
// Give 1 row/event; trackpad flick hits the burst-count disengage.
|
// trackpad flick trips the burst-count disengage.
|
||||||
if (++state.burstCount >= 5) {
|
if (++state.burstCount >= 5) {
|
||||||
state.wheelMode = false
|
state.wheelMode = false
|
||||||
state.burstCount = 0
|
state.burstCount = 0
|
||||||
|
|
@ -183,7 +136,6 @@ function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-check after possible disengage above.
|
|
||||||
if (state.wheelMode) {
|
if (state.wheelMode) {
|
||||||
const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS)
|
const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS)
|
||||||
const cap = Math.max(WHEEL_MODE_CAP, state.base * 2)
|
const cap = Math.max(WHEEL_MODE_CAP, state.base * 2)
|
||||||
|
|
@ -194,8 +146,8 @@ function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
|
||||||
return Math.floor(state.mult)
|
return Math.floor(state.mult)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trackpad / hi-res (native, non-wheel-mode). Tight 40ms window:
|
// Trackpad / hi-res native: tight 40ms window — sub-window ramps,
|
||||||
// sub-40ms ramps, anything slower resets to baseline.
|
// anything slower resets to baseline.
|
||||||
if (gap > WHEEL_ACCEL_WINDOW_MS) {
|
if (gap > WHEEL_ACCEL_WINDOW_MS) {
|
||||||
state.mult = state.base
|
state.mult = state.base
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -215,13 +167,11 @@ function xtermJsStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
|
||||||
state.dir = dir
|
state.dir = dir
|
||||||
|
|
||||||
if (sameDir && gap < WHEEL_BURST_MS) {
|
if (sameDir && gap < WHEEL_BURST_MS) {
|
||||||
// Same-batch burst — 1 row/event, same philosophy as native.
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sameDir || gap > WHEEL_DECAY_IDLE_MS) {
|
if (!sameDir || gap > WHEEL_DECAY_IDLE_MS) {
|
||||||
// Direction reversal or long idle: start at 2 so the first click
|
// Reversal or long idle — start at 2 so first click after a pause moves visibly.
|
||||||
// after a pause moves visibly.
|
|
||||||
state.mult = 2
|
state.mult = 2
|
||||||
state.frac = 0
|
state.frac = 0
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue