mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Merge branch 'refactor/ink-v2'
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -26,3 +26,4 @@ src/utils/vendor/
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.pyc
|
||||
logs
|
||||
|
||||
28
bun.lock
28
bun.lock
@@ -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=="],
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
30
packages/@ant/ink/package.json
Normal file
30
packages/@ant/ink/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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, {
|
||||
@@ -290,9 +315,21 @@ export default class App extends PureComponent<Props, State> {
|
||||
if (this.rawModeEnabledCount === 0) {
|
||||
// Stop early input capture right before we add our own readable handler.
|
||||
// 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()
|
||||
// coexist -- the early capture handler would drain stdin before ours
|
||||
// can see it. The buffered text is preserved for REPL.tsx via consumeEarlyInput().
|
||||
defaultCallbacks.stopCapturingEarlyInput()
|
||||
|
||||
// Safety net: remove any pre-existing readable listeners that aren't
|
||||
// ours. In builds where setAppCallbacks() was never called, the early
|
||||
// input capture's readableHandler remains attached and would consume
|
||||
// all stdin data before our handleReadable sees it.
|
||||
const existingListeners = stdin.listeners('readable')
|
||||
for (const listener of existingListeners) {
|
||||
if (listener !== this.handleReadable) {
|
||||
stdin.removeListener('readable', listener as any)
|
||||
}
|
||||
}
|
||||
|
||||
stdin.ref()
|
||||
stdin.setRawMode(true)
|
||||
stdin.addListener('readable', this.handleReadable)
|
||||
@@ -324,9 +361,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)')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -338,6 +375,17 @@ export default class App extends PureComponent<Props, State> {
|
||||
|
||||
// Disable raw mode only when no components left that are using it
|
||||
if (--this.rawModeEnabledCount === 0) {
|
||||
// Guard: React 19 runs new useLayoutEffect setup before old cleanup when
|
||||
// replacing the tree (e.g., showSetupDialog → launchResumeChooser).
|
||||
// If the old tree had more useInput hooks than the new tree, the old
|
||||
// cleanup over-decrements the count to 0 even though the new tree has
|
||||
// active listeners. Detect this and fix the count instead of disabling.
|
||||
const activeListeners = this.internal_eventEmitter.listenerCount('input')
|
||||
if (activeListeners > 0) {
|
||||
this.rawModeEnabledCount = activeListeners
|
||||
return
|
||||
}
|
||||
|
||||
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS)
|
||||
this.props.stdout.write(DISABLE_KITTY_KEYBOARD)
|
||||
// Disable terminal focus reporting (DECSET 1004)
|
||||
@@ -436,7 +484,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 +494,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 +604,7 @@ function processKeysInBatch(
|
||||
!((i.button & 0x20) !== 0 && (i.button & 0x03) === 3)),
|
||||
)
|
||||
) {
|
||||
updateLastInteractionTime()
|
||||
defaultCallbacks.updateLastInteractionTime()
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
@@ -625,7 +673,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
|
||||
@@ -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>
|
||||
@@ -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 = {
|
||||
@@ -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 = {
|
||||
@@ -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 */
|
||||
@@ -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 = {
|
||||
@@ -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()
|
||||
@@ -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 = {
|
||||
/**
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
getTerminalFocusState,
|
||||
subscribeTerminalFocus,
|
||||
type TerminalFocusState,
|
||||
} from '../terminal-focus-state.js'
|
||||
} from '../core/terminal-focus-state.js'
|
||||
|
||||
export type { TerminalFocusState }
|
||||
|
||||
@@ -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 = {
|
||||
/**
|
||||
@@ -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,
|
||||
@@ -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 = {
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
@@ -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 {
|
||||
@@ -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 {
|
||||
@@ -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`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
53
packages/@ant/ink/src/core/utils/grapheme.ts
Normal file
53
packages/@ant/ink/src/core/utils/grapheme.ts
Normal 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
|
||||
}
|
||||
97
packages/@ant/ink/src/core/utils/sliceAnsi.ts
Normal file
97
packages/@ant/ink/src/core/utils/sliceAnsi.ts
Normal 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
|
||||
}
|
||||
@@ -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'
|
||||
134
packages/@ant/ink/src/core/yoga-layout/enums.ts
Normal file
134
packages/@ant/ink/src/core/yoga-layout/enums.ts
Normal 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]
|
||||
2578
packages/@ant/ink/src/core/yoga-layout/index.ts
Normal file
2578
packages/@ant/ink/src/core/yoga-layout/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
|
||||
/**
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user