Revert "Revert "feat: 第一个可以用的 ink 组件抽象 (#158)" (#175)"

This reverts commit 88d4c3ba24.
This commit is contained in:
claude-code-best
2026-04-07 16:17:48 +08:00
parent 4e1e681a46
commit e5782e732c
645 changed files with 7312 additions and 1272 deletions

View File

@@ -17,6 +17,7 @@
"@anthropic-ai/sandbox-runtime": "^0.0.44",
"@anthropic-ai/sdk": "^0.80.0",
"@anthropic-ai/vertex-sdk": "^0.14.4",
"@anthropic/ink": "workspace:*",
"@aws-sdk/client-bedrock": "^3.1020.0",
"@aws-sdk/client-bedrock-runtime": "^3.1020.0",
"@aws-sdk/client-sts": "^3.1020.0",
@@ -138,6 +139,29 @@
"name": "@ant/computer-use-swift",
"version": "1.0.0",
},
"packages/@ant/ink": {
"name": "@anthropic/ink",
"version": "1.0.0",
"dependencies": {
"auto-bind": "^5.0.1",
"bidi-js": "^1.0.3",
"chalk": "^5.6.2",
"cli-boxes": "^4.0.1",
"emoji-regex": "^10.6.0",
"figures": "^6.1.0",
"get-east-asian-width": "^1.5.0",
"indent-string": "^5.0.0",
"lodash-es": "^4.17.23",
"react": "^19.2.4",
"react-reconciler": "^0.33.0",
"signal-exit": "^4.1.0",
"strip-ansi": "^7.2.0",
"supports-hyperlinks": "^4.4.0",
"type-fest": "^5.5.0",
"usehooks-ts": "^3.1.1",
"wrap-ansi": "^10.0.0",
},
},
"packages/audio-capture-napi": {
"name": "audio-capture-napi",
"version": "1.0.0",
@@ -190,6 +214,8 @@
"@anthropic-ai/vertex-sdk": ["@anthropic-ai/vertex-sdk@0.14.4", "https://registry.npmmirror.com/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.14.4.tgz", { "dependencies": { "@anthropic-ai/sdk": ">=0.50.3 <1", "google-auth-library": "^9.4.2" } }, "sha512-BZUPRWghZxfSFtAxU563wH+jfWBPoedAwsVxG35FhmNsjeV8tyfN+lFriWhCpcZApxA4NdT6Soov+PzfnxxD5g=="],
"@anthropic/ink": ["@anthropic/ink@workspace:packages/@ant/ink"],
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "https://registry.npmmirror.com/@aws-crypto/crc32/-/crc32-5.2.0.tgz", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "https://registry.npmmirror.com/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
@@ -1198,7 +1224,7 @@
"open": ["open@10.2.0", "https://registry.npmmirror.com/open/-/open-10.2.0.tgz", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"openai": ["openai@6.33.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw=="],
"openai": ["openai@6.33.0", "https://registry.npmmirror.com/openai/-/openai-6.33.0.tgz", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw=="],
"os-tmpdir": ["os-tmpdir@1.0.2", "https://registry.npmmirror.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="],

View File

@@ -131,6 +131,7 @@
"highlight.js": "^11.11.1",
"https-proxy-agent": "^8.0.0",
"ignore": "^7.0.5",
"@anthropic/ink": "workspace:*",
"image-processor-napi": "workspace:*",
"indent-string": "^5.0.0",
"jsonc-parser": "^3.3.1",

View File

@@ -0,0 +1,30 @@
{
"name": "@anthropic/ink",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"auto-bind": "^5.0.1",
"bidi-js": "^1.0.3",
"chalk": "^5.6.2",
"cli-boxes": "^4.0.1",
"emoji-regex": "^10.6.0",
"figures": "^6.1.0",
"get-east-asian-width": "^1.5.0",
"indent-string": "^5.0.0",
"lodash-es": "^4.17.23",
"react": "^19.2.4",
"react-reconciler": "^0.33.0",
"signal-exit": "^4.1.0",
"strip-ansi": "^7.2.0",
"supports-hyperlinks": "^4.4.0",
"type-fest": "^5.5.0",
"usehooks-ts": "^3.1.1",
"wrap-ansi": "^10.0.0"
}
}

View File

@@ -3,14 +3,14 @@ import React, {
useContext,
useInsertionEffect,
} from 'react'
import instances from '../instances.js'
import instances from '../core/instances.js'
import {
DISABLE_MOUSE_TRACKING,
ENABLE_MOUSE_TRACKING,
ENTER_ALT_SCREEN,
EXIT_ALT_SCREEN,
} from '../termio/dec.js'
import { TerminalWriteContext } from '../useTerminalNotification.js'
} from '../core/termio/dec.js'
import { TerminalWriteContext } from '../hooks/useTerminalNotification.js'
import Box from './Box.js'
import { TerminalSizeContext } from './TerminalSizeContext.js'

View File

@@ -1,37 +1,62 @@
import React, { PureComponent, type ReactNode } from 'react'
import { updateLastInteractionTime } from '../../bootstrap/state.js'
import { logForDebugging } from '../../utils/debug.js'
import { stopCapturingEarlyInput } from '../../utils/earlyInput.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { isMouseClicksDisabled } from '../../utils/fullscreen.js'
import { logError } from '../../utils/log.js'
import { EventEmitter } from '../events/emitter.js'
import { InputEvent } from '../events/input-event.js'
import { TerminalFocusEvent } from '../events/terminal-focus-event.js'
// Business-layer callbacks — replaced with inline defaults so this package
// has zero dependencies on business code. The business layer can inject
// implementations via AppCallbacks when needed.
type AppCallbacks = {
updateLastInteractionTime?: () => void
stopCapturingEarlyInput?: () => void
isMouseClicksDisabled?: () => boolean
logError?: (error: unknown) => void
logForDebugging?: (message: string, opts?: { level?: string }) => void
}
/** Default no-op / safe-default implementations */
const defaultCallbacks: Required<AppCallbacks> = {
updateLastInteractionTime: () => {},
stopCapturingEarlyInput: () => {},
isMouseClicksDisabled: () => false,
logError: (error: unknown) => console.error(error),
logForDebugging: (_message: string, _opts?: { level?: string }) => {},
}
/**
* Override the default no-op callbacks. Call this from the business layer
* (e.g. src/ink.tsx) before mounting <App>.
*/
export function setAppCallbacks(cb: AppCallbacks): void {
Object.assign(defaultCallbacks, cb)
}
function isEnvTruthy(value: string | undefined): boolean {
return value === '1' || value === 'true'
}
import { EventEmitter } from '../core/events/emitter.js'
import { InputEvent } from '../core/events/input-event.js'
import { TerminalFocusEvent } from '../core/events/terminal-focus-event.js'
import {
INITIAL_STATE,
type ParsedInput,
type ParsedKey,
type ParsedMouse,
parseMultipleKeypresses,
} from '../parse-keypress.js'
import reconciler from '../reconciler.js'
} from '../core/parse-keypress.js'
import reconciler from '../core/reconciler.js'
import {
finishSelection,
hasSelection,
type SelectionState,
startSelection,
} from '../selection.js'
} from '../core/selection.js'
import {
isXtermJs,
setXtversionName,
supportsExtendedKeys,
} from '../terminal.js'
} from '../core/terminal.js'
import {
getTerminalFocused,
setTerminalFocused,
} from '../terminal-focus-state.js'
import { TerminalQuerier, xtversion } from '../terminal-querier.js'
} from '../core/terminal-focus-state.js'
import { TerminalQuerier, xtversion } from '../core/terminal-querier.js'
import {
DISABLE_KITTY_KEYBOARD,
DISABLE_MODIFY_OTHER_KEYS,
@@ -39,7 +64,7 @@ import {
ENABLE_MODIFY_OTHER_KEYS,
FOCUS_IN,
FOCUS_OUT,
} from '../termio/csi.js'
} from '../core/termio/csi.js'
import {
DBP,
DFE,
@@ -48,7 +73,7 @@ import {
EFE,
HIDE_CURSOR,
SHOW_CURSOR,
} from '../termio/dec.js'
} from '../core/termio/dec.js'
import AppContext from './AppContext.js'
import { ClockProvider } from './ClockContext.js'
import CursorDeclarationContext, {
@@ -292,7 +317,7 @@ export default class App extends PureComponent<Props, State> {
// Both use the same stdin 'readable' + read() pattern, so they can't
// coexist -- our handler would drain stdin before Ink's can see it.
// The buffered text is preserved for REPL.tsx via consumeEarlyInput().
stopCapturingEarlyInput()
defaultCallbacks.stopCapturingEarlyInput()
stdin.ref()
stdin.setRawMode(true)
stdin.addListener('readable', this.handleReadable)
@@ -324,9 +349,9 @@ export default class App extends PureComponent<Props, State> {
]).then(([r]) => {
if (r) {
setXtversionName(r.name)
logForDebugging(`XTVERSION: terminal identified as "${r.name}"`)
defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`)
} else {
logForDebugging('XTVERSION: no reply (terminal ignored query)')
defaultCallbacks.logForDebugging('XTVERSION: no reply (terminal ignored query)')
}
})
})
@@ -436,7 +461,7 @@ export default class App extends PureComponent<Props, State> {
// permanently wedge the stream: data stays buffered and 'readable'
// never re-emits. Catching here ensures the stream stays healthy so
// subsequent keystrokes are still delivered.
logError(error)
defaultCallbacks.logError(error)
// Re-attach the listener in case the exception detached it.
// Bun may remove the listener after an error; without this,
@@ -446,7 +471,7 @@ export default class App extends PureComponent<Props, State> {
this.rawModeEnabledCount > 0 &&
!stdin.listeners('readable').includes(this.handleReadable)
) {
logForDebugging(
defaultCallbacks.logForDebugging(
'handleReadable: re-attaching stdin readable listener after error recovery',
{ level: 'warn' },
)
@@ -556,7 +581,7 @@ function processKeysInBatch(
!((i.button & 0x20) !== 0 && (i.button & 0x03) === 3)),
)
) {
updateLastInteractionTime()
defaultCallbacks.updateLastInteractionTime()
}
for (const item of items) {
@@ -625,7 +650,7 @@ function processKeysInBatch(
export function handleMouseEvent(app: App, m: ParsedMouse): void {
// Allow disabling click handling while keeping wheel scroll (which goes
// through the keybinding system as 'wheelup'/'wheeldown', not here).
if (isMouseClicksDisabled()) return
if (defaultCallbacks.isMouseClicksDisabled()) return
const sel = app.props.selection
// Terminal coords are 1-indexed; screen buffer is 0-indexed

View File

@@ -1,11 +1,11 @@
import React, { type PropsWithChildren, type Ref } from 'react'
import type { Except } from 'type-fest'
import type { DOMElement } from '../dom.js'
import type { ClickEvent } from '../events/click-event.js'
import type { FocusEvent } from '../events/focus-event.js'
import type { KeyboardEvent } from '../events/keyboard-event.js'
import type { Styles } from '../styles.js'
import * as warn from '../warn.js'
import type { DOMElement } from '../core/dom.js'
import type { ClickEvent } from '../core/events/click-event.js'
import type { FocusEvent } from '../core/events/focus-event.js'
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
import type { Styles } from '../core/styles.js'
import * as warn from '../core/warn.js'
export type Props = Except<Styles, 'textWrap'> & {
ref?: Ref<DOMElement>

View File

@@ -6,11 +6,11 @@ import React, {
useState,
} from 'react'
import type { Except } from 'type-fest'
import type { DOMElement } from '../dom.js'
import type { ClickEvent } from '../events/click-event.js'
import type { FocusEvent } from '../events/focus-event.js'
import type { KeyboardEvent } from '../events/keyboard-event.js'
import type { Styles } from '../styles.js'
import type { DOMElement } from '../core/dom.js'
import type { ClickEvent } from '../core/events/click-event.js'
import type { FocusEvent } from '../core/events/focus-event.js'
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
import type { Styles } from '../core/styles.js'
import Box from './Box.js'
type ButtonState = {

View File

@@ -1,5 +1,5 @@
import React, { createContext, useEffect, useState } from 'react'
import { FRAME_INTERVAL_MS } from '../constants.js'
import { FRAME_INTERVAL_MS } from '../core/constants.js'
import { useTerminalFocus } from '../hooks/use-terminal-focus.js'
export type Clock = {

View File

@@ -1,5 +1,5 @@
import { createContext } from 'react'
import type { DOMElement } from '../dom.js'
import type { DOMElement } from '../core/dom.js'
export type CursorDeclaration = {
/** Display column (terminal cell width) within the declared node */

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import React from 'react'
import { supportsHyperlinks } from '../supports-hyperlinks.js'
import { supportsHyperlinks } from '../core/supports-hyperlinks.js'
import Text from './Text.js'
export type Props = {

View File

@@ -6,11 +6,10 @@ import React, {
useState,
} from 'react'
import type { Except } from 'type-fest'
import { markScrollActivity } from '../../bootstrap/state.js'
import type { DOMElement } from '../dom.js'
import { markDirty, scheduleRenderFrom } from '../dom.js'
import { markCommitStart } from '../reconciler.js'
import type { Styles } from '../styles.js'
import type { DOMElement } from '../core/dom.js'
import { markDirty, scheduleRenderFrom } from '../core/dom.js'
import { markCommitStart } from '../core/reconciler.js'
import type { Styles } from '../core/styles.js'
import Box from './Box.js'
export type ScrollBoxHandle = {
@@ -116,7 +115,7 @@ function ScrollBox({
// Signal background intervals (IDE poll, LSP poll, GCS fetch, orphan
// check) to skip their next tick — they compete for the event loop and
// contributed to 1402ms max frame gaps during scroll drain.
markScrollActivity()
// noop — injected by business layer via onScrollActivity callback
markDirty(el)
markCommitStart()
notify()

View File

@@ -1,6 +1,6 @@
import { createContext } from 'react'
import { EventEmitter } from '../events/emitter.js'
import type { TerminalQuerier } from '../terminal-querier.js'
import { EventEmitter } from '../core/events/emitter.js'
import type { TerminalQuerier } from '../core/terminal-querier.js'
export type Props = {
/**

View File

@@ -4,7 +4,7 @@ import {
getTerminalFocusState,
subscribeTerminalFocus,
type TerminalFocusState,
} from '../terminal-focus-state.js'
} from '../core/terminal-focus-state.js'
export type { TerminalFocusState }

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import React from 'react'
import type { Color, Styles, TextStyles } from '../styles.js'
import type { Color, Styles, TextStyles } from '../core/styles.js'
type BaseProps = {
/**

View File

@@ -1,6 +1,6 @@
import React from 'react'
import Link from './components/Link.js'
import Text from './components/Text.js'
import Link from '../components/Link.js'
import Text from '../components/Text.js'
import type { Color } from './styles.js'
import {
type NamedColor,

View File

@@ -4,10 +4,14 @@ import {
DiscreteEventPriority,
NoEventPriority,
} from 'react-reconciler/constants.js'
import { logError } from '../../utils/log.js'
import { HANDLER_FOR_EVENT } from './event-handlers.js'
import type { EventTarget, TerminalEvent } from './terminal-event.js'
// logError stub — replaced from business dependency; use injected logger in production
const logError = (error: unknown): void => {
console.error(error)
}
// --
type DispatchListener = {

View File

@@ -12,17 +12,14 @@ import React, { type ReactNode } from 'react'
import type { FiberRoot } from 'react-reconciler'
import { ConcurrentRoot } from 'react-reconciler/constants.js'
import { onExit } from 'signal-exit'
import { flushInteractionTime } from 'src/bootstrap/state.js'
import { getYogaCounters } from 'src/native-ts/yoga-layout/index.js'
import { logForDebugging } from 'src/utils/debug.js'
import { logError } from 'src/utils/log.js'
import { getYogaCounters } from './yoga-layout/index.js'
import { format } from 'util'
import { colorize } from './colorize.js'
import App from './components/App.js'
import App from '../components/App.js'
import type {
CursorDeclaration,
CursorDeclarationSetter,
} from './components/CursorDeclarationContext.js'
} from '../components/CursorDeclarationContext.js'
import { FRAME_INTERVAL_MS } from './constants.js'
import * as dom from './dom.js'
import { KeyboardEvent } from './events/keyboard-event.js'
@@ -116,7 +113,7 @@ import {
supportsTabStatus,
wrapForMultiplexer,
} from './termio/osc.js'
import { TerminalWriteProvider } from './useTerminalNotification.js'
import { TerminalWriteProvider } from '../hooks/useTerminalNotification.js'
// Alt-screen: renderer.ts sets cursor.visible = !isTTY || screen.height===0,
// which is always false in alt-screen (TTY + content fills screen).
@@ -140,6 +137,11 @@ function makeAltScreenParkPatch(terminalRows: number) {
})
}
export type Logger = {
debug(message: string, options?: { level?: string }): void
error(error: Error | unknown): void
}
export type Options = {
stdout: NodeJS.WriteStream
stdin: NodeJS.ReadStream
@@ -148,6 +150,16 @@ export type Options = {
patchConsole: boolean
waitUntilExit?: () => Promise<void>
onFrame?: (event: FrameEvent) => void
/** Called before each render cycle. Replaces flushInteractionTime(). */
onBeforeRender?: () => void
/** Injected logger. Replaces logForDebugging / logError imports. */
logger?: Logger
}
/** No-op logger used when no logger is injected. */
const noopLogger: Logger = {
debug() {},
error() {},
}
export default class Ink {
@@ -240,9 +252,11 @@ export default class Ink {
// for log-update's relative-move invariants). Alt-screen doesn't need
// this — every frame begins with CSI H. null = no move emitted last frame.
private displayCursor: { x: number; y: number } | null = null
private readonly logger: Logger
constructor(private readonly options: Options) {
autoBind(this)
this.logger = options.logger ?? noopLogger
if (this.options.patchConsole) {
this.restoreConsole = this.patchConsole()
@@ -533,7 +547,7 @@ export default class Ink {
// Date.now() at most once per frame instead of once per keypress.
// Done before the render to avoid dirtying state that would trigger
// an extra React re-render cycle.
flushInteractionTime()
this.options.onBeforeRender?.()
const renderStart = performance.now()
const terminalWidth = this.options.stdout.columns || 80
@@ -754,7 +768,7 @@ export default class Ink {
this.rootNode,
patch.debug.triggerY,
)
logForDebugging(
this.logger.debug(
`[REPAINT] full reset · ${patch.reason} · row ${patch.debug.triggerY}\n` +
` prev: "${patch.debug.prevLine}"\n` +
` next: "${patch.debug.nextLine}"\n` +
@@ -1274,7 +1288,7 @@ export default class Ink {
// correctly. One extra paint of this message, but correct > fast.
dom.markDirty(el)
const positions = scanPositions(rendered, this.searchHighlightQuery)
logForDebugging(
this.logger.debug(
`scanElementSubtree: q='${this.searchHighlightQuery}' ` +
`el=${width}x${height}@(${elLeft},${elTop}) n=${positions.length} ` +
`[${positions
@@ -1580,7 +1594,7 @@ export default class Ink {
// Store and remove all 'readable' event listeners temporarily
// This prevents Ink from consuming stdin while the editor is active
const readableListeners = stdin.listeners('readable')
logForDebugging(
this.logger.debug(
`[stdin] suspendStdin: removing ${readableListeners.length} readable listener(s), wasRawMode=${(stdin as NodeJS.ReadStream & { isRaw?: boolean }).isRaw ?? false}`,
)
readableListeners.forEach(listener => {
@@ -1610,12 +1624,12 @@ export default class Ink {
// Re-attach all the stored listeners
if (this.stdinListeners.length === 0 && !this.wasRawMode) {
logForDebugging(
this.logger.debug(
'[stdin] resumeStdin: called with no stored listeners and wasRawMode=false (possible desync)',
{ level: 'warn' },
)
}
logForDebugging(
this.logger.debug(
`[stdin] resumeStdin: re-attaching ${this.stdinListeners.length} listener(s), wasRawMode=${this.wasRawMode}`,
)
this.stdinListeners.forEach(({ event, listener }) => {
@@ -1833,9 +1847,9 @@ export default class Ink {
const con = console
const originals: Partial<Record<keyof Console, Console[keyof Console]>> = {}
const toDebug = (...args: unknown[]) =>
logForDebugging(`console.log: ${format(...args)}`)
this.logger.debug(`console.log: ${format(...args)}`)
const toError = (...args: unknown[]) =>
logError(new Error(`console.error: ${format(...args)}`))
this.logger.error(new Error(`console.error: ${format(...args)}`))
for (const m of CONSOLE_STDOUT_METHODS) {
originals[m] = con[m]
con[m] = toDebug
@@ -1873,7 +1887,7 @@ export default class Ink {
cb?: (err?: Error) => void,
): boolean => {
const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb
// Reentrancy guard: logForDebugging → writeToStderr → here. Pass
// Reentrancy guard: logger.debug → writeToStderr → here. Pass
// through to the original so --debug-to-stderr still works and we
// don't stack-overflow.
if (reentered) {
@@ -1887,7 +1901,7 @@ export default class Ink {
typeof chunk === 'string'
? chunk
: Buffer.from(chunk).toString('utf8')
logForDebugging(`[stderr] ${text}`, { level: 'warn' })
this.logger.debug(`[stderr] ${text}`, { level: 'warn' })
if (this.altScreenActive && !this.isUnmounted && !this.isPaused) {
this.prevFrameContaminated = true
this.scheduleRender()

View File

@@ -11,7 +11,7 @@ import Yoga, {
PositionType,
Wrap,
type Node as YogaNode,
} from 'src/native-ts/yoga-layout/index.js'
} from '../yoga-layout/index.js'
import {
type LayoutAlign,
LayoutDisplay,

View File

@@ -3,7 +3,8 @@ import {
ansiCodesToString,
diffAnsiCodes,
} from '@alcalzone/ansi-tokenize'
import { logForDebugging } from '../utils/debug.js'
/** Debug logger — no-op placeholder until proper logger injection is added */
const logForDebugging = (_message: string) => {}
import type { Diff, FlickerReason, Frame } from './frame.js'
import type { Point } from './layout/geometry.js'
import {

View File

@@ -4,9 +4,11 @@ import {
styledCharsFromTokens,
tokenize,
} from '@alcalzone/ansi-tokenize'
import { logForDebugging } from '../utils/debug.js'
import { getGraphemeSegmenter } from '../utils/intl.js'
import sliceAnsi from '../utils/sliceAnsi.js'
import { getGraphemeSegmenter } from './utils/grapheme.js'
import sliceAnsi from './utils/sliceAnsi.js'
/** Debug logger — no-op placeholder until proper logger injection is added */
const logForDebugging = (_message: string) => {}
import { reorderBidi } from './bidi.js'
import { type Rectangle, unionRect } from './layout/geometry.js'
import {

View File

@@ -1,9 +1,7 @@
/* eslint-disable custom-rules/no-top-level-side-effects */
import { appendFileSync } from 'fs'
import createReconciler from 'react-reconciler'
import { getYogaCounters } from 'src/native-ts/yoga-layout/index.js'
import { isEnvTruthy } from '../utils/envUtils.js'
import { getYogaCounters } from './yoga-layout/index.js'
import {
appendChildNode,
clearYogaNodeReferences,
@@ -179,15 +177,16 @@ export function getOwnerChain(fiber: unknown): string[] {
let debugRepaints: boolean | undefined
export function isDebugRepaintsEnabled(): boolean {
if (debugRepaints === undefined) {
debugRepaints = isEnvTruthy(process.env.CLAUDE_CODE_DEBUG_REPAINTS)
debugRepaints = process.env.CLAUDE_CODE_DEBUG_REPAINTS === '1'
}
return debugRepaints
}
export const dispatcher = new Dispatcher()
// --- COMMIT INSTRUMENTATION (temp debugging) ---
// eslint-disable-next-line custom-rules/no-process-env-top-level -- debug instrumentation, read-once is fine
// --- COMMIT INSTRUMENTATION (debug logging) ---
// Uses console.warn instead of fs.appendFileSync to avoid filesystem dependencies.
// Set CLAUDE_CODE_COMMIT_LOG=1 to enable debug logging to stderr.
const COMMIT_LOG = process.env.CLAUDE_CODE_COMMIT_LOG
let _commits = 0
let _lastLog = 0
@@ -195,6 +194,12 @@ let _lastCommitAt = 0
let _maxGapMs = 0
let _createCount = 0
let _prepareAt = 0
/** Debug log helper — replaces fs.appendFileSync with console.warn. */
function debugLog(message: string): void {
// biome-ignore lint/suspicious/noConsole: debug instrumentation
console.warn(`[ink-commit] ${message}`)
}
// --- END ---
// --- SCROLL PROFILING (bench/scroll-e2e.sh reads via getLastYogaMs) ---
@@ -255,18 +260,14 @@ const reconciler = createReconciler<
_lastCommitAt = now
const reconcileMs = _prepareAt > 0 ? now - _prepareAt : 0
if (gap > 30 || reconcileMs > 20 || _createCount > 50) {
// eslint-disable-next-line custom-rules/no-sync-fs -- debug instrumentation
appendFileSync(
COMMIT_LOG,
`${now.toFixed(1)} gap=${gap.toFixed(1)}ms reconcile=${reconcileMs.toFixed(1)}ms creates=${_createCount}\n`,
debugLog(
`${now.toFixed(1)} gap=${gap.toFixed(1)}ms reconcile=${reconcileMs.toFixed(1)}ms creates=${_createCount}`,
)
}
_createCount = 0
if (now - _lastLog > 1000) {
// eslint-disable-next-line custom-rules/no-sync-fs -- debug instrumentation
appendFileSync(
COMMIT_LOG,
`${now.toFixed(1)} commits=${_commits}/s maxGap=${_maxGapMs.toFixed(1)}ms\n`,
debugLog(
`${now.toFixed(1)} commits=${_commits}/s maxGap=${_maxGapMs.toFixed(1)}ms`,
)
_commits = 0
_maxGapMs = 0
@@ -281,10 +282,8 @@ const reconciler = createReconciler<
const layoutMs = performance.now() - _t0
if (layoutMs > 20) {
const c = getYogaCounters()
// eslint-disable-next-line custom-rules/no-sync-fs -- debug instrumentation
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`,
debugLog(
`${_t0.toFixed(1)} SLOW_YOGA ${layoutMs.toFixed(1)}ms visited=${c.visited} measured=${c.measured} hits=${c.cacheHits} live=${c.live}`,
)
}
}
@@ -305,10 +304,8 @@ const reconciler = createReconciler<
if (COMMIT_LOG) {
const renderMs = performance.now() - _tr
if (renderMs > 10) {
// eslint-disable-next-line custom-rules/no-sync-fs -- debug instrumentation
appendFileSync(
COMMIT_LOG,
`${_tr.toFixed(1)} SLOW_PAINT ${renderMs.toFixed(1)}ms\n`,
debugLog(
`${_tr.toFixed(1)} SLOW_PAINT ${renderMs.toFixed(1)}ms`,
)
}
}

View File

@@ -1,7 +1,8 @@
import noop from 'lodash-es/noop.js'
import type { ReactElement } from 'react'
import { LegacyRoot } from 'react-reconciler/constants.js'
import { logForDebugging } from '../utils/debug.js'
/** Debug logger — no-op placeholder until proper logger injection is added */
const logForDebugging = (_message: string) => {}
import { createNode, type DOMElement } from './dom.js'
import { FocusManager } from './focus.js'
import Output from './output.js'

View File

@@ -1,4 +1,5 @@
import { logForDebugging } from 'src/utils/debug.js'
/** Debug logger — no-op placeholder until proper logger injection is added */
const logForDebugging = (_message: string, _opts?: { level?: string }) => {}
import { type DOMElement, markDirty } from './dom.js'
import type { Frame } from './frame.js'
import { consumeAbsoluteRemovedFlag } from './node-cache.js'

View File

@@ -1,5 +1,4 @@
import type { ReactNode } from 'react'
import { logForDebugging } from 'src/utils/debug.js'
import { Stream } from 'stream'
import type { FrameEvent } from './frame.js'
import Ink, { type Options as InkOptions } from './ink.js'
@@ -114,9 +113,12 @@ const wrappedRender = async (
// write overwrites scrollback instead of appending below the logo.
await Promise.resolve()
const instance = renderSync(node, options)
logForDebugging(
`[render] first ink render: ${Math.round(process.uptime() * 1000)}ms since process start`,
)
if (process.env.CLAUDE_CODE_DEBUG_REPAINTS === '1') {
// biome-ignore lint/suspicious/noConsole: debug instrumentation
console.warn(
`[render] first ink render: ${Math.round(process.uptime() * 1000)}ms since process start`,
)
}
return instance
}

View File

@@ -1,7 +1,7 @@
import emojiRegex from 'emoji-regex'
import { eastAsianWidth } from 'get-east-asian-width'
import stripAnsi from 'strip-ansi'
import { getGraphemeSegmenter } from '../utils/intl.js'
import { getGraphemeSegmenter } from './utils/grapheme.js'
const EMOJI_REGEX = emojiRegex()

View File

@@ -1,7 +1,5 @@
import { coerce } from 'semver'
import { coerce, gte } from 'semver'
import type { Writable } from 'stream'
import { env } from '../utils/env.js'
import { gte } from '../utils/semver.js'
import { getClearTerminalSequence } from './clearTerminal.js'
import type { Diff } from './frame.js'
import { cursorMove, cursorTo, eraseLines } from './termio/csi.js'
@@ -165,7 +163,7 @@ const EXTENDED_KEYS_TERMINALS = [
/** True if this terminal correctly handles extended key reporting
* (Kitty keyboard protocol + xterm modifyOtherKeys). */
export function supportsExtendedKeys(): boolean {
return EXTENDED_KEYS_TERMINALS.includes(env.terminal ?? '')
return EXTENDED_KEYS_TERMINALS.includes(process.env.TERM_PROGRAM ?? '')
}
/** True if the terminal scrolls the viewport when it receives cursor-up

View File

@@ -3,9 +3,26 @@
*/
import { Buffer } from 'buffer'
import { env } from '../../utils/env.js'
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
import { execFile as nodeExecFile } from 'child_process'
import { BEL, ESC, ESC_TYPE, SEP } from './ansi.js'
/** Promise-based wrapper around child_process.execFile */
function execFileNoThrow(
command: string,
args: string[],
options: { input?: string; useCwd?: boolean; timeout?: number } = {},
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise(resolve => {
const { input, timeout } = options
const proc = nodeExecFile(command, args, { timeout }, (error, stdout, stderr) => {
resolve({ code: error ? 1 : 0, stdout: stdout ?? '', stderr: stderr ?? '' })
})
if (input && proc.stdin) {
proc.stdin.write(input)
proc.stdin.end()
}
})
}
import type { Action, Color, TabStatusAction } from './types.js'
export const OSC_PREFIX = ESC + String.fromCharCode(ESC_TYPE.OSC)
@@ -16,7 +33,7 @@ export const ST = ESC + '\\'
/** Generate an OSC sequence: ESC ] p1;p2;...;pN <terminator>
* Uses ST terminator for Kitty (avoids beeps), BEL for others */
export function osc(...parts: (string | number)[]): string {
const terminator = env.terminal === 'kitty' ? ST : BEL
const terminator = process.env.TERM_PROGRAM === 'kitty' ? ST : BEL
return `${OSC_PREFIX}${parts.join(SEP)}${terminator}`
}

View File

@@ -11,7 +11,7 @@
* - Style tracking: maintains current text style state
*/
import { getGraphemeSegmenter } from '../../utils/intl.js'
import { getGraphemeSegmenter } from '../utils/grapheme.js'
import { C0 } from './ansi.js'
import { CSI, CURSOR_STYLES, ERASE_DISPLAY, ERASE_LINE_REGION } from './csi.js'
import { DEC } from './dec.js'

View File

@@ -0,0 +1,53 @@
/**
* Shared Intl object instances with lazy initialization.
*
* Intl constructors are expensive (~0.05-0.1ms each), so we cache instances
* for reuse across the codebase instead of creating new ones each time.
* Lazy initialization ensures we only pay the cost when actually needed.
*
* Vendored from src/utils/intl.ts for package independence.
*/
// Segmenters for Unicode text processing (lazily initialized)
let graphemeSegmenter: Intl.Segmenter | null = null
let wordSegmenter: Intl.Segmenter | null = null
export function getGraphemeSegmenter(): Intl.Segmenter {
if (!graphemeSegmenter) {
graphemeSegmenter = new Intl.Segmenter(undefined, {
granularity: 'grapheme',
})
}
return graphemeSegmenter
}
/**
* Extract the first grapheme cluster from a string.
* Returns '' for empty strings.
*/
export function firstGrapheme(text: string): string {
if (!text) return ''
const segments = getGraphemeSegmenter().segment(text)
const first = segments[Symbol.iterator]().next().value
return first?.segment ?? ''
}
/**
* Extract the last grapheme cluster from a string.
* Returns '' for empty strings.
*/
export function lastGrapheme(text: string): string {
if (!text) return ''
let last = ''
for (const { segment } of getGraphemeSegmenter().segment(text)) {
last = segment
}
return last
}
export function getWordSegmenter(): Intl.Segmenter {
if (!wordSegmenter) {
wordSegmenter = new Intl.Segmenter(undefined, { granularity: 'word' })
}
return wordSegmenter
}

View File

@@ -0,0 +1,97 @@
/**
* Slice a string containing ANSI escape codes.
*
* Vendored from src/utils/sliceAnsi.ts for package independence.
* The only external dependency is stringWidth from the core package.
*/
import {
type AnsiCode,
ansiCodesToString,
reduceAnsiCodes,
tokenize,
undoAnsiCodes,
} from '@alcalzone/ansi-tokenize'
import { stringWidth } from '../stringWidth.js'
// A code is an "end code" if its code equals its endCode (e.g., hyperlink close)
function isEndCode(code: AnsiCode): boolean {
return code.code === code.endCode
}
// Filter to only include "start codes" (not end codes)
function filterStartCodes(codes: AnsiCode[]): AnsiCode[] {
return codes.filter(c => !isEndCode(c))
}
/**
* Slice a string containing ANSI escape codes.
*
* Unlike the slice-ansi package, this properly handles OSC 8 hyperlink
* sequences because @alcalzone/ansi-tokenize tokenizes them correctly.
*/
export default function sliceAnsi(
str: string,
start: number,
end?: number,
): string {
// Don't pass `end` to tokenize — it counts code units, not display cells,
// so it drops tokens early for text with zero-width combining marks.
const tokens = tokenize(str)
let activeCodes: AnsiCode[] = []
let position = 0
let result = ''
let include = false
for (const token of tokens) {
// Advance by display width, not code units. Combining marks (Devanagari
// matras, virama, diacritics) are width 0 — counting them via .length
// advanced position past `end` early and truncated the slice. Callers
// pass start/end in display cells (via stringWidth), so position must
// track the same units.
const width =
token.type === 'ansi' ? 0 : token.type === 'char' ? (token.fullWidth ? 2 : stringWidth(token.value)) : 0
// Break AFTER trailing zero-width marks — a combining mark attaches to
// the preceding base char, so "भा" (भ + ा, 1 display cell) sliced at
// end=1 must include the ा. Breaking on position >= end BEFORE the
// zero-width check would drop it and render भ bare. ANSI codes are
// width 0 but must NOT be included past end (they open new style runs
// that leak into the undo sequence), so gate on char type too. The
// !include guard ensures empty slices (start===end) stay empty even
// when the string starts with a zero-width char (BOM, ZWJ).
if (end !== undefined && position >= end) {
if (token.type === 'ansi' || width > 0 || !include) break
}
if (token.type === 'ansi') {
activeCodes.push(token)
if (include) {
// Emit all ANSI codes during the slice
result += token.code
}
} else {
if (!include && position >= start) {
// Skip leading zero-width marks at the start boundary — they belong
// to the preceding base char in the left half. Without this, the
// mark appears in BOTH halves: left+right ≠ original. Only applies
// when start > 0 (otherwise there's no preceding char to own it).
if (start > 0 && width === 0) continue
include = true
// Reduce and filter to only active start codes
activeCodes = filterStartCodes(reduceAnsiCodes(activeCodes))
result = ansiCodesToString(activeCodes)
}
if (include) {
result += (token as any).value
}
position += width
}
}
// Only undo start codes that are still active
const activeStartCodes = filterStartCodes(reduceAnsiCodes(activeCodes))
result += ansiCodesToString(undoAnsiCodes(activeStartCodes))
return result
}

View File

@@ -1,4 +1,4 @@
import sliceAnsi from '../utils/sliceAnsi.js'
import sliceAnsi from './utils/sliceAnsi.js'
import { stringWidth } from './stringWidth.js'
import type { Styles } from './styles.js'
import { wrapAnsi } from './wrapAnsi.js'

View File

@@ -0,0 +1,134 @@
/**
* Yoga enums — ported from yoga-layout/src/generated/YGEnums.ts
* Kept as `const` objects (not TS enums) per repo convention.
* Values match upstream exactly so callers don't change.
*/
export const Align = {
Auto: 0,
FlexStart: 1,
Center: 2,
FlexEnd: 3,
Stretch: 4,
Baseline: 5,
SpaceBetween: 6,
SpaceAround: 7,
SpaceEvenly: 8,
} as const
export type Align = (typeof Align)[keyof typeof Align]
export const BoxSizing = {
BorderBox: 0,
ContentBox: 1,
} as const
export type BoxSizing = (typeof BoxSizing)[keyof typeof BoxSizing]
export const Dimension = {
Width: 0,
Height: 1,
} as const
export type Dimension = (typeof Dimension)[keyof typeof Dimension]
export const Direction = {
Inherit: 0,
LTR: 1,
RTL: 2,
} as const
export type Direction = (typeof Direction)[keyof typeof Direction]
export const Display = {
Flex: 0,
None: 1,
Contents: 2,
} as const
export type Display = (typeof Display)[keyof typeof Display]
export const Edge = {
Left: 0,
Top: 1,
Right: 2,
Bottom: 3,
Start: 4,
End: 5,
Horizontal: 6,
Vertical: 7,
All: 8,
} as const
export type Edge = (typeof Edge)[keyof typeof Edge]
export const Errata = {
None: 0,
StretchFlexBasis: 1,
AbsolutePositionWithoutInsetsExcludesPadding: 2,
AbsolutePercentAgainstInnerSize: 4,
All: 2147483647,
Classic: 2147483646,
} as const
export type Errata = (typeof Errata)[keyof typeof Errata]
export const ExperimentalFeature = {
WebFlexBasis: 0,
} as const
export type ExperimentalFeature =
(typeof ExperimentalFeature)[keyof typeof ExperimentalFeature]
export const FlexDirection = {
Column: 0,
ColumnReverse: 1,
Row: 2,
RowReverse: 3,
} as const
export type FlexDirection = (typeof FlexDirection)[keyof typeof FlexDirection]
export const Gutter = {
Column: 0,
Row: 1,
All: 2,
} as const
export type Gutter = (typeof Gutter)[keyof typeof Gutter]
export const Justify = {
FlexStart: 0,
Center: 1,
FlexEnd: 2,
SpaceBetween: 3,
SpaceAround: 4,
SpaceEvenly: 5,
} as const
export type Justify = (typeof Justify)[keyof typeof Justify]
export const MeasureMode = {
Undefined: 0,
Exactly: 1,
AtMost: 2,
} as const
export type MeasureMode = (typeof MeasureMode)[keyof typeof MeasureMode]
export const Overflow = {
Visible: 0,
Hidden: 1,
Scroll: 2,
} as const
export type Overflow = (typeof Overflow)[keyof typeof Overflow]
export const PositionType = {
Static: 0,
Relative: 1,
Absolute: 2,
} as const
export type PositionType = (typeof PositionType)[keyof typeof PositionType]
export const Unit = {
Undefined: 0,
Point: 1,
Percent: 2,
Auto: 3,
} as const
export type Unit = (typeof Unit)[keyof typeof Unit]
export const Wrap = {
NoWrap: 0,
Wrap: 1,
WrapReverse: 2,
} as const
export type Wrap = (typeof Wrap)[keyof typeof Wrap]

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { useContext, useEffect, useState } from 'react'
import { ClockContext } from '../components/ClockContext.js'
import type { DOMElement } from '../dom.js'
import type { DOMElement } from '../core/dom.js'
import { useTerminalViewport } from './use-terminal-viewport.js'
/**

View File

@@ -1,6 +1,6 @@
import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
import CursorDeclarationContext from '../components/CursorDeclarationContext.js'
import type { DOMElement } from '../dom.js'
import type { DOMElement } from '../core/dom.js'
/**
* Declares where the terminal cursor should be parked after each frame.

View File

@@ -1,6 +1,6 @@
import { useEffect, useLayoutEffect } from 'react'
import { useEventCallback } from 'usehooks-ts'
import type { InputEvent, Key } from '../events/input-event.js'
import type { InputEvent, Key } from '../core/events/input-event.js'
import useStdin from './use-stdin.js'
type Handler = (input: string, key: Key, event: InputEvent) => void

View File

@@ -1,8 +1,8 @@
import { useContext, useMemo } from 'react'
import StdinContext from '../components/StdinContext.js'
import type { DOMElement } from '../dom.js'
import instances from '../instances.js'
import type { MatchPosition } from '../render-to-screen.js'
import type { DOMElement } from '../core/dom.js'
import instances from '../core/instances.js'
import type { MatchPosition } from '../core/render-to-screen.js'
/**
* Set the search highlight query on the Ink instance. Non-empty all

Some files were not shown because too many files have changed in this diff Show More