Merge branch 'feat/ink-refactor' of github.com:NousResearch/hermes-agent into feat/ink-refactor

This commit is contained in:
Brooklyn Nicholson 2026-04-11 11:29:11 -05:00
commit 7803d21bcc
10 changed files with 78 additions and 239 deletions

View file

@ -8,8 +8,8 @@ import type { Color, TextStyles } from './styles.js'
* COLORTERM=truecolor. chalk's supports-color doesn't recognize
* TERM_PROGRAM=vscode (it only knows iTerm.app/Apple_Terminal), so it falls
* through to the -256color regex level 2. At level 2, chalk.rgb()
* downgrades to the nearest 6×6×6 cube color: rgb(215,119,87) (Claude
* orange) idx 174 rgb(215,135,135) washed-out salmon.
* downgrades to the nearest 6×6×6 cube color: rgb(215,119,87) idx 174
* rgb(215,135,135) washed-out salmon.
*
* Gated on level === 2 (not < 3) to respect NO_COLOR / FORCE_COLOR=0
* those yield level 0 and are an explicit "no colors" request. Desktop VS
@ -48,13 +48,6 @@ function boostChalkLevelForXtermJs(): boolean {
* this clamps ALL truecolor output (fg+bg+hex) across the entire app.
*/
function clampChalkLevelForTmux(): boolean {
// bg.ts sets terminal-overrides :Tc before attach, so truecolor passes
// through — skip the clamp. General escape hatch for anyone who's
// configured their tmux correctly.
if (process.env.CLAUDE_CODE_TMUX_TRUECOLOR) {
return false
}
if (process.env.TMUX && chalk.level > 2) {
chalk.level = 2

View file

@ -1,7 +1,7 @@
import type { FocusManager } from './focus.js'
import { createLayoutNode } from './layout/engine.js'
import type { LayoutNode } from './layout/node.js'
import { LayoutDisplay, LayoutMeasureMode } from './layout/node.js'
import { LayoutMeasureMode } from './layout/node.js'
import measureText from './measure-text.js'
import { addPendingClear, nodeCache } from './node-cache.js'
import squashTextNodes from './squash-text-nodes.js'
@ -82,11 +82,6 @@ export type DOMElement = {
// Only set on ink-root. The document owns focus — any node can
// reach it by walking parentNode, like browser getRootNode().
focusManager?: FocusManager
// React component stack captured at createInstance time (reconciler.ts),
// e.g. ['ToolUseLoader', 'Messages', 'REPL']. Only populated when
// CLAUDE_CODE_DEBUG_REPAINTS is set. Used by findOwnerChainAtRow to
// attribute scrollback-diff full-resets to the component that caused them.
debugOwnerChain?: string[]
} & InkNode
export type TextNode = {
@ -442,44 +437,3 @@ export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => {
node.yogaNode = undefined
}
/**
* Find the React component stack responsible for content at screen row `y`.
*
* DFS the DOM tree accumulating yoga offsets. Returns the debugOwnerChain of
* the deepest node whose bounding box contains `y`. Called from ink.tsx when
* log-update triggers a full reset, to attribute the flicker to its source.
*
* Only useful when CLAUDE_CODE_DEBUG_REPAINTS is set (otherwise chains are
* undefined and this returns []).
*/
export function findOwnerChainAtRow(root: DOMElement, y: number): string[] {
let best: string[] = []
walk(root, 0)
return best
function walk(node: DOMElement, offsetY: number): void {
const yoga = node.yogaNode
if (!yoga || yoga.getDisplay() === LayoutDisplay.None) {
return
}
const top = offsetY + yoga.getComputedTop()
const height = yoga.getComputedHeight()
if (y < top || y >= top + height) {
return
}
if (node.debugOwnerChain) {
best = node.debugOwnerChain
}
for (const child of node.childNodes) {
if (isDOMElement(child)) {
walk(child, top)
}
}
}
}

View file

@ -71,8 +71,6 @@ export type Patch =
type: 'clearTerminal'
reason: FlickerReason
// Populated by log-update when a scrollback diff triggers the reset.
// ink.tsx uses triggerY with findOwnerChainAtRow to attribute the
// flicker to its source React component.
debug?: { triggerY: number; prevLine: string; nextLine: string }
}
| { type: 'cursorHide' }

View file

@ -33,7 +33,6 @@ import reconciler, {
dispatcher,
getLastCommitMs,
getLastYogaMs,
isDebugRepaintsEnabled,
recordYogaMs,
resetProfileCounters
} from './reconciler.js'
@ -509,7 +508,6 @@ export default class Ink {
'\x1b[H' +
// cursor home
(this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : '') +
// re-enable mouse (skip if CLAUDE_CODE_DISABLE_MOUSE)
(this.altScreenActive ? '' : '\x1b[?1049l') +
// exit alt (non-fullscreen only)
'\x1b[?25l' // hide cursor (Ink manages)
@ -762,19 +760,6 @@ export default class Ink {
availableHeight: frame.viewport.height,
reason: patch.reason
})
if (isDebugRepaintsEnabled() && patch.debug) {
const chain = dom.findOwnerChainAtRow(this.rootNode, patch.debug.triggerY)
logForDebugging(
`[REPAINT] full reset · ${patch.reason} · row ${patch.debug.triggerY}\n` +
` prev: "${patch.debug.prevLine}"\n` +
` next: "${patch.debug.nextLine}"\n` +
` culprit: ${chain.length ? chain.join(' < ') : '(no owner chain captured)'}`,
{
level: 'warn'
}
)
}
}
}

View file

@ -1,10 +1,5 @@
import { appendFileSync } from 'fs'
import createReconciler from 'react-reconciler'
import { getYogaCounters } from '../native-ts/yoga-layout/index.js'
import { isEnvTruthy } from '../utils/envUtils.js'
import {
appendChildNode,
clearYogaNodeReferences,
@ -150,71 +145,8 @@ function applyProp(node: DOMElement, key: string, value: unknown): void {
// --
// react-reconciler's Fiber shape — only the fields we walk. The 5th arg to
// createInstance is the Fiber (`workInProgress` in react-reconciler.dev.js).
// _debugOwner is the component that rendered this element (dev builds only);
// return is the parent fiber (always present). We prefer _debugOwner since it
// skips past Box/Text wrappers to the actual named component.
type FiberLike = {
elementType?: { displayName?: string; name?: string } | string | null
_debugOwner?: FiberLike | null
return?: FiberLike | null
}
export function getOwnerChain(fiber: unknown): string[] {
const chain: string[] = []
const seen = new Set<unknown>()
let cur = fiber as FiberLike | null | undefined
for (let i = 0; cur && i < 50; i++) {
if (seen.has(cur)) {
break
}
seen.add(cur)
const t = cur.elementType
const name =
typeof t === 'function'
? (t as { displayName?: string; name?: string }).displayName ||
(t as { displayName?: string; name?: string }).name
: typeof t === 'string'
? undefined // host element (ink-box etc) — skip
: t?.displayName || t?.name
if (name && name !== chain[chain.length - 1]) {
chain.push(name)
}
cur = cur._debugOwner ?? cur.return
}
return chain
}
let debugRepaints: boolean | undefined
export function isDebugRepaintsEnabled(): boolean {
if (debugRepaints === undefined) {
debugRepaints = isEnvTruthy(process.env.CLAUDE_CODE_DEBUG_REPAINTS)
}
return debugRepaints
}
export const dispatcher = new Dispatcher()
// --- COMMIT INSTRUMENTATION (temp debugging) ---
const COMMIT_LOG = process.env.CLAUDE_CODE_COMMIT_LOG
let _commits = 0
let _lastLog = 0
let _lastCommitAt = 0
let _maxGapMs = 0
let _createCount = 0
let _prepareAt = 0
// --- END ---
// --- SCROLL PROFILING (bench/scroll-e2e.sh reads via getLastYogaMs) ---
// Set by onComputeLayout wrapper in ink.tsx; read by onRender for phases.
let _lastYogaMs = 0
@ -261,67 +193,17 @@ const reconciler = createReconciler<
null
>({
getRootHostContext: () => ({ isInsideText: false }),
prepareForCommit: () => {
if (COMMIT_LOG) {
_prepareAt = performance.now()
}
return null
},
prepareForCommit: () => null,
preparePortalMount: () => null,
clearContainer: () => false,
resetAfterCommit(rootNode) {
_lastCommitMs = _commitStart > 0 ? performance.now() - _commitStart : 0
_commitStart = 0
if (COMMIT_LOG) {
const now = performance.now()
_commits++
const gap = _lastCommitAt > 0 ? now - _lastCommitAt : 0
if (gap > _maxGapMs) {
_maxGapMs = gap
}
_lastCommitAt = now
const reconcileMs = _prepareAt > 0 ? now - _prepareAt : 0
if (gap > 30 || reconcileMs > 20 || _createCount > 50) {
appendFileSync(
COMMIT_LOG,
`${now.toFixed(1)} gap=${gap.toFixed(1)}ms reconcile=${reconcileMs.toFixed(1)}ms creates=${_createCount}\n`
)
}
_createCount = 0
if (now - _lastLog > 1000) {
appendFileSync(COMMIT_LOG, `${now.toFixed(1)} commits=${_commits}/s maxGap=${_maxGapMs.toFixed(1)}ms\n`)
_commits = 0
_maxGapMs = 0
_lastLog = now
}
}
const _t0 = COMMIT_LOG ? performance.now() : 0
if (typeof rootNode.onComputeLayout === 'function') {
rootNode.onComputeLayout()
}
if (COMMIT_LOG) {
const layoutMs = performance.now() - _t0
if (layoutMs > 20) {
const c = getYogaCounters()
appendFileSync(
COMMIT_LOG,
`${_t0.toFixed(1)} SLOW_YOGA ${layoutMs.toFixed(1)}ms visited=${c.visited} measured=${c.measured} hits=${c.cacheHits} live=${c.live}\n`
)
}
}
if (process.env.NODE_ENV === 'test') {
if (rootNode.childNodes.length === 0 && rootNode.hasRenderedContent) {
return
@ -336,16 +218,7 @@ const reconciler = createReconciler<
return
}
const _tr = COMMIT_LOG ? performance.now() : 0
rootNode.onRender?.()
if (COMMIT_LOG) {
const renderMs = performance.now() - _tr
if (renderMs > 10) {
appendFileSync(COMMIT_LOG, `${_tr.toFixed(1)} SLOW_PAINT ${renderMs.toFixed(1)}ms\n`)
}
}
},
getChildHostContext(parentHostContext: HostContext, type: ElementNames): HostContext {
const previousIsInsideText = parentHostContext.isInsideText
@ -364,7 +237,7 @@ const reconciler = createReconciler<
newProps: Props,
_root: DOMElement,
hostContext: HostContext,
internalHandle?: unknown
_internalHandle?: unknown
): DOMElement {
if (hostContext.isInsideText && originalType === 'ink-box') {
throw new Error(`<Box> can't be nested inside <Text> component`)
@ -374,18 +247,10 @@ const reconciler = createReconciler<
const node = createNode(type)
if (COMMIT_LOG) {
_createCount++
}
for (const [key, value] of Object.entries(newProps)) {
applyProp(node, key, value)
}
if (isDebugRepaintsEnabled()) {
node.debugOwnerChain = getOwnerChain(internalHandle)
}
return node
},
createTextInstance(text: string, _root: DOMElement, hostContext: HostContext): TextNode {

View file

@ -12,7 +12,7 @@
import { clamp } from './layout/geometry.js'
import type { Screen, StylePool } from './screen.js'
import { cellAt, cellAtIndex, CellWidth, setCellStyleId } from './screen.js'
import { CellWidth, cellAt, cellAtIndex, setCellStyleId } from './screen.js'
type Point = { col: number; row: number }
@ -133,7 +133,6 @@ export function clearSelection(s: SelectionState): void {
// Unicode-aware word character matcher: letters (any script), digits,
// and the punctuation set iTerm2 treats as word-part by default.
// Matching iTerm2's default means double-clicking a path like
// `/usr/bin/bash` or `~/.claude/config.json` selects the whole thing,
// which is the muscle memory most macOS terminal users have.
// iTerm2 default "characters considered part of a word": /-+\~_.
const WORD_CHAR = /[\p{L}\p{N}_/.\-+~\\]/u

View file

@ -138,7 +138,7 @@ export function isSynchronizedOutputSupported(): boolean {
// -- XTVERSION-detected terminal name (populated async at startup) --
//
// TERM_PROGRAM is not forwarded over SSH by default, so env-based detection
// fails when claude runs remotely inside a VS Code integrated terminal.
// fails when the process runs remotely inside a VS Code integrated terminal.
// XTVERSION (CSI > 0 q → DCS > | name ST) goes through the pty — the query
// reaches the *client* terminal and the reply comes back through stdin.
// App.tsx fires the query when raw mode enables; setXtversionName() is called