Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
+83
@@ -0,0 +1,83 @@
|
||||
/// <reference types="react" />
|
||||
|
||||
declare module 'react/compiler-runtime' {
|
||||
export function c(size: number): any[]
|
||||
}
|
||||
|
||||
declare module 'bidi-js' {
|
||||
const bidiFactory: () => Record<string, any>
|
||||
export default bidiFactory
|
||||
}
|
||||
|
||||
declare module 'stack-utils' {
|
||||
class StackUtils {
|
||||
static nodeInternals(): RegExp[]
|
||||
constructor(opts?: { cwd?: string; internals?: RegExp[] })
|
||||
clean(stack: string | undefined): string | undefined
|
||||
parseLine(line: string): { file?: string; line?: number; column?: number; function?: string } | undefined
|
||||
}
|
||||
export default StackUtils
|
||||
}
|
||||
|
||||
declare module 'react-reconciler' {
|
||||
export type FiberRoot = unknown
|
||||
const createReconciler: any
|
||||
export default createReconciler
|
||||
}
|
||||
|
||||
declare module 'react-reconciler/constants.js' {
|
||||
export const ConcurrentRoot: number
|
||||
export const LegacyRoot: number
|
||||
export const DiscreteEventPriority: symbol | number
|
||||
export const ContinuousEventPriority: symbol | number
|
||||
export const DefaultEventPriority: symbol | number
|
||||
export const NoEventPriority: symbol | number
|
||||
}
|
||||
|
||||
declare module 'lodash-es/noop.js' {
|
||||
const noop: (...args: unknown[]) => void
|
||||
export default noop
|
||||
}
|
||||
|
||||
declare module 'lodash-es/throttle.js' {
|
||||
function throttle<T extends (...args: unknown[]) => unknown>(
|
||||
fn: T,
|
||||
wait?: number,
|
||||
opts?: { leading?: boolean; trailing?: boolean }
|
||||
): T & { cancel(): void; flush(): void }
|
||||
export default throttle
|
||||
}
|
||||
|
||||
declare module 'semver' {
|
||||
export function coerce(version: string | number | null | undefined): { version: string } | null
|
||||
export function gt(a: string, b: string, opts?: { loose?: boolean }): boolean
|
||||
export function gte(a: string, b: string, opts?: { loose?: boolean }): boolean
|
||||
export function lt(a: string, b: string, opts?: { loose?: boolean }): boolean
|
||||
export function lte(a: string, b: string, opts?: { loose?: boolean }): boolean
|
||||
export function satisfies(version: string, range: string, opts?: { loose?: boolean }): boolean
|
||||
export function compare(a: string, b: string, opts?: { loose?: boolean }): number
|
||||
}
|
||||
|
||||
interface BunSemver {
|
||||
order(a: string, b: string): -1 | 0 | 1
|
||||
satisfies(version: string, range: string): boolean
|
||||
}
|
||||
|
||||
interface BunRuntime {
|
||||
stringWidth(s: string, opts?: { ambiguousIsNarrow?: boolean }): number
|
||||
semver: BunSemver
|
||||
wrapAnsi?(input: string, columns: number, options?: { hard?: boolean; wordWrap?: boolean; trim?: boolean }): string
|
||||
}
|
||||
|
||||
declare var Bun: BunRuntime | undefined
|
||||
|
||||
declare namespace React {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
'ink-box': Record<string, unknown>
|
||||
'ink-text': Record<string, unknown>
|
||||
'ink-link': Record<string, unknown>
|
||||
'ink-raw-ansi': Record<string, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/// <reference path="./ambient.d.ts" />
|
||||
export { default as useStderr } from './src/hooks/use-stderr.ts'
|
||||
export type { StderrHandle } from './src/hooks/use-stderr.ts'
|
||||
export { default as useStdout } from './src/hooks/use-stdout.ts'
|
||||
export type { StdoutHandle } from './src/hooks/use-stdout.ts'
|
||||
export { Ansi } from './src/ink/Ansi.tsx'
|
||||
export { evictInkCaches } from './src/ink/cache-eviction.ts'
|
||||
export type { EvictLevel, InkCacheSizes } from './src/ink/cache-eviction.ts'
|
||||
export { colorize } from './src/ink/colorize.ts'
|
||||
export { AlternateScreen } from './src/ink/components/AlternateScreen.tsx'
|
||||
export { default as Box } from './src/ink/components/Box.tsx'
|
||||
export type { Props as BoxProps } from './src/ink/components/Box.tsx'
|
||||
export { default as Link } from './src/ink/components/Link.tsx'
|
||||
export { default as Newline } from './src/ink/components/Newline.tsx'
|
||||
export { NoSelect } from './src/ink/components/NoSelect.tsx'
|
||||
export { RawAnsi } from './src/ink/components/RawAnsi.tsx'
|
||||
export { default as ScrollBox } from './src/ink/components/ScrollBox.tsx'
|
||||
export type { ScrollBoxHandle, ScrollBoxProps } from './src/ink/components/ScrollBox.tsx'
|
||||
export { default as Spacer } from './src/ink/components/Spacer.tsx'
|
||||
export type { Props as StdinProps } from './src/ink/components/StdinContext.ts'
|
||||
export { default as Text } from './src/ink/components/Text.tsx'
|
||||
export type { Props as TextProps } from './src/ink/components/Text.tsx'
|
||||
export type { Key } from './src/ink/events/input-event.ts'
|
||||
export { default as useApp } from './src/ink/hooks/use-app.ts'
|
||||
export { useCursorAdvance } from './src/ink/hooks/use-cursor-advance.ts'
|
||||
export { useDeclaredCursor } from './src/ink/hooks/use-declared-cursor.ts'
|
||||
export { default as useInput } from './src/ink/hooks/use-input.ts'
|
||||
export { useHasSelection, useSelection } from './src/ink/hooks/use-selection.ts'
|
||||
export { default as useStdin } from './src/ink/hooks/use-stdin.ts'
|
||||
export { useTabStatus } from './src/ink/hooks/use-tab-status.ts'
|
||||
export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts'
|
||||
export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts'
|
||||
export type { TerminalTitlePair } from './src/ink/hooks/use-terminal-title.ts'
|
||||
export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts'
|
||||
export { default as measureElement } from './src/ink/measure-element.ts'
|
||||
export { createRoot, forceRedraw, default as render, renderSync } from './src/ink/root.ts'
|
||||
export type { Instance, RenderOptions, Root } from './src/ink/root.ts'
|
||||
export { stringWidth } from './src/ink/stringWidth.ts'
|
||||
export type { MouseTrackingMode } from './src/ink/termio/dec.ts'
|
||||
export { wrapAnsi } from './src/ink/wrapAnsi.ts'
|
||||
// 'ink-text-input' types deliberately not re-exported here; see
|
||||
// src/entry-exports.ts for the full rationale (#31227). Use the
|
||||
// '@hermes/ink/text-input' subpath when the upstream widget is needed.
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dist/entry-exports.js'
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@hermes/ink",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "esbuild src/entry-exports.ts --bundle --platform=node --format=esm --packages=external --outdir=dist",
|
||||
"check": "npm run typecheck",
|
||||
"typecheck": "tsc -b . --noEmit",
|
||||
"lint": "echo 'ok!'",
|
||||
"fix": "echo 'nothing to fix'"
|
||||
},
|
||||
"sideEffects": true,
|
||||
"main": "./index.js",
|
||||
"types": "./index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./index.d.ts",
|
||||
"import": "./index.js",
|
||||
"default": "./index.js"
|
||||
},
|
||||
"./text-input": {
|
||||
"types": "./text-input.d.ts",
|
||||
"import": "./text-input.js",
|
||||
"default": "./text-input.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ink-text-input": "6.0.0",
|
||||
"react": "19.2.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "0.1.3",
|
||||
"auto-bind": "5.0.1",
|
||||
"bidi-js": "1.0.3",
|
||||
"chalk": "5.6.2",
|
||||
"cli-boxes": "3.0.0",
|
||||
"code-excerpt": "4.0.0",
|
||||
"emoji-regex": "10.6.0",
|
||||
"get-east-asian-width": "1.6.0",
|
||||
"indent-string": "5.0.0",
|
||||
"lodash-es": "4.18.1",
|
||||
"react": "19.2.7",
|
||||
"react-reconciler": "0.33.0",
|
||||
"semver": "7.8.5",
|
||||
"signal-exit": "4.1.0",
|
||||
"stack-utils": "2.0.6",
|
||||
"strip-ansi": "7.2.0",
|
||||
"supports-hyperlinks": "3.2.0",
|
||||
"type-fest": "4.41.0",
|
||||
"usehooks-ts": "3.1.1",
|
||||
"wrap-ansi": "9.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.28.1",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function flushInteractionTime(): void {}
|
||||
|
||||
export function updateLastInteractionTime(): void {}
|
||||
|
||||
export function markScrollActivity(): void {}
|
||||
|
||||
export function getIsInteractive(): boolean {
|
||||
return !!process.stdin.isTTY && !!process.stdout.isTTY
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export { default as useStderr } from './hooks/use-stderr.js'
|
||||
export { default as useStdout } from './hooks/use-stdout.js'
|
||||
export { Ansi } from './ink/Ansi.js'
|
||||
export { evictInkCaches, type EvictLevel, type InkCacheSizes } from './ink/cache-eviction.js'
|
||||
export { colorize } from './ink/colorize.js'
|
||||
export { AlternateScreen } from './ink/components/AlternateScreen.js'
|
||||
export { default as Box } from './ink/components/Box.js'
|
||||
export { default as Link } from './ink/components/Link.js'
|
||||
export { default as Newline } from './ink/components/Newline.js'
|
||||
export { NoSelect } from './ink/components/NoSelect.js'
|
||||
export { RawAnsi } from './ink/components/RawAnsi.js'
|
||||
export { default as ScrollBox } from './ink/components/ScrollBox.js'
|
||||
export { default as Spacer } from './ink/components/Spacer.js'
|
||||
export { setDimFallbackColor, default as Text } from './ink/components/Text.js'
|
||||
export { default as useApp } from './ink/hooks/use-app.js'
|
||||
export { useCursorAdvance } from './ink/hooks/use-cursor-advance.js'
|
||||
export { useDeclaredCursor } from './ink/hooks/use-declared-cursor.js'
|
||||
export { type RunExternalProcess, useExternalProcess, withInkSuspended } from './ink/hooks/use-external-process.js'
|
||||
export { default as useInput } from './ink/hooks/use-input.js'
|
||||
export { useHasSelection, useSelection } from './ink/hooks/use-selection.js'
|
||||
export { default as useStdin } from './ink/hooks/use-stdin.js'
|
||||
export { useTabStatus } from './ink/hooks/use-tab-status.js'
|
||||
export { useTerminalFocus } from './ink/hooks/use-terminal-focus.js'
|
||||
export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
|
||||
export type { TerminalTitlePair } from './ink/hooks/use-terminal-title.js'
|
||||
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js'
|
||||
export { default as measureElement } from './ink/measure-element.js'
|
||||
export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js'
|
||||
export { createRoot, forceRedraw, default as render, renderSync } from './ink/root.js'
|
||||
export { stringWidth } from './ink/stringWidth.js'
|
||||
export {
|
||||
isXtermJs,
|
||||
onTerminalBackground,
|
||||
onTerminalForeground,
|
||||
parseOscColor,
|
||||
terminalBackgroundHex,
|
||||
terminalForegroundHex
|
||||
} from './ink/terminal.js'
|
||||
export type { MouseTrackingMode } from './ink/termio/dec.js'
|
||||
export { wrapAnsi } from './ink/wrapAnsi.js'
|
||||
|
||||
// NOTE: Do not re-export from 'ink-text-input' here.
|
||||
//
|
||||
// 'ink-text-input' depends on the npm 'ink' package; pulling it in from
|
||||
// this re-export drags an entire second copy of ink (and its async
|
||||
// top-level init chain) into any caller that bundles `@hermes/ink` from
|
||||
// source. esbuild's `__esm` helper then deadlocks on the circular
|
||||
// async init between the two ink graphs — the dashboard TUI bundle
|
||||
// stalls at startup with only 141 bytes of ANSI reset output, blank
|
||||
// screen forever (#31227).
|
||||
//
|
||||
// Consumers that actually want the upstream ink-text-input widget must
|
||||
// import it via the dedicated subpath:
|
||||
//
|
||||
// import TextInput from '@hermes/ink/text-input'
|
||||
//
|
||||
// which still resolves through this package's `./text-input` export,
|
||||
// just outside the entry-exports surface that gets inlined by callers.
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMemo } from 'react'
|
||||
export type StderrHandle = {
|
||||
stderr: NodeJS.WriteStream
|
||||
write: (data: string) => boolean
|
||||
}
|
||||
|
||||
export default function useStderr(): StderrHandle {
|
||||
return useMemo(
|
||||
() => ({
|
||||
stderr: process.stderr,
|
||||
write: data => process.stderr.write(data)
|
||||
}),
|
||||
[]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMemo } from 'react'
|
||||
export type StdoutHandle = {
|
||||
stdout: NodeJS.WriteStream
|
||||
write: (data: string) => boolean
|
||||
}
|
||||
|
||||
export default function useStdout(): StdoutHandle {
|
||||
return useMemo(
|
||||
() => ({
|
||||
stdout: process.stdout,
|
||||
write: data => process.stdout.write(data)
|
||||
}),
|
||||
[]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import React, { type ReactNode } from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
|
||||
import Link from './components/Link.js'
|
||||
import Text from './components/Text.js'
|
||||
import type { Color } from './styles.js'
|
||||
import { type NamedColor, Parser, type Color as TermioColor, type TextStyle } from './termio.js'
|
||||
type Props = {
|
||||
children?: ReactNode
|
||||
/** When true, force all text to be rendered with dim styling */
|
||||
dimColor?: boolean
|
||||
}
|
||||
type SpanProps = {
|
||||
color?: Color
|
||||
backgroundColor?: Color
|
||||
dim?: boolean
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
underline?: boolean
|
||||
strikethrough?: boolean
|
||||
inverse?: boolean
|
||||
hyperlink?: string
|
||||
}
|
||||
|
||||
type Span = {
|
||||
text: string
|
||||
props: SpanProps
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that parses ANSI escape codes and renders them using Text components.
|
||||
*
|
||||
* Use this as an escape hatch when you have pre-formatted ANSI strings from
|
||||
* external tools (like cli-highlight) that need to be rendered in Ink.
|
||||
*
|
||||
* Memoized to prevent re-renders when parent changes but children string is the same.
|
||||
*/
|
||||
export const Ansi = React.memo(function Ansi(t0: Props) {
|
||||
const $ = _c(12)
|
||||
|
||||
const { children, dimColor } = t0
|
||||
|
||||
if (typeof children !== 'string') {
|
||||
let t1
|
||||
|
||||
if ($[0] !== children || $[1] !== dimColor) {
|
||||
t1 = dimColor ? <Text dim={true}>{String(children)}</Text> : <Text>{String(children)}</Text>
|
||||
$[0] = children
|
||||
$[1] = dimColor
|
||||
$[2] = t1
|
||||
} else {
|
||||
t1 = $[2]
|
||||
}
|
||||
|
||||
return t1
|
||||
}
|
||||
|
||||
if (children === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
let t1
|
||||
let t2
|
||||
|
||||
if ($[3] !== children || $[4] !== dimColor) {
|
||||
t2 = Symbol.for('react.early_return_sentinel')
|
||||
|
||||
bb0: {
|
||||
const spans = parseToSpans(children)
|
||||
|
||||
if (spans.length === 0) {
|
||||
t2 = null
|
||||
|
||||
break bb0
|
||||
}
|
||||
|
||||
if (spans.length === 1 && !hasAnyProps(spans[0].props)) {
|
||||
t2 = dimColor ? <Text dim={true}>{spans[0].text}</Text> : <Text>{spans[0].text}</Text>
|
||||
|
||||
break bb0
|
||||
}
|
||||
|
||||
let t3
|
||||
|
||||
if ($[7] !== dimColor) {
|
||||
t3 = (span: Span, i: number) => {
|
||||
const hyperlink = span.props.hyperlink
|
||||
|
||||
if (dimColor) {
|
||||
span.props.dim = true
|
||||
}
|
||||
|
||||
const hasTextProps = hasAnyTextProps(span.props)
|
||||
|
||||
if (hyperlink) {
|
||||
return hasTextProps ? (
|
||||
<Link key={i} url={hyperlink}>
|
||||
<StyledText
|
||||
backgroundColor={span.props.backgroundColor}
|
||||
bold={span.props.bold}
|
||||
color={span.props.color}
|
||||
dim={span.props.dim}
|
||||
inverse={span.props.inverse}
|
||||
italic={span.props.italic}
|
||||
strikethrough={span.props.strikethrough}
|
||||
underline={span.props.underline}
|
||||
>
|
||||
{span.text}
|
||||
</StyledText>
|
||||
</Link>
|
||||
) : (
|
||||
<Link key={i} url={hyperlink}>
|
||||
{span.text}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return hasTextProps ? (
|
||||
<StyledText
|
||||
backgroundColor={span.props.backgroundColor}
|
||||
bold={span.props.bold}
|
||||
color={span.props.color}
|
||||
dim={span.props.dim}
|
||||
inverse={span.props.inverse}
|
||||
italic={span.props.italic}
|
||||
key={i}
|
||||
strikethrough={span.props.strikethrough}
|
||||
underline={span.props.underline}
|
||||
>
|
||||
{span.text}
|
||||
</StyledText>
|
||||
) : (
|
||||
span.text
|
||||
)
|
||||
}
|
||||
|
||||
$[7] = dimColor
|
||||
$[8] = t3
|
||||
} else {
|
||||
t3 = $[8]
|
||||
}
|
||||
|
||||
t1 = spans.map(t3)
|
||||
}
|
||||
|
||||
$[3] = children
|
||||
$[4] = dimColor
|
||||
$[5] = t1
|
||||
$[6] = t2
|
||||
} else {
|
||||
t1 = $[5]
|
||||
t2 = $[6]
|
||||
}
|
||||
|
||||
if (t2 !== Symbol.for('react.early_return_sentinel')) {
|
||||
return t2
|
||||
}
|
||||
|
||||
const content = t1
|
||||
let t3
|
||||
|
||||
if ($[9] !== content || $[10] !== dimColor) {
|
||||
t3 = dimColor ? <Text dim={true}>{content}</Text> : <Text>{content}</Text>
|
||||
$[9] = content
|
||||
$[10] = dimColor
|
||||
$[11] = t3
|
||||
} else {
|
||||
t3 = $[11]
|
||||
}
|
||||
|
||||
return t3
|
||||
})
|
||||
|
||||
/**
|
||||
* Parse an ANSI string into spans using the termio parser.
|
||||
*/
|
||||
function parseToSpans(input: string): Span[] {
|
||||
const parser = new Parser()
|
||||
const actions = parser.feed(input)
|
||||
const spans: Span[] = []
|
||||
let currentHyperlink: string | undefined
|
||||
|
||||
for (const action of actions) {
|
||||
if (action.type === 'link') {
|
||||
if (action.action.type === 'start') {
|
||||
currentHyperlink = action.action.url
|
||||
} else {
|
||||
currentHyperlink = undefined
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (action.type === 'text') {
|
||||
const text = action.graphemes.map(g => g.value).join('')
|
||||
|
||||
if (!text) {
|
||||
continue
|
||||
}
|
||||
|
||||
const props = textStyleToSpanProps(action.style)
|
||||
|
||||
if (currentHyperlink) {
|
||||
props.hyperlink = currentHyperlink
|
||||
}
|
||||
|
||||
// Try to merge with previous span if props match
|
||||
const lastSpan = spans[spans.length - 1]
|
||||
|
||||
if (lastSpan && propsEqual(lastSpan.props, props)) {
|
||||
lastSpan.text += text
|
||||
} else {
|
||||
spans.push({
|
||||
text,
|
||||
props
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return spans
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert termio's TextStyle to SpanProps.
|
||||
*/
|
||||
function textStyleToSpanProps(style: TextStyle): SpanProps {
|
||||
const props: SpanProps = {}
|
||||
|
||||
if (style.bold) {
|
||||
props.bold = true
|
||||
}
|
||||
|
||||
if (style.dim) {
|
||||
props.dim = true
|
||||
}
|
||||
|
||||
if (style.italic) {
|
||||
props.italic = true
|
||||
}
|
||||
|
||||
if (style.underline !== 'none') {
|
||||
props.underline = true
|
||||
}
|
||||
|
||||
if (style.strikethrough) {
|
||||
props.strikethrough = true
|
||||
}
|
||||
|
||||
if (style.inverse) {
|
||||
props.inverse = true
|
||||
}
|
||||
|
||||
const fgColor = colorToString(style.fg)
|
||||
|
||||
if (fgColor) {
|
||||
props.color = fgColor
|
||||
}
|
||||
|
||||
const bgColor = colorToString(style.bg)
|
||||
|
||||
if (bgColor) {
|
||||
props.backgroundColor = bgColor
|
||||
}
|
||||
|
||||
return props
|
||||
}
|
||||
|
||||
// Map termio named colors to the ansi: format
|
||||
const NAMED_COLOR_MAP: Record<NamedColor, string> = {
|
||||
black: 'ansi:black',
|
||||
red: 'ansi:red',
|
||||
green: 'ansi:green',
|
||||
yellow: 'ansi:yellow',
|
||||
blue: 'ansi:blue',
|
||||
magenta: 'ansi:magenta',
|
||||
cyan: 'ansi:cyan',
|
||||
white: 'ansi:white',
|
||||
brightBlack: 'ansi:blackBright',
|
||||
brightRed: 'ansi:redBright',
|
||||
brightGreen: 'ansi:greenBright',
|
||||
brightYellow: 'ansi:yellowBright',
|
||||
brightBlue: 'ansi:blueBright',
|
||||
brightMagenta: 'ansi:magentaBright',
|
||||
brightCyan: 'ansi:cyanBright',
|
||||
brightWhite: 'ansi:whiteBright'
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert termio's Color to the string format used by Ink.
|
||||
*/
|
||||
function colorToString(color: TermioColor): Color | undefined {
|
||||
switch (color.type) {
|
||||
case 'named':
|
||||
return NAMED_COLOR_MAP[color.name] as Color
|
||||
|
||||
case 'indexed':
|
||||
return `ansi256(${color.index})` as Color
|
||||
|
||||
case 'rgb':
|
||||
return `rgb(${color.r},${color.g},${color.b})` as Color
|
||||
|
||||
case 'default':
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two SpanProps are equal for merging.
|
||||
*/
|
||||
function propsEqual(a: SpanProps, b: SpanProps): boolean {
|
||||
return (
|
||||
a.color === b.color &&
|
||||
a.backgroundColor === b.backgroundColor &&
|
||||
a.bold === b.bold &&
|
||||
a.dim === b.dim &&
|
||||
a.italic === b.italic &&
|
||||
a.underline === b.underline &&
|
||||
a.strikethrough === b.strikethrough &&
|
||||
a.inverse === b.inverse &&
|
||||
a.hyperlink === b.hyperlink
|
||||
)
|
||||
}
|
||||
|
||||
function hasAnyProps(props: SpanProps): boolean {
|
||||
return (
|
||||
props.color !== undefined ||
|
||||
props.backgroundColor !== undefined ||
|
||||
props.dim === true ||
|
||||
props.bold === true ||
|
||||
props.italic === true ||
|
||||
props.underline === true ||
|
||||
props.strikethrough === true ||
|
||||
props.inverse === true ||
|
||||
props.hyperlink !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
function hasAnyTextProps(props: SpanProps): boolean {
|
||||
return (
|
||||
props.color !== undefined ||
|
||||
props.backgroundColor !== undefined ||
|
||||
props.dim === true ||
|
||||
props.bold === true ||
|
||||
props.italic === true ||
|
||||
props.underline === true ||
|
||||
props.strikethrough === true ||
|
||||
props.inverse === true
|
||||
)
|
||||
}
|
||||
|
||||
// Text style props without weight (bold/dim) - these are handled separately
|
||||
type BaseTextStyleProps = {
|
||||
color?: Color
|
||||
backgroundColor?: Color
|
||||
italic?: boolean
|
||||
underline?: boolean
|
||||
strikethrough?: boolean
|
||||
inverse?: boolean
|
||||
}
|
||||
|
||||
// Wrapper component that handles bold/dim mutual exclusivity for Text
|
||||
function StyledText(t0: BaseTextStyleProps & { bold?: boolean; dim?: boolean; children?: ReactNode }) {
|
||||
const $ = _c(14)
|
||||
let bold
|
||||
let children
|
||||
let dim
|
||||
let rest
|
||||
|
||||
if ($[0] !== t0) {
|
||||
;({ bold, dim, children, ...rest } = t0)
|
||||
$[0] = t0
|
||||
$[1] = bold
|
||||
$[2] = children
|
||||
$[3] = dim
|
||||
$[4] = rest
|
||||
} else {
|
||||
bold = $[1]
|
||||
children = $[2]
|
||||
dim = $[3]
|
||||
rest = $[4]
|
||||
}
|
||||
|
||||
if (dim) {
|
||||
let t1
|
||||
|
||||
if ($[5] !== children || $[6] !== rest) {
|
||||
t1 = (
|
||||
<Text {...rest} dim={true}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
$[5] = children
|
||||
$[6] = rest
|
||||
$[7] = t1
|
||||
} else {
|
||||
t1 = $[7]
|
||||
}
|
||||
|
||||
return t1
|
||||
}
|
||||
|
||||
if (bold) {
|
||||
let t1
|
||||
|
||||
if ($[8] !== children || $[9] !== rest) {
|
||||
t1 = (
|
||||
<Text {...rest} bold={true}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
$[8] = children
|
||||
$[9] = rest
|
||||
$[10] = t1
|
||||
} else {
|
||||
t1 = $[10]
|
||||
}
|
||||
|
||||
return t1
|
||||
}
|
||||
|
||||
let t1
|
||||
|
||||
if ($[11] !== children || $[12] !== rest) {
|
||||
t1 = <Text {...rest}>{children}</Text>
|
||||
$[11] = children
|
||||
$[12] = rest
|
||||
$[13] = t1
|
||||
} else {
|
||||
t1 = $[13]
|
||||
}
|
||||
|
||||
return t1
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkxpbmsiLCJUZXh0IiwiQ29sb3IiLCJOYW1lZENvbG9yIiwiUGFyc2VyIiwiVGVybWlvQ29sb3IiLCJUZXh0U3R5bGUiLCJQcm9wcyIsImNoaWxkcmVuIiwiZGltQ29sb3IiLCJTcGFuUHJvcHMiLCJjb2xvciIsImJhY2tncm91bmRDb2xvciIsImRpbSIsImJvbGQiLCJpdGFsaWMiLCJ1bmRlcmxpbmUiLCJzdHJpa2V0aHJvdWdoIiwiaW52ZXJzZSIsImh5cGVybGluayIsIkFuc2kiLCJtZW1vIiwidDAiLCIkIiwiX2MiLCJ0MSIsIlN0cmluZyIsInQyIiwiU3ltYm9sIiwiZm9yIiwiYmIwIiwic3BhbnMiLCJwYXJzZVRvU3BhbnMiLCJsZW5ndGgiLCJoYXNBbnlQcm9wcyIsInByb3BzIiwidGV4dCIsInQzIiwic3BhbiIsImkiLCJoYXNUZXh0UHJvcHMiLCJoYXNBbnlUZXh0UHJvcHMiLCJtYXAiLCJjb250ZW50IiwiU3BhbiIsImlucHV0IiwicGFyc2VyIiwiYWN0aW9ucyIsImZlZWQiLCJjdXJyZW50SHlwZXJsaW5rIiwiYWN0aW9uIiwidHlwZSIsInVybCIsInVuZGVmaW5lZCIsImdyYXBoZW1lcyIsImciLCJ2YWx1ZSIsImpvaW4iLCJ0ZXh0U3R5bGVUb1NwYW5Qcm9wcyIsInN0eWxlIiwibGFzdFNwYW4iLCJwcm9wc0VxdWFsIiwicHVzaCIsImZnQ29sb3IiLCJjb2xvclRvU3RyaW5nIiwiZmciLCJiZ0NvbG9yIiwiYmciLCJOQU1FRF9DT0xPUl9NQVAiLCJSZWNvcmQiLCJibGFjayIsInJlZCIsImdyZWVuIiwieWVsbG93IiwiYmx1ZSIsIm1hZ2VudGEiLCJjeWFuIiwid2hpdGUiLCJicmlnaHRCbGFjayIsImJyaWdodFJlZCIsImJyaWdodEdyZWVuIiwiYnJpZ2h0WWVsbG93IiwiYnJpZ2h0Qmx1ZSIsImJyaWdodE1hZ2VudGEiLCJicmlnaHRDeWFuIiwiYnJpZ2h0V2hpdGUiLCJuYW1lIiwiaW5kZXgiLCJyIiwiYiIsImEiLCJCYXNlVGV4dFN0eWxlUHJvcHMiLCJTdHlsZWRUZXh0IiwicmVzdCJdLCJzb3VyY2VzIjpbIkFuc2kudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCBMaW5rIGZyb20gJy4vY29tcG9uZW50cy9MaW5rLmpzJ1xuaW1wb3J0IFRleHQgZnJvbSAnLi9jb21wb25lbnRzL1RleHQuanMnXG5pbXBvcnQgdHlwZSB7IENvbG9yIH0gZnJvbSAnLi9zdHlsZXMuanMnXG5pbXBvcnQge1xuICB0eXBlIE5hbWVkQ29sb3IsXG4gIFBhcnNlcixcbiAgdHlwZSBDb2xvciBhcyBUZXJtaW9Db2xvcixcbiAgdHlwZSBUZXh0U3R5bGUsXG59IGZyb20gJy4vdGVybWlvLmpzJ1xuXG50eXBlIFByb3BzID0ge1xuICBjaGlsZHJlbjogc3RyaW5nXG4gIC8qKiBXaGVuIHRydWUsIGZvcmNlIGFsbCB0ZXh0IHRvIGJlIHJlbmRlcmVkIHdpdGggZGltIHN0eWxpbmcgKi9cbiAgZGltQ29sb3I/OiBib29sZWFuXG59XG5cbnR5cGUgU3BhblByb3BzID0ge1xuICBjb2xvcj86IENvbG9yXG4gIGJhY2tncm91bmRDb2xvcj86IENvbG9yXG4gIGRpbT86IGJvb2xlYW5cbiAgYm9sZD86IGJvb2xlYW5cbiAgaXRhbGljPzogYm9vbGVhblxuICB1bmRlcmxpbmU/OiBib29sZWFuXG4gIHN0cmlrZXRocm91Z2g/OiBib29sZWFuXG4gIGludmVyc2U/OiBib29sZWFuXG4gIGh5cGVybGluaz86IHN0cmluZ1xufVxuXG4vKipcbiAqIENvbXBvbmVudCB0aGF0IHBhcnNlcyBBTlNJIGVzY2FwZSBjb2RlcyBhbmQgcmVuZGVycyB0aGVtIHVzaW5nIFRleHQgY29tcG9uZW50cy5cbiAqXG4gKiBVc2UgdGhpcyBhcyBhbiBlc2NhcGUgaGF0Y2ggd2hlbiB5b3UgaGF2ZSBwcmUtZm9ybWF0dGVkIEFOU0kgc3RyaW5ncyBmcm9tXG4gKiBleHRlcm5hbCB0b29scyAobGlrZSBjbGktaGlnaGxpZ2h0KSB0aGF0IG5lZWQgdG8gYmUgcmVuZGVyZWQgaW4gSW5rLlxuICpcbiAqIE1lbW9pemVkIHRvIHByZXZlbnQgcmUtcmVuZGVycyB3aGVuIHBhcmVudCBjaGFuZ2VzIGJ1dCBjaGlsZHJlbiBzdHJpbmcgaXMgdGhlIHNhbWUuXG4gKi9cbmV4cG9ydCBjb25zdCBBbnNpID0gUmVhY3QubWVtbyhmdW5jdGlvbiBBbnNpKHtcbiAgY2hpbGRyZW4sXG4gIGRpbUNvbG9yLFxufTogUHJvcHMpOiBSZWFjdC5SZWFjdE5vZGUge1xuICBpZiAodHlwZW9mIGNoaWxkcmVuICE9PSAnc3RyaW5nJykge1xuICAgIHJldHVybiBkaW1Db2xvciA/IChcbiAgICAgIDxUZXh0IGRpbT57U3RyaW5nKGNoaWxkcmVuKX08L1RleHQ+XG4gICAgKSA6IChcbiAgICAgIDxUZXh0PntTdHJpbmcoY2hpbGRyZW4pfTwvVGV4dD5cbiAgICApXG4gIH1cblxuICBpZiAoY2hpbGRyZW4gPT09ICcnKSB7XG4gICAgcmV0dXJuIG51bGxcbiAgfVxuXG4gIGNvbnN0IHNwYW5zID0gcGFyc2VUb1NwYW5zKGNoaWxkcmVuKVxuXG4gIGlmIChzcGFucy5sZW5ndGggPT09IDApIHtcbiAgICByZXR1cm4gbnVsbFxuICB9XG5cbiAgaWYgKHNwYW5zLmxlbmd0aCA9PT0gMSAmJiAhaGFzQW55UHJvcHMoc3BhbnNbMF0hLnByb3BzKSkge1xuICAgIHJldHVybiBkaW1Db2xvciA/IChcbiAgICAgIDxUZXh0IGRpbT57c3BhbnNbMF0hLnRleHR9PC9UZXh0PlxuICAgICkgOiAoXG4gICAgICA8VGV4dD57c3BhbnNbMF0hLnRleHR9PC9UZXh0PlxuICAgIClcbiAgfVxuXG4gIGNvbnN0IGNvbnRlbnQgPSBzcGFucy5tYXAoKHNwYW4sIGkpID0+IHtcbiAgICBjb25zdCBoeXBlcmxpbmsgPSBzcGFuLnByb3BzLmh5cGVybGlua1xuICAgIC8vIFdoZW4gZGltQ29sb3IgaXMgZm9yY2VkLCBvdmVycmlkZSB0aGUgc3BhbidzIGRpbSBwcm9wXG4gICAgaWYgKGRpbUNvbG9yKSB7XG4gICAgICBzcGFuLnByb3BzLmRpbSA9IHRydWVcbiAgICB9XG4gICAgY29uc3QgaGFzVGV4dFByb3BzID0gaGFzQW55VGV4dFByb3BzKHNwYW4ucHJvcHMpXG5cbiAgICBpZiAoaHlwZXJsaW5rKSB7XG4gICAgICByZXR1cm4gaGFzVGV4dFByb3BzID8gKFxuICAgICAgICA8TGluayBrZXk9e2l9IHVybD17aHlwZXJsaW5rfT5cbiAgICAgICAgICA8U3R5bGVkVGV4dFxuICAgICAgICAgICAgY29sb3I9e3NwYW4ucHJvcHMuY29sb3J9XG4gICAgICAgICAgICBiYWNrZ3JvdW5kQ29sb3I9e3NwYW4ucHJvcHMuYmFja2dyb3VuZENvbG9yfVxuICAgICAgICAgICAgZGltPXtzcGFuLnByb3BzLmRpbX1cbiAgICAgICAgICAgIGJvbGQ9e3NwYW4ucHJvcHMuYm9sZH1cbiAgICAgICAgICAgIGl0YWxpYz17c3Bhbi5wcm9wcy5pdGFsaWN9XG4gICAgICAgICAgICB1bmRlcmxpbmU9e3NwYW4ucHJvcHMudW5kZXJsaW5lfVxuICAgICAgICAgICAgc3RyaWtldGhyb3VnaD17c3Bhbi5wcm9wcy5zdHJpa2V0aHJvdWdofVxuICAgICAgICAgICAgaW52ZXJzZT17c3Bhbi5wcm9wcy5pbnZlcnNlfVxuICAgICAgICAgID5cbiAgICAgICAgICAgIHtzcGFuLnRleHR9XG4gICAgICAgICAgPC9TdHlsZWRUZXh0PlxuICAgICAgICA8L0xpbms+XG4gICAgICApIDogKFxuICAgICAgICA8TGluayBrZXk9e2l9IHVybD17aHlwZXJsaW5rfT5cbiAgICAgICAgICB7c3Bhbi50ZXh0fVxuICAgICAgICA8L0xpbms+XG4gICAgICApXG4gICAgfVxuXG4gICAgcmV0dXJuIGhhc1RleHRQcm9wcyA/IChcbiAgICAgIDxTdHlsZWRUZXh0XG4gICAgICAgIGtleT17aX1cbiAgICAgICAgY29sb3I9e3NwYW4ucHJvcHMuY29sb3J9XG4gICAgICAgIGJhY2tncm91bmRDb2xvcj17c3Bhbi5wcm9wcy5iYWNrZ3JvdW5kQ29sb3J9XG4gICAgICAgIGRpbT17c3Bhbi5wcm9wcy5kaW19XG4gICAgICAgIGJvbGQ9e3NwYW4ucHJvcHMuYm9sZH1cbiAgICAgICAgaXRhbGljPXtzcGFuLnByb3B
Line truncated
|
||||
@@ -0,0 +1,77 @@
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import Box from './components/Box.js'
|
||||
import Text from './components/Text.js'
|
||||
import Ink from './ink.js'
|
||||
|
||||
class FakeTty extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
columns = 40
|
||||
rows = 8
|
||||
isTTY = true
|
||||
|
||||
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
|
||||
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
cb?.()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const paint = (node: React.ReactElement) => {
|
||||
const stdout = new FakeTty()
|
||||
const stdin = new FakeTty()
|
||||
const stderr = new FakeTty()
|
||||
|
||||
const ink = new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
ink.render(node)
|
||||
ink.onRender()
|
||||
const frame = stdout.chunks.join('')
|
||||
ink.unmount()
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
// The composer's floating panels (session switcher, model picker, …) are
|
||||
// absolute `bottom: 100%` children of a relative Box whose only OTHER children
|
||||
// — the input rows — unmount while an overlay is open. That leaves the host box
|
||||
// at height 0 with a sibling on the same row, which is exactly the shape the
|
||||
// same-row ghost guard skips. The guard must not take the escaping absolute
|
||||
// child with it: it paints outside the host's bounds and can never ghost.
|
||||
describe('absolute children of a zero-height box', () => {
|
||||
it('paints an absolute bottom:100% panel when its host box collapses to h=0', () => {
|
||||
const frame = paint(
|
||||
<Box flexDirection="column">
|
||||
<Text>transcript</Text>
|
||||
|
||||
<Box flexDirection="column" position="relative">
|
||||
<Box bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
|
||||
<Text>PANEL-CONTENT</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Text>footer</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
expect(frame).toContain('PANEL-CONTENT')
|
||||
})
|
||||
|
||||
// NOT covered here: that the guard still SUPPRESSES the same-row ghost it
|
||||
// exists for (a squeezed box and its sibling both writing one row, leaving
|
||||
// the longer content's tail behind). That path has no test upstream either,
|
||||
// and the obvious candidates are vacuous — they pass with the guard deleted
|
||||
// outright, which would silently reintroduce the ghost. Asserting it needs a
|
||||
// tree where Yoga actually squeezes a node to h=0 onto a sibling's row (the
|
||||
// HelpV2 shortcuts column is the known real case); worth adding separately.
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { transitionAnsiCodes } from './ansi-transition.js'
|
||||
import { StylePool } from './screen.js'
|
||||
|
||||
const ESC = '\u001b'
|
||||
const BOLD = { type: 'ansi' as const, code: `${ESC}[1m`, endCode: `${ESC}[22m` }
|
||||
const DIM = { type: 'ansi' as const, code: `${ESC}[2m`, endCode: `${ESC}[22m` }
|
||||
const FG_PINK = { type: 'ansi' as const, code: `${ESC}[38;5;204m`, endCode: `${ESC}[39m` }
|
||||
const FG_GRAY = { type: 'ansi' as const, code: `${ESC}[38;5;245m`, endCode: `${ESC}[39m` }
|
||||
|
||||
const codes = (result: ReturnType<typeof transitionAnsiCodes>) => result.map(c => c.code)
|
||||
|
||||
/**
|
||||
* SGR 1 (bold) and SGR 2 (dim) are independent attributes sharing one reset
|
||||
* (SGR 22). A transition that swaps one for the other MUST pass through 22 —
|
||||
* otherwise the terminal accumulates both and every later transition is
|
||||
* computed from phantom state (the "random dimness/opacity" drift).
|
||||
*/
|
||||
describe('transitionAnsiCodes weight family', () => {
|
||||
it('bold → dim resets the weight family before applying dim', () => {
|
||||
expect(codes(transitionAnsiCodes([BOLD], [DIM]))).toEqual([`${ESC}[22m`, `${ESC}[2m`])
|
||||
})
|
||||
|
||||
it('dim → bold resets the weight family before applying bold', () => {
|
||||
expect(codes(transitionAnsiCodes([DIM], [BOLD]))).toEqual([`${ESC}[22m`, `${ESC}[1m`])
|
||||
})
|
||||
|
||||
it('carries color changes through a weight swap', () => {
|
||||
expect(codes(transitionAnsiCodes([BOLD, FG_PINK], [DIM, FG_GRAY]))).toEqual([
|
||||
`${ESC}[22m`,
|
||||
`${ESC}[38;5;245m`,
|
||||
`${ESC}[2m`
|
||||
])
|
||||
})
|
||||
|
||||
it('weight removal to plain still emits the reset', () => {
|
||||
expect(codes(transitionAnsiCodes([BOLD, FG_PINK], [FG_GRAY]))).toEqual([`${ESC}[22m`, `${ESC}[38;5;245m`])
|
||||
expect(codes(transitionAnsiCodes([DIM], []))).toEqual([`${ESC}[22m`])
|
||||
})
|
||||
|
||||
it('pure additions stay minimal (no gratuitous resets)', () => {
|
||||
expect(codes(transitionAnsiCodes([], [BOLD]))).toEqual([`${ESC}[1m`])
|
||||
expect(codes(transitionAnsiCodes([FG_PINK], [FG_PINK, DIM]))).toEqual([`${ESC}[2m`])
|
||||
expect(codes(transitionAnsiCodes([BOLD], [BOLD, FG_PINK]))).toEqual([`${ESC}[38;5;204m`])
|
||||
})
|
||||
|
||||
it('unchanged styles emit nothing', () => {
|
||||
expect(transitionAnsiCodes([BOLD, FG_PINK], [BOLD, FG_PINK])).toEqual([])
|
||||
})
|
||||
|
||||
// Real tool output (ls/grep) ships COMPOUND sequences like `[1;31m` whose
|
||||
// endCode is `[0m` — weight detection must parse params, not endCodes.
|
||||
const compound = (params: string) => ({ type: 'ansi' as const, code: `${ESC}[${params}m`, endCode: `${ESC}[0m` })
|
||||
|
||||
it('compound bold → compound dim resets the weight family', () => {
|
||||
expect(codes(transitionAnsiCodes([compound('1;31')], [compound('2;37')]))).toEqual([`${ESC}[22m`, `${ESC}[2;37m`])
|
||||
})
|
||||
|
||||
it('compound bold → compound bold (color change) stays minimal', () => {
|
||||
expect(codes(transitionAnsiCodes([compound('1;31')], [compound('1;32')]))).toEqual([`${ESC}[1;32m`])
|
||||
})
|
||||
|
||||
it('compound weight removal to plain emits the reset', () => {
|
||||
expect(codes(transitionAnsiCodes([compound('1;31')], [FG_GRAY]))).toEqual([`${ESC}[22m`, `${ESC}[38;5;245m`])
|
||||
})
|
||||
|
||||
it('extended-color arguments are not read as weight atoms', () => {
|
||||
// `38;2;r;g;b` / `38;5;N` carry literal 2/5 sub-params: not SGR atoms.
|
||||
const tc = { type: 'ansi' as const, code: `${ESC}[38;2;120;87;109m`, endCode: `${ESC}[39m` }
|
||||
|
||||
expect(codes(transitionAnsiCodes([tc], [FG_GRAY]))).toEqual([`${ESC}[38;5;245m`])
|
||||
expect(codes(transitionAnsiCodes([FG_PINK], [tc]))).toEqual([`${ESC}[38;2;120;87;109m`])
|
||||
})
|
||||
})
|
||||
|
||||
describe('StylePool.transition weight correctness', () => {
|
||||
// Minimal SGR interpreter: the ground truth a real terminal applies.
|
||||
const applySgr = (state: { bold: boolean; dim: boolean; fg: string }, str: string) => {
|
||||
const re = new RegExp(`${ESC}\\[([0-9;]*)m`, 'g')
|
||||
let match: null | RegExpExecArray
|
||||
|
||||
while ((match = re.exec(str)) !== null) {
|
||||
const parts = (match[1] || '0').split(';')
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const p = parts[i]!
|
||||
|
||||
if (p === '0' || p === '') {
|
||||
state.bold = false
|
||||
state.dim = false
|
||||
state.fg = 'default'
|
||||
} else if (p === '1') {
|
||||
state.bold = true
|
||||
} else if (p === '2') {
|
||||
state.dim = true
|
||||
} else if (p === '22') {
|
||||
state.bold = false
|
||||
state.dim = false
|
||||
} else if (p === '39') {
|
||||
state.fg = 'default'
|
||||
} else if (p === '38' && parts[i + 1] === '5') {
|
||||
state.fg = `ansi${parts[i + 2]}`
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('every pairwise transition lands the terminal in exactly the target state', () => {
|
||||
const pool = new StylePool()
|
||||
|
||||
const ids = [
|
||||
pool.none,
|
||||
pool.intern([BOLD]),
|
||||
pool.intern([DIM]),
|
||||
pool.intern([FG_PINK]),
|
||||
pool.intern([BOLD, FG_PINK]),
|
||||
pool.intern([DIM, FG_GRAY]),
|
||||
pool.intern([DIM, FG_PINK]),
|
||||
pool.intern([BOLD, FG_GRAY])
|
||||
]
|
||||
|
||||
const expected = (id: number) => {
|
||||
const styles = pool.get(id)
|
||||
const fgCode = styles.find(s => s.endCode === `${ESC}[39m`)?.code
|
||||
|
||||
return {
|
||||
bold: styles.some(s => s.code === `${ESC}[1m`),
|
||||
dim: styles.some(s => s.code === `${ESC}[2m`),
|
||||
fg: fgCode ? `ansi${fgCode.slice(7, -1)}` : 'default'
|
||||
}
|
||||
}
|
||||
|
||||
for (const from of ids) {
|
||||
for (const to of ids) {
|
||||
const state = expected(from)
|
||||
applySgr(state, pool.transition(from, to))
|
||||
expect(state, `transition ${from} → ${to}`).toEqual(expected(to))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { type AnsiCode, diffAnsiCodes } from '@alcalzone/ansi-tokenize'
|
||||
|
||||
/**
|
||||
* Bold (SGR 1) and dim (SGR 2) are INDEPENDENT terminal attributes that share
|
||||
* one reset code (SGR 22). `diffAnsiCodes` models "same endCode" as "same
|
||||
* slot, new start code overwrites" — true for fg (39) / bg (49), false here:
|
||||
* emitting `[2m` over a bold cell yields bold+dim, not dim.
|
||||
*
|
||||
* Every transition the diff renderer emits from that assumption leaves the
|
||||
* terminal's real attributes diverged from the StylePool's tracked state, and
|
||||
* because later transitions are computed FROM that tracked state, the
|
||||
* corruption compounds and sticks — visible as random spans of wrong
|
||||
* weight/brightness ("random dimness/opacity changes") that depend on which
|
||||
* cells happened to change in which order.
|
||||
*
|
||||
* Weight flags hide in two shapes: standalone `[1m`/`[2m` (endCode `[22m`),
|
||||
* and compound sequences from real tool output — `[1;31m` ls/grep style —
|
||||
* whose endCode is `[0m`, dodging any endCode-based check. Both are detected
|
||||
* by parsing the params (skipping 38/48 extended-color arguments, whose
|
||||
* literal `2`/`5` sub-params are not SGR atoms).
|
||||
*/
|
||||
const WEIGHT_END = '\u001b[22m'
|
||||
|
||||
const WEIGHT_RESET: AnsiCode = {
|
||||
type: 'ansi',
|
||||
code: WEIGHT_END,
|
||||
endCode: WEIGHT_END
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- SGR CSI matcher; ESC is intentional
|
||||
const SGR_PARAMS_RE = /^\u001b\[([0-9;]*)m$/
|
||||
|
||||
/** The bold/dim atoms ('1' / '2') a single SGR sequence turns on. */
|
||||
function weightAtoms(code: AnsiCode): string[] {
|
||||
const match = SGR_PARAMS_RE.exec(code.code)
|
||||
|
||||
if (!match) {
|
||||
return []
|
||||
}
|
||||
|
||||
const parts = (match[1] || '0').split(';')
|
||||
const atoms: string[] = []
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const p = parts[i] || '0'
|
||||
|
||||
if ((p === '38' || p === '48') && i + 1 < parts.length) {
|
||||
// Extended color: consume the argument sub-params so their literal
|
||||
// 2/5 aren't read as weight atoms.
|
||||
i += parts[i + 1] === '5' ? 2 : parts[i + 1] === '2' ? 4 : 0
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (p === '1' || p === '2') {
|
||||
atoms.push(p)
|
||||
}
|
||||
}
|
||||
|
||||
return atoms
|
||||
}
|
||||
|
||||
const carriesWeight = (code: AnsiCode): boolean => weightAtoms(code).length > 0
|
||||
|
||||
/**
|
||||
* Like `diffAnsiCodes`, but correct for the shared-reset weight family:
|
||||
* when the bold/dim set changes in a way that removes a flag, emit SGR 22
|
||||
* first, then re-apply every weight-carrying sequence the target style has.
|
||||
*/
|
||||
export function transitionAnsiCodes(from: AnsiCode[], to: AnsiCode[]): AnsiCode[] {
|
||||
const fromAtoms = new Set(from.flatMap(weightAtoms))
|
||||
|
||||
if (fromAtoms.size === 0) {
|
||||
// Nothing to un-set; the library's "add what's missing" pass is correct.
|
||||
return diffAnsiCodes(from, to)
|
||||
}
|
||||
|
||||
const toAtoms = new Set(to.flatMap(weightAtoms))
|
||||
let removesWeight = false
|
||||
|
||||
for (const atom of fromAtoms) {
|
||||
if (!toAtoms.has(atom)) {
|
||||
removesWeight = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!removesWeight) {
|
||||
// from's weights ⊆ to's weights — additions only, library handles it.
|
||||
return diffAnsiCodes(from, to)
|
||||
}
|
||||
|
||||
// A weight flag must be dropped: SGR 22 is the only way (it clears BOTH),
|
||||
// so reset the family and re-apply the target's weight-carrying sequences
|
||||
// in full (a compound re-asserts its color too — redundant bytes, never
|
||||
// wrong). The rest of the style diffs normally with the weight carriers
|
||||
// stripped from both sides.
|
||||
const rest = diffAnsiCodes(
|
||||
from.filter(code => !carriesWeight(code)),
|
||||
to.filter(code => !carriesWeight(code))
|
||||
)
|
||||
|
||||
return [WEIGHT_RESET, ...rest, ...to.filter(carriesWeight)]
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
import React, { useContext, useEffect } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import StdinContext from './components/StdinContext.js'
|
||||
import Text from './components/Text.js'
|
||||
import Ink from './ink.js'
|
||||
import instances from './instances.js'
|
||||
import { csi } from './termio/csi.js'
|
||||
import { DEC, DISABLE_MOUSE_TRACKING, enableMouseTrackingFor } from './termio/dec.js'
|
||||
|
||||
// DECRQM request for mode 1000 (what the watchdog writes).
|
||||
const DECRQM_1000 = csi(`?${DEC.MOUSE_NORMAL}$p`)
|
||||
// DA1 sentinel (what querier.flush() writes).
|
||||
const DA1_REQUEST = csi('c')
|
||||
// DECRPM replies (what the terminal answers).
|
||||
const DECRPM_1000_SET = csi(`?${DEC.MOUSE_NORMAL};1$y`)
|
||||
const DECRPM_1000_RESET = csi(`?${DEC.MOUSE_NORMAL};2$y`)
|
||||
const DA1_REPLY = csi('?62c')
|
||||
|
||||
// Watchdog cadence (mirrors MOUSE_WATCHDOG_INTERVAL_MS in App.tsx).
|
||||
const TICK_MS = 2000
|
||||
|
||||
class FakeStdout extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
columns = 80
|
||||
rows = 24
|
||||
isTTY = true
|
||||
|
||||
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
|
||||
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
cb?.()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Stdin fake with a real readable-style buffer so tests can feed terminal
|
||||
// responses (DECRPM / DA1) the way a live pty would deliver them.
|
||||
class FakeStdin extends EventEmitter {
|
||||
isTTY = true
|
||||
isRaw = false
|
||||
private buffer: string[] = []
|
||||
|
||||
get readableLength(): number {
|
||||
return this.buffer.reduce((n, c) => n + c.length, 0)
|
||||
}
|
||||
|
||||
ref(): void {}
|
||||
unref(): void {}
|
||||
setEncoding(): this {
|
||||
return this
|
||||
}
|
||||
setRawMode(mode: boolean): this {
|
||||
this.isRaw = mode
|
||||
|
||||
return this
|
||||
}
|
||||
read(): string | null {
|
||||
return this.buffer.shift() ?? null
|
||||
}
|
||||
feed(data: string): void {
|
||||
this.buffer.push(data)
|
||||
this.emit('readable')
|
||||
}
|
||||
}
|
||||
|
||||
function RawModeConsumer() {
|
||||
const { setRawMode, isRawModeSupported } = useContext(StdinContext)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRawModeSupported) {
|
||||
return
|
||||
}
|
||||
|
||||
setRawMode(true)
|
||||
|
||||
return () => setRawMode(false)
|
||||
}, [isRawModeSupported, setRawMode])
|
||||
|
||||
return React.createElement(Text, null, 'x')
|
||||
}
|
||||
|
||||
type Harness = {
|
||||
ink: Ink
|
||||
stdout: FakeStdout
|
||||
stdin: FakeStdin
|
||||
/** Advance one watchdog tick and let the probe write settle. */
|
||||
tickWatchdog: () => Promise<void>
|
||||
/** Feed a terminal response and let promise resolution settle. */
|
||||
answer: (data: string) => Promise<void>
|
||||
}
|
||||
|
||||
const flushMicrotasks = async () => {
|
||||
// Real setImmediate turns: lets React flush effects (raw-mode enable),
|
||||
// the deferred init writes fire, and querier promise chains settle.
|
||||
// Two rounds cover promise → setImmediate interleave.
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
}
|
||||
|
||||
async function mount(mouseTracking: 'all' | 'off' = 'all'): Promise<Harness> {
|
||||
const stdout = new FakeStdout()
|
||||
const stdin = new FakeStdin()
|
||||
const stderr = new FakeStdout()
|
||||
|
||||
const ink = new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
// Production instances are registered by render.ts; direct construction
|
||||
// skips that. The watchdog (like the raw-mode re-assert) resolves its
|
||||
// Ink through this map, so mirror production here.
|
||||
instances.set(stdout as unknown as NodeJS.WriteStream, ink)
|
||||
|
||||
ink.setAltScreenActive(true, mouseTracking)
|
||||
ink.render(React.createElement(RawModeConsumer))
|
||||
ink.onRender()
|
||||
await flushMicrotasks()
|
||||
|
||||
// The XTVERSION probe from raw-mode entry has its own DA1 sentinel
|
||||
// pending. Answer it so the querier queue is empty before tests start.
|
||||
stdin.feed(DA1_REPLY)
|
||||
await flushMicrotasks()
|
||||
|
||||
stdout.chunks = []
|
||||
|
||||
return {
|
||||
ink,
|
||||
stdout,
|
||||
stdin,
|
||||
tickWatchdog: async () => {
|
||||
await vi.advanceTimersByTimeAsync(TICK_MS)
|
||||
},
|
||||
answer: async (data: string) => {
|
||||
stdin.feed(data)
|
||||
await flushMicrotasks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('App mouse-mode watchdog', () => {
|
||||
beforeEach(() => {
|
||||
// Fake only the interval + Date clock. setImmediate/setTimeout stay
|
||||
// real so React effect flushing and Ink's internal scheduling work.
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('probes DECRQM on the interval and re-asserts tracking when the terminal reports RESET', async () => {
|
||||
const h = await mount('all')
|
||||
|
||||
await h.tickWatchdog()
|
||||
|
||||
// Probe went out: DECRQM for mode 1000 + DA1 sentinel.
|
||||
const probed = h.stdout.chunks.join('')
|
||||
|
||||
expect(probed).toContain(DECRQM_1000)
|
||||
expect(probed).toContain(DA1_REQUEST)
|
||||
|
||||
h.stdout.chunks = []
|
||||
|
||||
// Terminal says mode 1000 is RESET (someone cleared our modes), then
|
||||
// answers the sentinel.
|
||||
await h.answer(DECRPM_1000_RESET + DA1_REPLY)
|
||||
|
||||
const out = h.stdout.chunks.join('')
|
||||
|
||||
// reassertTerminalModes: DISABLE first, then the full 'all' preset.
|
||||
expect(out).toContain(DISABLE_MOUSE_TRACKING)
|
||||
expect(out).toContain(enableMouseTrackingFor('all'))
|
||||
|
||||
h.ink.unmount()
|
||||
})
|
||||
|
||||
it('does nothing when the terminal reports the mode still SET', async () => {
|
||||
const h = await mount('all')
|
||||
|
||||
await h.tickWatchdog()
|
||||
h.stdout.chunks = []
|
||||
|
||||
await h.answer(DECRPM_1000_SET + DA1_REPLY)
|
||||
|
||||
expect(h.stdout.chunks.join('')).not.toContain(enableMouseTrackingFor('all'))
|
||||
|
||||
h.ink.unmount()
|
||||
})
|
||||
|
||||
it('disables itself permanently when the terminal ignores DECRQM', async () => {
|
||||
const h = await mount('all')
|
||||
|
||||
await h.tickWatchdog()
|
||||
expect(h.stdout.chunks.join('')).toContain(DECRQM_1000)
|
||||
h.stdout.chunks = []
|
||||
|
||||
// Terminal answers only the DA1 sentinel — DECRQM unsupported.
|
||||
await h.answer(DA1_REPLY)
|
||||
|
||||
// No re-assert...
|
||||
expect(h.stdout.chunks.join('')).not.toContain(enableMouseTrackingFor('all'))
|
||||
|
||||
// ...and no further probes on subsequent ticks.
|
||||
await h.tickWatchdog()
|
||||
await h.tickWatchdog()
|
||||
expect(h.stdout.chunks.join('')).not.toContain(DECRQM_1000)
|
||||
|
||||
h.ink.unmount()
|
||||
})
|
||||
|
||||
it('does not probe when mouse tracking is off', async () => {
|
||||
const h = await mount('off')
|
||||
|
||||
await h.tickWatchdog()
|
||||
await h.tickWatchdog()
|
||||
|
||||
expect(h.stdout.chunks.join('')).not.toContain(DECRQM_1000)
|
||||
|
||||
h.ink.unmount()
|
||||
})
|
||||
|
||||
it('skips the probe when a mouse event arrived within the interval', async () => {
|
||||
const h = await mount('all')
|
||||
|
||||
// Half a tick in, a live SGR mouse event (wheel-up at 10;5) proves
|
||||
// tracking works; the interval fires half a tick later → gap < interval.
|
||||
await vi.advanceTimersByTimeAsync(TICK_MS / 2)
|
||||
await h.answer(csi('<64;10;5M'))
|
||||
await vi.advanceTimersByTimeAsync(TICK_MS / 2)
|
||||
|
||||
expect(h.stdout.chunks.join('')).not.toContain(DECRQM_1000)
|
||||
|
||||
h.ink.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { handleMouseEvent } from './components/App.js'
|
||||
import { createSelectionState, hasSelection, startSelection, updateSelection } from './selection.js'
|
||||
|
||||
const makeApp = () => {
|
||||
const selection = createSelectionState()
|
||||
|
||||
return {
|
||||
clickCount: 1,
|
||||
lastHoverCol: -1,
|
||||
lastHoverRow: -1,
|
||||
mouseCaptureTarget: undefined,
|
||||
props: {
|
||||
getSelectedText: vi.fn(() => 'selected text'),
|
||||
onCopySelectionNoClear: vi.fn(async () => 'selected text'),
|
||||
onHoverAt: vi.fn(),
|
||||
onMouseDownAt: vi.fn(),
|
||||
onMouseDragAt: vi.fn(),
|
||||
onMouseUpAt: vi.fn(),
|
||||
onSelectionChange: vi.fn(),
|
||||
selection
|
||||
}
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('handleMouseEvent right-click selection behavior', () => {
|
||||
it('copies an active selection instead of dispatching right-click paste handlers', async () => {
|
||||
const app = makeApp()
|
||||
|
||||
startSelection(app.props.selection, 0, 0)
|
||||
updateSelection(app.props.selection, 4, 0)
|
||||
|
||||
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(app.props.onCopySelectionNoClear).toHaveBeenCalledOnce()
|
||||
expect(app.props.onMouseDownAt).not.toHaveBeenCalled()
|
||||
expect(app.clickCount).toBe(0)
|
||||
})
|
||||
|
||||
it('clears the highlight after a successful right-click copy', async () => {
|
||||
const app = makeApp()
|
||||
|
||||
startSelection(app.props.selection, 0, 0)
|
||||
updateSelection(app.props.selection, 4, 0)
|
||||
expect(hasSelection(app.props.selection)).toBe(true)
|
||||
|
||||
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1, sequence: '' })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// Deliberate copy clears the selection (visual confirmation + a follow-up
|
||||
// right-click on empty space pastes rather than re-copying a stale range).
|
||||
expect(hasSelection(app.props.selection)).toBe(false)
|
||||
expect(app.props.onSelectionChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the highlight when right-click copy fails (no clipboard path)', async () => {
|
||||
const app = makeApp()
|
||||
app.props.onCopySelectionNoClear.mockResolvedValue('')
|
||||
|
||||
startSelection(app.props.selection, 0, 0)
|
||||
updateSelection(app.props.selection, 4, 0)
|
||||
|
||||
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1, sequence: '' })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// Copy didn't land, so the highlight must survive (and we fall back to the
|
||||
// right-click paste handler instead).
|
||||
expect(hasSelection(app.props.selection)).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to right-click handlers when selection copy has no clipboard path', async () => {
|
||||
const app = makeApp()
|
||||
app.props.onCopySelectionNoClear.mockResolvedValue('')
|
||||
|
||||
startSelection(app.props.selection, 0, 0)
|
||||
updateSelection(app.props.selection, 4, 0)
|
||||
|
||||
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(app.props.onCopySelectionNoClear).toHaveBeenCalledOnce()
|
||||
expect(app.props.onMouseDownAt).toHaveBeenCalledWith(2, 0, 2)
|
||||
})
|
||||
|
||||
it('does not paste when highlighted selection text is empty', async () => {
|
||||
const app = makeApp()
|
||||
app.props.getSelectedText.mockReturnValue('')
|
||||
|
||||
startSelection(app.props.selection, 0, 0)
|
||||
updateSelection(app.props.selection, 4, 0)
|
||||
|
||||
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(app.props.onCopySelectionNoClear).not.toHaveBeenCalled()
|
||||
expect(app.props.onMouseDownAt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not repeatedly copy or paste during right-button motion events over a selection', () => {
|
||||
const app = makeApp()
|
||||
|
||||
startSelection(app.props.selection, 0, 0)
|
||||
updateSelection(app.props.selection, 4, 0)
|
||||
|
||||
handleMouseEvent(app, { action: 'press', button: 0x20 | 2, col: 3, kind: 'mouse', row: 1 })
|
||||
|
||||
expect(app.props.onCopySelectionNoClear).not.toHaveBeenCalled()
|
||||
expect(app.props.onMouseDownAt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still dispatches right-click handlers when no text is selected', () => {
|
||||
const app = makeApp()
|
||||
|
||||
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 })
|
||||
|
||||
expect(app.props.onCopySelectionNoClear).not.toHaveBeenCalled()
|
||||
expect(app.props.onMouseDownAt).toHaveBeenCalledWith(2, 0, 2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
import React, { useContext, useEffect } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import StdinContext from './components/StdinContext.js'
|
||||
import Text from './components/Text.js'
|
||||
import Ink from './ink.js'
|
||||
import { DISABLE_MOUSE_TRACKING } from './termio/dec.js'
|
||||
|
||||
class FakeTty extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
columns = 80
|
||||
rows = 24
|
||||
isTTY = true
|
||||
isRaw = false
|
||||
|
||||
ref(): void {}
|
||||
unref(): void {}
|
||||
read(): null {
|
||||
return null
|
||||
}
|
||||
setEncoding(): this {
|
||||
return this
|
||||
}
|
||||
setRawMode(mode: boolean): this {
|
||||
this.isRaw = mode
|
||||
|
||||
return this
|
||||
}
|
||||
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
|
||||
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
cb?.()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const tick = () => new Promise<void>(resolve => setImmediate(resolve))
|
||||
|
||||
// A child that grabs the last useInput consumer's raw-mode toggle. Mounting
|
||||
// enables raw mode (count 0→1); unmounting disables it (count 1→0), which is
|
||||
// the teardown path that must DISABLE_MOUSE_TRACKING so DEC 1003 hover can't
|
||||
// leak as cooked-mode `35;col;row M` text over the prompt.
|
||||
function RawModeConsumer({ active }: { active: boolean }) {
|
||||
const { setRawMode, isRawModeSupported } = useContext(StdinContext)
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !isRawModeSupported) {
|
||||
return
|
||||
}
|
||||
|
||||
setRawMode(true)
|
||||
|
||||
return () => setRawMode(false)
|
||||
}, [active, isRawModeSupported, setRawMode])
|
||||
|
||||
return React.createElement(Text, null, 'x')
|
||||
}
|
||||
|
||||
describe('App raw-mode teardown', () => {
|
||||
it('disables mouse tracking when the last raw-mode consumer detaches', async () => {
|
||||
const stdout = new FakeTty()
|
||||
const stdin = new FakeTty()
|
||||
const stderr = new FakeTty()
|
||||
|
||||
const ink = new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
// Mouse tracking is asserted on the alt screen; the teardown path lives in
|
||||
// App, independent of who enabled tracking.
|
||||
ink.setAltScreenActive(true, 'all')
|
||||
ink.render(React.createElement(RawModeConsumer, { active: true }))
|
||||
ink.onRender()
|
||||
await tick()
|
||||
expect(stdin.isRaw).toBe(true)
|
||||
|
||||
stdout.chunks = []
|
||||
|
||||
// Drop the consumer → raw-mode count hits 0 → teardown runs.
|
||||
ink.render(React.createElement(RawModeConsumer, { active: false }))
|
||||
ink.onRender()
|
||||
await tick()
|
||||
|
||||
expect(stdin.isRaw).toBe(false)
|
||||
expect(stdout.chunks.join('')).toContain(DISABLE_MOUSE_TRACKING)
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import App from './components/App.js'
|
||||
|
||||
// Regression for issue #31486: when processInput throws inside the
|
||||
// handleReadable read loop, any bytes still buffered in stdin are stranded
|
||||
// because Node only emits 'readable' on buffer transitions, not for data
|
||||
// the consumer has already been notified about. Without a re-pump, the
|
||||
// TUI freezes; stdin appears wedged while the agent loop keeps running.
|
||||
|
||||
const makeFakeStdin = (initialChunks: Array<string | null>) => {
|
||||
const queue: Array<string | null> = [...initialChunks]
|
||||
const readableListeners: Array<() => void> = []
|
||||
|
||||
return {
|
||||
addListener: vi.fn((event: string, fn: () => void) => {
|
||||
if (event === 'readable') {
|
||||
readableListeners.push(fn)
|
||||
}
|
||||
}),
|
||||
listeners: vi.fn((event: string) => (event === 'readable' ? [...readableListeners] : [])),
|
||||
read: vi.fn(() => (queue.length > 0 ? queue.shift()! : null)),
|
||||
get readableLength() {
|
||||
return queue.filter(c => c !== null).reduce((n, c) => n + (c as string).length, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const noopStream = { isTTY: false, write: () => true } as unknown as NodeJS.WriteStream
|
||||
|
||||
const makeApp = (stdin: ReturnType<typeof makeFakeStdin>) => {
|
||||
// Construct a real App instance with minimal props. PureComponent only
|
||||
// stores `props`; class-field arrows (including handleReadable) bind to
|
||||
// the instance during construction.
|
||||
const app = new App({
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: noopStream,
|
||||
stderr: noopStream,
|
||||
exitOnCtrlC: false,
|
||||
onExit: vi.fn(),
|
||||
terminalColumns: 80,
|
||||
terminalRows: 24,
|
||||
selection: undefined as any,
|
||||
onSelectionChange: vi.fn(),
|
||||
onClickAt: vi.fn(() => false),
|
||||
onMouseDownAt: vi.fn(() => undefined),
|
||||
onMouseUpAt: vi.fn(),
|
||||
onMouseDragAt: vi.fn(),
|
||||
onHoverAt: vi.fn(),
|
||||
onCopySelectionNoClear: vi.fn(async () => ''),
|
||||
getSelectedText: vi.fn(() => ''),
|
||||
getHyperlinkAt: vi.fn(() => undefined),
|
||||
onOpenHyperlink: vi.fn(),
|
||||
onMultiClick: vi.fn(),
|
||||
onSelectionDrag: vi.fn(),
|
||||
onStdinResume: vi.fn(),
|
||||
dispatchKeyboardEvent: vi.fn(),
|
||||
children: null as any
|
||||
} as any)
|
||||
|
||||
;(app as any).rawModeEnabledCount = 1
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
describe('App.handleReadable error recovery (issue #31486)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('re-pumps the readable handler when bytes remain buffered after a throw', () => {
|
||||
const stdin = makeFakeStdin(['boom', 'queued-keystroke', null])
|
||||
const app = makeApp(stdin)
|
||||
|
||||
let calls = 0
|
||||
|
||||
;(app as any).processInput = vi.fn((chunk: string) => {
|
||||
calls++
|
||||
|
||||
if (calls === 1) {
|
||||
throw new Error('synthetic processInput failure')
|
||||
}
|
||||
|
||||
void chunk
|
||||
})
|
||||
;(app as any).handleReadable()
|
||||
|
||||
// First handler run threw mid-loop. The remaining chunk is still in
|
||||
// the fake stdin buffer; without the re-pump, Node would never call
|
||||
// the listener again because no new bytes arrive.
|
||||
expect((app as any).processInput).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.runAllTimers()
|
||||
|
||||
expect((app as any).processInput).toHaveBeenCalledTimes(2)
|
||||
expect((app as any).processInput).toHaveBeenLastCalledWith('queued-keystroke')
|
||||
})
|
||||
|
||||
it('does not re-pump when raw mode has been fully disabled during recovery', () => {
|
||||
const stdin = makeFakeStdin(['boom', 'stranded', null])
|
||||
|
||||
const app = makeApp(stdin)
|
||||
|
||||
;(app as any).processInput = vi.fn(() => {
|
||||
// Simulate a useInput handler that disabled raw mode and threw.
|
||||
;(app as any).rawModeEnabledCount = 0
|
||||
throw new Error('synthetic')
|
||||
})
|
||||
;(app as any).handleReadable()
|
||||
vi.runAllTimers()
|
||||
|
||||
expect((app as any).processInput).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Bidirectional text reordering for terminal rendering.
|
||||
*
|
||||
* Terminals on Windows do not implement the Unicode Bidi Algorithm,
|
||||
* so RTL text (Hebrew, Arabic, etc.) appears reversed. This module
|
||||
* applies the bidi algorithm to reorder ClusteredChar arrays from
|
||||
* logical order to visual order before Ink's LTR cell placement loop.
|
||||
*
|
||||
* On macOS terminals (Terminal.app, iTerm2) bidi works natively.
|
||||
* Windows Terminal (including WSL) does not implement bidi
|
||||
* (https://github.com/microsoft/terminal/issues/538).
|
||||
*
|
||||
* Detection: Windows Terminal sets WT_SESSION; native Windows cmd/conhost
|
||||
* also lacks bidi. We enable bidi reordering when running on Windows or
|
||||
* inside Windows Terminal (covers WSL).
|
||||
*/
|
||||
import bidiFactory from 'bidi-js'
|
||||
|
||||
type ClusteredChar = {
|
||||
value: string
|
||||
width: number
|
||||
styleId: number
|
||||
hyperlink: string | undefined
|
||||
}
|
||||
|
||||
let bidiInstance: ReturnType<typeof bidiFactory> | undefined
|
||||
let needsSoftwareBidi: boolean | undefined
|
||||
|
||||
function needsBidi(): boolean {
|
||||
if (needsSoftwareBidi === undefined) {
|
||||
needsSoftwareBidi =
|
||||
process.platform === 'win32' ||
|
||||
typeof process.env['WT_SESSION'] === 'string' || // WSL in Windows Terminal
|
||||
process.env['TERM_PROGRAM'] === 'vscode' // VS Code integrated terminal (xterm.js)
|
||||
}
|
||||
|
||||
return needsSoftwareBidi
|
||||
}
|
||||
|
||||
function getBidi() {
|
||||
if (!bidiInstance) {
|
||||
bidiInstance = bidiFactory()
|
||||
}
|
||||
|
||||
return bidiInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder an array of ClusteredChars from logical order to visual order
|
||||
* using the Unicode Bidi Algorithm. Active on terminals that lack native
|
||||
* bidi support (Windows Terminal, conhost, WSL).
|
||||
*
|
||||
* Returns the same array on bidi-capable terminals (no-op).
|
||||
*/
|
||||
export function reorderBidi(characters: ClusteredChar[]): ClusteredChar[] {
|
||||
if (!needsBidi() || characters.length === 0) {
|
||||
return characters
|
||||
}
|
||||
|
||||
// Build a plain string from the clustered chars to run through bidi
|
||||
const plainText = characters.map(c => c.value).join('')
|
||||
|
||||
// Check if there are any RTL characters — skip bidi if pure LTR
|
||||
if (!hasRTLCharacters(plainText)) {
|
||||
return characters
|
||||
}
|
||||
|
||||
const bidi = getBidi()
|
||||
const { levels } = bidi.getEmbeddingLevels(plainText, 'auto')
|
||||
|
||||
// Map bidi levels back to ClusteredChar indices.
|
||||
// Each ClusteredChar may be multiple code units in the joined string.
|
||||
const charLevels: number[] = []
|
||||
let offset = 0
|
||||
|
||||
for (let i = 0; i < characters.length; i++) {
|
||||
charLevels.push(levels[offset]!)
|
||||
offset += characters[i]!.value.length
|
||||
}
|
||||
|
||||
// Get reorder segments from bidi-js, but we need to work at the
|
||||
// ClusteredChar level, not the string level. We'll implement the
|
||||
// standard bidi reordering: find the max level, then for each level
|
||||
// from max down to 1, reverse all contiguous runs >= that level.
|
||||
const reordered = [...characters]
|
||||
const maxLevel = Math.max(...charLevels)
|
||||
|
||||
for (let level = maxLevel; level >= 1; level--) {
|
||||
let i = 0
|
||||
|
||||
while (i < reordered.length) {
|
||||
if (charLevels[i]! >= level) {
|
||||
// Find the end of this run
|
||||
let j = i + 1
|
||||
|
||||
while (j < reordered.length && charLevels[j]! >= level) {
|
||||
j++
|
||||
}
|
||||
|
||||
// Reverse the run in both arrays
|
||||
reverseRange(reordered, i, j - 1)
|
||||
reverseRangeNumbers(charLevels, i, j - 1)
|
||||
i = j
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reordered
|
||||
}
|
||||
|
||||
function reverseRange<T>(arr: T[], start: number, end: number): void {
|
||||
while (start < end) {
|
||||
const temp = arr[start]!
|
||||
arr[start] = arr[end]!
|
||||
arr[end] = temp
|
||||
start++
|
||||
end--
|
||||
}
|
||||
}
|
||||
|
||||
function reverseRangeNumbers(arr: number[], start: number, end: number): void {
|
||||
while (start < end) {
|
||||
const temp = arr[start]!
|
||||
arr[start] = arr[end]!
|
||||
arr[end] = temp
|
||||
start++
|
||||
end--
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check for RTL characters (Hebrew, Arabic, and related scripts).
|
||||
* Avoids running the full bidi algorithm on pure-LTR text.
|
||||
*/
|
||||
function hasRTLCharacters(text: string): boolean {
|
||||
// Hebrew: U+0590-U+05FF, U+FB1D-U+FB4F
|
||||
// Arabic: U+0600-U+06FF, U+0750-U+077F, U+08A0-U+08FF, U+FB50-U+FDFF, U+FE70-U+FEFF
|
||||
// Thaana: U+0780-U+07BF
|
||||
// Syriac: U+0700-U+074F
|
||||
return /[\u0590-\u05FF\uFB1D-\uFB4F\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF\u0780-\u07BF\u0700-\u074F]/u.test(
|
||||
text
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Unified cache eviction for the four hot Ink module-level caches:
|
||||
// - widthCache (stringWidth.ts)
|
||||
// - wrapCache (wrap-text.ts)
|
||||
// - sliceCache (sliceAnsi.ts)
|
||||
// - lineWidthCache (line-width-cache.ts)
|
||||
//
|
||||
// Used by the host (TUI) under memory pressure or on session swap to drop
|
||||
// content-keyed entries that won't recur. All caches are content-keyed
|
||||
// (not session-keyed), so cross-session sharing is normally beneficial —
|
||||
// only evict when memory tightens or when the user explicitly resets.
|
||||
|
||||
import { evictSliceCache, sliceCacheSize } from '../utils/sliceAnsi.js'
|
||||
|
||||
import { evictLineWidthCache, lineWidthCacheSize } from './line-width-cache.js'
|
||||
import { evictWidthCache, widthCacheSize } from './stringWidth.js'
|
||||
import { evictWrapCache, wrapCacheSize } from './wrap-text.js'
|
||||
|
||||
export interface InkCacheSizes {
|
||||
lineWidth: number
|
||||
slice: number
|
||||
width: number
|
||||
wrap: number
|
||||
}
|
||||
|
||||
function inkCacheSizes(): InkCacheSizes {
|
||||
return {
|
||||
lineWidth: lineWidthCacheSize(),
|
||||
slice: sliceCacheSize(),
|
||||
width: widthCacheSize(),
|
||||
wrap: wrapCacheSize()
|
||||
}
|
||||
}
|
||||
|
||||
export type EvictLevel = 'all' | 'half'
|
||||
|
||||
export function evictInkCaches(level: EvictLevel = 'half'): InkCacheSizes {
|
||||
const keep = level === 'half' ? 0.5 : 0
|
||||
|
||||
evictWidthCache(keep)
|
||||
evictWrapCache(keep)
|
||||
evictSliceCache(keep)
|
||||
evictLineWidthCache(keep)
|
||||
|
||||
return inkCacheSizes()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Cross-platform terminal clearing with scrollback support.
|
||||
* Detects modern terminals that support ESC[3J for clearing scrollback.
|
||||
*/
|
||||
|
||||
import { csi, CURSOR_HOME, ERASE_SCREEN, ERASE_SCROLLBACK } from './termio/csi.js'
|
||||
|
||||
// HVP (Horizontal Vertical Position) - legacy Windows cursor home
|
||||
const CURSOR_HOME_WINDOWS = csi(0, 'f')
|
||||
|
||||
function isWindowsTerminal(): boolean {
|
||||
return process.platform === 'win32' && !!process.env.WT_SESSION
|
||||
}
|
||||
|
||||
function isMintty(): boolean {
|
||||
// mintty 3.1.5+ sets TERM_PROGRAM to 'mintty'
|
||||
if (process.env.TERM_PROGRAM === 'mintty') {
|
||||
return true
|
||||
}
|
||||
|
||||
// GitBash/MSYS2/MINGW use mintty and set MSYSTEM
|
||||
if (process.platform === 'win32' && process.env.MSYSTEM) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isModernWindowsTerminal(): boolean {
|
||||
// Windows Terminal sets WT_SESSION environment variable
|
||||
if (isWindowsTerminal()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// VS Code integrated terminal on Windows with ConPTY support
|
||||
if (process.platform === 'win32' && process.env.TERM_PROGRAM === 'vscode' && process.env.TERM_PROGRAM_VERSION) {
|
||||
return true
|
||||
}
|
||||
|
||||
// mintty (GitBash/MSYS2/Cygwin) supports modern escape sequences
|
||||
if (isMintty()) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ANSI escape sequence to clear the terminal including scrollback.
|
||||
* Automatically detects terminal capabilities.
|
||||
*/
|
||||
export function getClearTerminalSequence(): string {
|
||||
if (process.platform === 'win32') {
|
||||
if (isModernWindowsTerminal()) {
|
||||
return ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME
|
||||
} else {
|
||||
// Legacy Windows console - can't clear scrollback
|
||||
return ERASE_SCREEN + CURSOR_HOME_WINDOWS
|
||||
}
|
||||
}
|
||||
|
||||
return ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the terminal screen. On supported terminals, also clears scrollback.
|
||||
*/
|
||||
export const clearTerminal = getClearTerminalSequence()
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE,
|
||||
richEightBitColorNumber,
|
||||
shouldUseRichEightBitDowngradeForLegacyAppleTerminal
|
||||
} from './colorize.js'
|
||||
|
||||
describe('shouldUseRichEightBitDowngradeForLegacyAppleTerminal', () => {
|
||||
it('memoizes the current process decision for render hot paths', () => {
|
||||
expect(typeof CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE).toBe('boolean')
|
||||
})
|
||||
|
||||
it('uses Rich-compatible 256-color downgrade on legacy Apple Terminal', () => {
|
||||
expect(
|
||||
shouldUseRichEightBitDowngradeForLegacyAppleTerminal({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv, 2)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes Apple Terminal names before matching', () => {
|
||||
expect(
|
||||
shouldUseRichEightBitDowngradeForLegacyAppleTerminal({ TERM_PROGRAM: ' Apple_Terminal ' } as NodeJS.ProcessEnv, 2)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not rewrite when Apple Terminal advertises truecolor', () => {
|
||||
expect(
|
||||
shouldUseRichEightBitDowngradeForLegacyAppleTerminal(
|
||||
{ COLORTERM: 'truecolor', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv,
|
||||
3
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not override explicit color environment choices', () => {
|
||||
expect(
|
||||
shouldUseRichEightBitDowngradeForLegacyAppleTerminal(
|
||||
{ FORCE_COLOR: '2', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv,
|
||||
2
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldUseRichEightBitDowngradeForLegacyAppleTerminal(
|
||||
{ HERMES_TUI_TRUECOLOR: '1', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv,
|
||||
3
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('richEightBitColorNumber', () => {
|
||||
it('matches Rich downgrade output for default Hermes skin colors', () => {
|
||||
expect(richEightBitColorNumber(0xff, 0xd7, 0x00)).toBe(220)
|
||||
expect(richEightBitColorNumber(0xff, 0xbf, 0x00)).toBe(214)
|
||||
expect(richEightBitColorNumber(0xcd, 0x7f, 0x32)).toBe(173)
|
||||
expect(richEightBitColorNumber(0xb8, 0x86, 0x0b)).toBe(136)
|
||||
expect(richEightBitColorNumber(0xff, 0xf8, 0xdc)).toBe(230)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,283 @@
|
||||
import chalk from 'chalk'
|
||||
|
||||
import type { Color, TextStyles } from './styles.js'
|
||||
|
||||
/**
|
||||
* xterm.js (VS Code, Cursor, code-server, Coder) has supported truecolor
|
||||
* since 2017, but code-server/Coder containers often don't set
|
||||
* 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) → 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
|
||||
* Code sets COLORTERM=truecolor itself, so this is a no-op there (already 3).
|
||||
*
|
||||
* Must run BEFORE the tmux clamp — if tmux is running inside a VS Code
|
||||
* terminal, tmux's passthrough limitation wins and we want level 2.
|
||||
*/
|
||||
function boostChalkLevelForXtermJs(): boolean {
|
||||
if (process.env.TERM_PROGRAM === 'vscode' && chalk.level === 2) {
|
||||
chalk.level = 3
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function shouldUseRichEightBitDowngradeForLegacyAppleTerminal(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
level = chalk.level
|
||||
): boolean {
|
||||
const termProgram = (env.TERM_PROGRAM ?? '').trim()
|
||||
const truecolorOverride = /^(?:1|true|yes|on)$/i.test((env.HERMES_TUI_TRUECOLOR ?? '').trim())
|
||||
const advertisesTruecolor = /^(?:truecolor|24bit)$/i.test((env.COLORTERM ?? '').trim())
|
||||
|
||||
return (
|
||||
termProgram === 'Apple_Terminal' &&
|
||||
!truecolorOverride &&
|
||||
!advertisesTruecolor &&
|
||||
!('FORCE_COLOR' in env) &&
|
||||
level === 2
|
||||
)
|
||||
}
|
||||
|
||||
export function richEightBitColorNumber(red: number, green: number, blue: number): number {
|
||||
const rn = red / 255
|
||||
const gn = green / 255
|
||||
const bn = blue / 255
|
||||
const max = Math.max(rn, gn, bn)
|
||||
const min = Math.min(rn, gn, bn)
|
||||
const lightness = (max + min) / 2
|
||||
const saturation = max === min ? 0 : lightness > 0.5 ? (max - min) / (2 - max - min) : (max - min) / (max + min)
|
||||
|
||||
if (saturation < 0.15) {
|
||||
const gray = Math.round(lightness * 25)
|
||||
|
||||
return gray === 0 ? 16 : gray === 25 ? 231 : 231 + gray
|
||||
}
|
||||
|
||||
const sixRed = red < 95 ? red / 95 : 1 + (red - 95) / 40
|
||||
const sixGreen = green < 95 ? green / 95 : 1 + (green - 95) / 40
|
||||
const sixBlue = blue < 95 ? blue / 95 : 1 + (blue - 95) / 40
|
||||
|
||||
return 16 + 36 * Math.round(sixRed) + 6 * Math.round(sixGreen) + Math.round(sixBlue)
|
||||
}
|
||||
|
||||
/**
|
||||
* tmux parses truecolor SGR (\e[48;2;r;g;bm) into its cell buffer correctly,
|
||||
* but its client-side emitter only re-emits truecolor to the outer terminal if
|
||||
* the outer terminal advertises Tc/RGB capability (via terminal-overrides).
|
||||
* Default tmux config doesn't set this, so tmux emits the cell to iTerm2/etc
|
||||
* WITHOUT the bg sequence — outer terminal's buffer has bg=default → black on
|
||||
* dark profiles. Clamping to level 2 makes chalk emit 256-color (\e[48;5;Nm),
|
||||
* which tmux passes through cleanly. grey93 (255) is visually identical to
|
||||
* rgb(240,240,240).
|
||||
*
|
||||
* Users who HAVE set `terminal-overrides ,*:Tc` get a technically-unnecessary
|
||||
* downgrade, but the visual difference is imperceptible. Querying
|
||||
* `tmux show -gv terminal-overrides` to detect this would add a subprocess on
|
||||
* startup — not worth it.
|
||||
*
|
||||
* $TMUX is a pty-lifecycle env var set by tmux itself; it never comes from
|
||||
* globalSettings.env, so reading it here is correct. chalk is a singleton, so
|
||||
* this clamps ALL truecolor output (fg+bg+hex) across the entire app.
|
||||
*/
|
||||
function clampChalkLevelForTmux(): boolean {
|
||||
if (process.env.TMUX && chalk.level > 2) {
|
||||
chalk.level = 2
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Computed once at module load — terminal/tmux environment doesn't change mid-session.
|
||||
// Order matters: boost first; then tmux can still clamp RGB to 256.
|
||||
// Exported for debugging — tree-shaken if unused.
|
||||
export const CHALK_BOOSTED_FOR_XTERMJS = boostChalkLevelForXtermJs()
|
||||
export const CHALK_CLAMPED_FOR_TMUX = clampChalkLevelForTmux()
|
||||
export const CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE = shouldUseRichEightBitDowngradeForLegacyAppleTerminal()
|
||||
|
||||
export type ColorType = 'foreground' | 'background'
|
||||
|
||||
const RGB_REGEX = /^rgb\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/
|
||||
const ANSI_REGEX = /^ansi256\(\s?(\d+)\s?\)$/
|
||||
const HEX_REGEX = /^#[0-9a-fA-F]{6}$/
|
||||
|
||||
export const colorize = (str: string, color: string | undefined, type: ColorType): string => {
|
||||
if (!color) {
|
||||
return str
|
||||
}
|
||||
|
||||
if (color.startsWith('ansi:')) {
|
||||
const value = color.substring('ansi:'.length)
|
||||
|
||||
switch (value) {
|
||||
case 'black':
|
||||
return type === 'foreground' ? chalk.black(str) : chalk.bgBlack(str)
|
||||
|
||||
case 'red':
|
||||
return type === 'foreground' ? chalk.red(str) : chalk.bgRed(str)
|
||||
|
||||
case 'green':
|
||||
return type === 'foreground' ? chalk.green(str) : chalk.bgGreen(str)
|
||||
|
||||
case 'yellow':
|
||||
return type === 'foreground' ? chalk.yellow(str) : chalk.bgYellow(str)
|
||||
|
||||
case 'blue':
|
||||
return type === 'foreground' ? chalk.blue(str) : chalk.bgBlue(str)
|
||||
|
||||
case 'magenta':
|
||||
return type === 'foreground' ? chalk.magenta(str) : chalk.bgMagenta(str)
|
||||
|
||||
case 'cyan':
|
||||
return type === 'foreground' ? chalk.cyan(str) : chalk.bgCyan(str)
|
||||
|
||||
case 'white':
|
||||
return type === 'foreground' ? chalk.white(str) : chalk.bgWhite(str)
|
||||
|
||||
case 'blackBright':
|
||||
return type === 'foreground' ? chalk.blackBright(str) : chalk.bgBlackBright(str)
|
||||
|
||||
case 'redBright':
|
||||
return type === 'foreground' ? chalk.redBright(str) : chalk.bgRedBright(str)
|
||||
|
||||
case 'greenBright':
|
||||
return type === 'foreground' ? chalk.greenBright(str) : chalk.bgGreenBright(str)
|
||||
|
||||
case 'yellowBright':
|
||||
return type === 'foreground' ? chalk.yellowBright(str) : chalk.bgYellowBright(str)
|
||||
|
||||
case 'blueBright':
|
||||
return type === 'foreground' ? chalk.blueBright(str) : chalk.bgBlueBright(str)
|
||||
|
||||
case 'magentaBright':
|
||||
return type === 'foreground' ? chalk.magentaBright(str) : chalk.bgMagentaBright(str)
|
||||
|
||||
case 'cyanBright':
|
||||
return type === 'foreground' ? chalk.cyanBright(str) : chalk.bgCyanBright(str)
|
||||
|
||||
case 'whiteBright':
|
||||
return type === 'foreground' ? chalk.whiteBright(str) : chalk.bgWhiteBright(str)
|
||||
}
|
||||
}
|
||||
|
||||
if (color.startsWith('#')) {
|
||||
if (HEX_REGEX.test(color) && CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE) {
|
||||
const value = Number.parseInt(color.slice(1), 16)
|
||||
const red = (value >> 16) & 0xff
|
||||
const green = (value >> 8) & 0xff
|
||||
const blue = value & 0xff
|
||||
const ansi = richEightBitColorNumber(red, green, blue)
|
||||
|
||||
return type === 'foreground' ? chalk.ansi256(ansi)(str) : chalk.bgAnsi256(ansi)(str)
|
||||
}
|
||||
|
||||
return type === 'foreground' ? chalk.hex(color)(str) : chalk.bgHex(color)(str)
|
||||
}
|
||||
|
||||
if (color.startsWith('ansi256')) {
|
||||
const matches = ANSI_REGEX.exec(color)
|
||||
|
||||
if (!matches) {
|
||||
return str
|
||||
}
|
||||
|
||||
const value = Number(matches[1])
|
||||
|
||||
return type === 'foreground' ? chalk.ansi256(value)(str) : chalk.bgAnsi256(value)(str)
|
||||
}
|
||||
|
||||
if (color.startsWith('rgb')) {
|
||||
const matches = RGB_REGEX.exec(color)
|
||||
|
||||
if (!matches) {
|
||||
return str
|
||||
}
|
||||
|
||||
const firstValue = Number(matches[1])
|
||||
const secondValue = Number(matches[2])
|
||||
const thirdValue = Number(matches[3])
|
||||
|
||||
if (CHALK_USES_RICH_EIGHT_BIT_DOWNGRADE) {
|
||||
const ansi = richEightBitColorNumber(firstValue, secondValue, thirdValue)
|
||||
|
||||
return type === 'foreground' ? chalk.ansi256(ansi)(str) : chalk.bgAnsi256(ansi)(str)
|
||||
}
|
||||
|
||||
return type === 'foreground'
|
||||
? chalk.rgb(firstValue, secondValue, thirdValue)(str)
|
||||
: chalk.bgRgb(firstValue, secondValue, thirdValue)(str)
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply TextStyles to a string using chalk.
|
||||
* This is the inverse of parsing ANSI codes - we generate them from structured styles.
|
||||
* Theme resolution happens at component layer, not here.
|
||||
*/
|
||||
export function applyTextStyles(text: string, styles: TextStyles): string {
|
||||
let result = text
|
||||
|
||||
// Apply styles in reverse order of desired nesting.
|
||||
// chalk wraps text so later calls become outer wrappers.
|
||||
// Desired order (outermost to innermost):
|
||||
// background > foreground > text modifiers
|
||||
// So we apply: text modifiers first, then foreground, then background last.
|
||||
|
||||
if (styles.inverse) {
|
||||
result = chalk.inverse(result)
|
||||
}
|
||||
|
||||
if (styles.strikethrough) {
|
||||
result = chalk.strikethrough(result)
|
||||
}
|
||||
|
||||
if (styles.underline) {
|
||||
result = chalk.underline(result)
|
||||
}
|
||||
|
||||
if (styles.italic) {
|
||||
result = chalk.italic(result)
|
||||
}
|
||||
|
||||
if (styles.bold) {
|
||||
result = chalk.bold(result)
|
||||
}
|
||||
|
||||
if (styles.dim) {
|
||||
result = chalk.dim(result)
|
||||
}
|
||||
|
||||
if (styles.color) {
|
||||
// Color is now always a raw color value (theme resolution happens at component layer)
|
||||
result = colorize(result, styles.color, 'foreground')
|
||||
}
|
||||
|
||||
if (styles.backgroundColor) {
|
||||
// backgroundColor is now always a raw color value
|
||||
result = colorize(result, styles.backgroundColor, 'background')
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a raw color value to text.
|
||||
* Theme resolution should happen at component layer, not here.
|
||||
*/
|
||||
export function applyColor(text: string, color: Color | undefined): string {
|
||||
if (!color) {
|
||||
return text
|
||||
}
|
||||
|
||||
return colorize(text, color, 'foreground')
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { type PropsWithChildren, useContext, useInsertionEffect } from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
|
||||
import instances from '../instances.js'
|
||||
import { CURSOR_HOME, ERASE_SCREEN, ERASE_SCROLLBACK } from '../termio/csi.js'
|
||||
import {
|
||||
DISABLE_MOUSE_TRACKING,
|
||||
enableMouseTrackingFor,
|
||||
ENTER_ALT_SCREEN,
|
||||
EXIT_ALT_SCREEN,
|
||||
type MouseTrackingMode
|
||||
} from '../termio/dec.js'
|
||||
import { TerminalWriteContext } from '../useTerminalNotification.js'
|
||||
|
||||
import Box from './Box.js'
|
||||
import { TerminalSizeContext } from './TerminalSizeContext.js'
|
||||
type Props = PropsWithChildren<{
|
||||
/**
|
||||
* Which SGR mouse-tracking preset to enable. Default `'all'` — wheel +
|
||||
* click + drag + hover (1000 + 1002 + 1003 + 1006). Set to `'wheel'`
|
||||
* (1000 + 1006) to silence the noisy hover events that tmux turns into
|
||||
* "No image in clipboard" spam over the prompt row, while keeping
|
||||
* scroll-wheel scrolling. `'off'` disables tracking entirely.
|
||||
*/
|
||||
mouseTracking?: MouseTrackingMode
|
||||
}>
|
||||
|
||||
/**
|
||||
* Run children in the terminal's alternate screen buffer, constrained to
|
||||
* the viewport height. While mounted:
|
||||
*
|
||||
* - Enters the alt screen (DEC 1049), clears it, homes the cursor
|
||||
* - Constrains its own height to the terminal row count, so overflow must
|
||||
* be handled via `overflow: scroll` / flexbox (no native scrollback)
|
||||
* - Optionally enables a subset of SGR mouse tracking (wheel-only,
|
||||
* wheel+drag, or wheel+drag+hover) — events surface as `ParsedKey`
|
||||
* (wheel) and update the Ink instance's selection state (click/drag).
|
||||
* See `MouseTrackingMode` for the available presets.
|
||||
*
|
||||
* On unmount, disables mouse tracking and exits the alt screen, restoring
|
||||
* the main screen's content. Safe for use in ctrl-o transcript overlays
|
||||
* and similar temporary fullscreen views — the main screen is preserved.
|
||||
*
|
||||
* Notifies the Ink instance via `setAltScreenActive()` so the renderer
|
||||
* keeps the cursor inside the viewport (preventing the cursor-restore LF
|
||||
* from scrolling content) and so signal-exit cleanup can exit the alt
|
||||
* screen if the component's own unmount doesn't run.
|
||||
*/
|
||||
export function AlternateScreen(t0: Props) {
|
||||
const $ = _c(7)
|
||||
|
||||
const { children, mouseTracking: t1 } = t0
|
||||
|
||||
const mouseTracking: MouseTrackingMode = t1 === undefined ? 'all' : t1
|
||||
const size = useContext(TerminalSizeContext)
|
||||
const writeRaw = useContext(TerminalWriteContext)
|
||||
let t2
|
||||
let t3
|
||||
|
||||
if ($[0] !== mouseTracking || $[1] !== writeRaw) {
|
||||
t2 = () => {
|
||||
const ink = instances.get(process.stdout)
|
||||
|
||||
if (!writeRaw) {
|
||||
return
|
||||
}
|
||||
|
||||
const enableMouse = enableMouseTrackingFor(mouseTracking)
|
||||
|
||||
// Always reset every mouse mode before enabling the requested preset
|
||||
// so the terminal lands in an exact state. If a previous instance
|
||||
// (crash, another app, lingering DECSET from a debugger) left DEC
|
||||
// 1003 hover events asserted, picking 'wheel' or 'buttons' without
|
||||
// an unconditional DISABLE would silently leave hover on and defeat
|
||||
// the point of the preset.
|
||||
writeRaw(ENTER_ALT_SCREEN + ERASE_SCROLLBACK + ERASE_SCREEN + CURSOR_HOME + DISABLE_MOUSE_TRACKING + enableMouse)
|
||||
ink?.setAltScreenActive(true, mouseTracking)
|
||||
// setAltScreenActive(true, mouseTracking) above stores the mode for
|
||||
// SIGCONT/resize/stdin-gap re-assertion. We don't also call
|
||||
// setAltScreenMouseTracking(mouseTracking) here: it would early-return
|
||||
// in the happy mode-change path (active flipped false→true with the
|
||||
// new mode), and on any path where setAltScreenActive saw active was
|
||||
// already true (so it didn't store mode), the writeRaw above has
|
||||
// already DISABLE'd + enabled the new mode. A second
|
||||
// setAltScreenMouseTracking would just duplicate the same DEC bytes.
|
||||
|
||||
return () => {
|
||||
ink?.setAltScreenActive(false)
|
||||
ink?.clearTextSelection()
|
||||
// DISABLE_MOUSE_TRACKING is safe to send even when we never enabled
|
||||
// tracking (it unconditionally resets all four modes). Sending it
|
||||
// on every teardown means a crash mid-mount can't leak DEC modes
|
||||
// back to the host shell.
|
||||
writeRaw(DISABLE_MOUSE_TRACKING + EXIT_ALT_SCREEN)
|
||||
}
|
||||
}
|
||||
|
||||
t3 = [writeRaw, mouseTracking]
|
||||
$[0] = mouseTracking
|
||||
$[1] = writeRaw
|
||||
$[2] = t2
|
||||
$[3] = t3
|
||||
} else {
|
||||
t2 = $[2]
|
||||
t3 = $[3]
|
||||
}
|
||||
|
||||
useInsertionEffect(t2, t3)
|
||||
const t4 = size?.rows ?? 24
|
||||
let t5
|
||||
|
||||
if ($[4] !== children || $[5] !== t4) {
|
||||
t5 = (
|
||||
<Box flexDirection="column" flexShrink={0} height={t4} width="100%">
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
$[4] = children
|
||||
$[5] = t4
|
||||
$[6] = t5
|
||||
} else {
|
||||
t5 = $[6]
|
||||
}
|
||||
|
||||
return t5
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createSelectionState } from '../selection.js'
|
||||
import { getTerminalFocusState, resetTerminalFocusState } from '../terminal-focus-state.js'
|
||||
import { FOCUS_IN, FOCUS_OUT } from '../termio/csi.js'
|
||||
|
||||
import App from './App.js'
|
||||
|
||||
function makeApp(onTerminalFocusChange = vi.fn()) {
|
||||
const stdin = {
|
||||
isTTY: true,
|
||||
readableLength: 0,
|
||||
read: vi.fn(),
|
||||
ref: vi.fn(),
|
||||
unref: vi.fn(),
|
||||
setEncoding: vi.fn(),
|
||||
setRawMode: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn()
|
||||
} as unknown as NodeJS.ReadStream
|
||||
|
||||
const stdout = {
|
||||
isTTY: true,
|
||||
columns: 80,
|
||||
rows: 24,
|
||||
write: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn()
|
||||
} as unknown as NodeJS.WriteStream
|
||||
|
||||
return new App({
|
||||
children: null,
|
||||
dispatchKeyboardEvent: vi.fn(),
|
||||
exitOnCtrlC: false,
|
||||
getHyperlinkAt: vi.fn(),
|
||||
onClickAt: vi.fn(() => false),
|
||||
onCursorDeclaration: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
onHoverAt: vi.fn(),
|
||||
onMouseDownAt: vi.fn(() => undefined),
|
||||
onMouseDragAt: vi.fn(),
|
||||
onMouseUpAt: vi.fn(),
|
||||
onMultiClick: vi.fn(),
|
||||
onOpenHyperlink: vi.fn(),
|
||||
onSelectionChange: vi.fn(),
|
||||
onSelectionDrag: vi.fn(),
|
||||
onTerminalFocusChange,
|
||||
selection: createSelectionState(),
|
||||
stderr: stdout,
|
||||
stdin,
|
||||
stdout,
|
||||
terminalColumns: 80,
|
||||
terminalRows: 24
|
||||
})
|
||||
}
|
||||
|
||||
describe('App terminal focus events', () => {
|
||||
beforeEach(() => {
|
||||
resetTerminalFocusState()
|
||||
})
|
||||
|
||||
it('notifies the renderer on DECSET 1004 focus transitions', () => {
|
||||
const onTerminalFocusChange = vi.fn()
|
||||
const app = makeApp(onTerminalFocusChange)
|
||||
|
||||
app.processInput(FOCUS_OUT)
|
||||
expect(getTerminalFocusState()).toBe('blurred')
|
||||
expect(onTerminalFocusChange).toHaveBeenLastCalledWith(false)
|
||||
|
||||
app.processInput(FOCUS_IN)
|
||||
expect(getTerminalFocusState()).toBe('focused')
|
||||
expect(onTerminalFocusChange).toHaveBeenLastCalledWith(true)
|
||||
expect(onTerminalFocusChange).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,20 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
export type Props = {
|
||||
/**
|
||||
* Exit (unmount) the whole Ink app.
|
||||
*/
|
||||
readonly exit: (error?: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* `AppContext` is a React context, which exposes a method to manually exit the app (unmount).
|
||||
*/
|
||||
|
||||
const AppContext = createContext<Props>({
|
||||
exit() {}
|
||||
})
|
||||
|
||||
AppContext.displayName = 'InternalAppContext'
|
||||
|
||||
export default AppContext
|
||||
@@ -0,0 +1,294 @@
|
||||
import '../global.d.ts'
|
||||
|
||||
import React, { type ReactNode, type Ref } from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
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 { MouseEvent } from '../events/mouse-event.js'
|
||||
import type { Styles } from '../styles.js'
|
||||
import * as warn from '../warn.js'
|
||||
export type Props = Except<Styles, 'textWrap'> & {
|
||||
children?: ReactNode
|
||||
ref?: Ref<DOMElement>
|
||||
/**
|
||||
* Tab order index. Nodes with `tabIndex >= 0` participate in
|
||||
* Tab/Shift+Tab cycling; `-1` means programmatically focusable only.
|
||||
*/
|
||||
tabIndex?: number
|
||||
/**
|
||||
* Focus this element when it mounts. Like the HTML `autofocus`
|
||||
* attribute — the FocusManager calls `focus(node)` during the
|
||||
* reconciler's `commitMount` phase.
|
||||
*/
|
||||
autoFocus?: boolean
|
||||
/**
|
||||
* Fired on left-button click (press + release without drag). Only works
|
||||
* inside `<AlternateScreen>` where mouse tracking is enabled — no-op
|
||||
* otherwise. The event bubbles from the deepest hit Box up through
|
||||
* ancestors; call `event.stopImmediatePropagation()` to stop bubbling.
|
||||
*/
|
||||
onClick?: (event: ClickEvent) => void
|
||||
onMouseDown?: (event: MouseEvent) => void
|
||||
onMouseUp?: (event: MouseEvent) => void
|
||||
onMouseDrag?: (event: MouseEvent) => void
|
||||
onFocus?: (event: FocusEvent) => void
|
||||
onFocusCapture?: (event: FocusEvent) => void
|
||||
onBlur?: (event: FocusEvent) => void
|
||||
onBlurCapture?: (event: FocusEvent) => void
|
||||
onKeyDown?: (event: KeyboardEvent) => void
|
||||
onKeyDownCapture?: (event: KeyboardEvent) => void
|
||||
/**
|
||||
* Fired when the mouse moves into this Box's rendered rect. Like DOM
|
||||
* `mouseenter`, does NOT bubble — moving between children does not
|
||||
* re-fire on the parent. Only works inside `<AlternateScreen>` where
|
||||
* mode-1003 mouse tracking is enabled.
|
||||
*/
|
||||
onMouseEnter?: () => void
|
||||
/** Fired when the mouse moves out of this Box's rendered rect. */
|
||||
onMouseLeave?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* `<Box>` is an essential Ink component to build your layout. It's like `<div style="display: flex">` in the browser.
|
||||
*/
|
||||
function Box(t0: Props) {
|
||||
const $ = _c(48)
|
||||
let autoFocus
|
||||
let children
|
||||
let flexDirection
|
||||
let flexGrow
|
||||
let flexShrink
|
||||
let flexWrap
|
||||
let onBlur
|
||||
let onBlurCapture
|
||||
let onClick
|
||||
let onFocus
|
||||
let onFocusCapture
|
||||
let onKeyDown
|
||||
let onKeyDownCapture
|
||||
let onMouseDown
|
||||
let onMouseDrag
|
||||
let onMouseEnter
|
||||
let onMouseLeave
|
||||
let onMouseUp
|
||||
let ref
|
||||
let style
|
||||
let tabIndex
|
||||
|
||||
if ($[0] !== t0) {
|
||||
const {
|
||||
children: t1,
|
||||
flexWrap: t2,
|
||||
flexDirection: t3,
|
||||
flexGrow: t4,
|
||||
flexShrink: t5,
|
||||
ref: t6,
|
||||
tabIndex: t7,
|
||||
autoFocus: t8,
|
||||
onClick: t9,
|
||||
onFocus: t10,
|
||||
onFocusCapture: t11,
|
||||
onBlur: t12,
|
||||
onBlurCapture: t13,
|
||||
onMouseDown: t14,
|
||||
onMouseUp: t15,
|
||||
onMouseDrag: t16,
|
||||
onMouseEnter: t17,
|
||||
onMouseLeave: t18,
|
||||
onKeyDown: t19,
|
||||
onKeyDownCapture: t20,
|
||||
...t21
|
||||
} = t0
|
||||
|
||||
children = t1
|
||||
ref = t6
|
||||
tabIndex = t7
|
||||
autoFocus = t8
|
||||
onClick = t9
|
||||
onFocus = t10
|
||||
onFocusCapture = t11
|
||||
onBlur = t12
|
||||
onBlurCapture = t13
|
||||
onMouseDown = t14
|
||||
onMouseUp = t15
|
||||
onMouseDrag = t16
|
||||
onMouseEnter = t17
|
||||
onMouseLeave = t18
|
||||
onKeyDown = t19
|
||||
onKeyDownCapture = t20
|
||||
style = t21
|
||||
flexWrap = t2 === undefined ? 'nowrap' : t2
|
||||
flexDirection = t3 === undefined ? 'row' : t3
|
||||
flexGrow = t4 === undefined ? 0 : t4
|
||||
flexShrink = t5 === undefined ? 1 : t5
|
||||
warn.ifNotInteger(style.margin, 'margin')
|
||||
warn.ifNotInteger(style.marginX, 'marginX')
|
||||
warn.ifNotInteger(style.marginY, 'marginY')
|
||||
warn.ifNotInteger(style.marginTop, 'marginTop')
|
||||
warn.ifNotInteger(style.marginBottom, 'marginBottom')
|
||||
warn.ifNotInteger(style.marginLeft, 'marginLeft')
|
||||
warn.ifNotInteger(style.marginRight, 'marginRight')
|
||||
warn.ifNotInteger(style.padding, 'padding')
|
||||
warn.ifNotInteger(style.paddingX, 'paddingX')
|
||||
warn.ifNotInteger(style.paddingY, 'paddingY')
|
||||
warn.ifNotInteger(style.paddingTop, 'paddingTop')
|
||||
warn.ifNotInteger(style.paddingBottom, 'paddingBottom')
|
||||
warn.ifNotInteger(style.paddingLeft, 'paddingLeft')
|
||||
warn.ifNotInteger(style.paddingRight, 'paddingRight')
|
||||
warn.ifNotInteger(style.gap, 'gap')
|
||||
warn.ifNotInteger(style.columnGap, 'columnGap')
|
||||
warn.ifNotInteger(style.rowGap, 'rowGap')
|
||||
$[0] = t0
|
||||
$[1] = autoFocus
|
||||
$[2] = children
|
||||
$[3] = flexDirection
|
||||
$[4] = flexGrow
|
||||
$[5] = flexShrink
|
||||
$[6] = flexWrap
|
||||
$[7] = onBlur
|
||||
$[8] = onBlurCapture
|
||||
$[9] = onClick
|
||||
$[10] = onFocus
|
||||
$[11] = onFocusCapture
|
||||
$[12] = onKeyDown
|
||||
$[13] = onKeyDownCapture
|
||||
$[14] = onMouseDown
|
||||
$[15] = onMouseUp
|
||||
$[16] = onMouseDrag
|
||||
$[17] = onMouseEnter
|
||||
$[18] = onMouseLeave
|
||||
$[19] = ref
|
||||
$[20] = style
|
||||
$[21] = tabIndex
|
||||
} else {
|
||||
autoFocus = $[1]
|
||||
children = $[2]
|
||||
flexDirection = $[3]
|
||||
flexGrow = $[4]
|
||||
flexShrink = $[5]
|
||||
flexWrap = $[6]
|
||||
onBlur = $[7]
|
||||
onBlurCapture = $[8]
|
||||
onClick = $[9]
|
||||
onFocus = $[10]
|
||||
onFocusCapture = $[11]
|
||||
onKeyDown = $[12]
|
||||
onKeyDownCapture = $[13]
|
||||
onMouseDown = $[14]
|
||||
onMouseUp = $[15]
|
||||
onMouseDrag = $[16]
|
||||
onMouseEnter = $[17]
|
||||
onMouseLeave = $[18]
|
||||
ref = $[19]
|
||||
style = $[20]
|
||||
tabIndex = $[21]
|
||||
}
|
||||
|
||||
const t1 = style.overflowX ?? style.overflow ?? 'visible'
|
||||
const t2 = style.overflowY ?? style.overflow ?? 'visible'
|
||||
let t3
|
||||
|
||||
if (
|
||||
$[22] !== flexDirection ||
|
||||
$[23] !== flexGrow ||
|
||||
$[24] !== flexShrink ||
|
||||
$[25] !== flexWrap ||
|
||||
$[26] !== style ||
|
||||
$[27] !== t1 ||
|
||||
$[28] !== t2
|
||||
) {
|
||||
t3 = {
|
||||
flexWrap,
|
||||
flexDirection,
|
||||
flexGrow,
|
||||
flexShrink,
|
||||
...style,
|
||||
overflowX: t1,
|
||||
overflowY: t2
|
||||
}
|
||||
$[22] = flexDirection
|
||||
$[23] = flexGrow
|
||||
$[24] = flexShrink
|
||||
$[25] = flexWrap
|
||||
$[26] = style
|
||||
$[27] = t1
|
||||
$[28] = t2
|
||||
$[29] = t3
|
||||
} else {
|
||||
t3 = $[29]
|
||||
}
|
||||
|
||||
let t4
|
||||
|
||||
if (
|
||||
$[30] !== autoFocus ||
|
||||
$[31] !== children ||
|
||||
$[32] !== onBlur ||
|
||||
$[33] !== onBlurCapture ||
|
||||
$[34] !== onClick ||
|
||||
$[35] !== onFocus ||
|
||||
$[36] !== onFocusCapture ||
|
||||
$[37] !== onKeyDown ||
|
||||
$[38] !== onKeyDownCapture ||
|
||||
$[39] !== onMouseDown ||
|
||||
$[40] !== onMouseUp ||
|
||||
$[41] !== onMouseDrag ||
|
||||
$[42] !== onMouseEnter ||
|
||||
$[43] !== onMouseLeave ||
|
||||
$[44] !== ref ||
|
||||
$[45] !== t3 ||
|
||||
$[46] !== tabIndex
|
||||
) {
|
||||
t4 = (
|
||||
<ink-box
|
||||
autoFocus={autoFocus}
|
||||
onBlur={onBlur}
|
||||
onBlurCapture={onBlurCapture}
|
||||
onClick={onClick}
|
||||
onFocus={onFocus}
|
||||
onFocusCapture={onFocusCapture}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseDrag={onMouseDrag}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseUp={onMouseUp}
|
||||
ref={ref}
|
||||
style={t3}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
{children}
|
||||
</ink-box>
|
||||
)
|
||||
$[30] = autoFocus
|
||||
$[31] = children
|
||||
$[32] = onBlur
|
||||
$[33] = onBlurCapture
|
||||
$[34] = onClick
|
||||
$[35] = onFocus
|
||||
$[36] = onFocusCapture
|
||||
$[37] = onKeyDown
|
||||
$[38] = onKeyDownCapture
|
||||
$[39] = onMouseDown
|
||||
$[40] = onMouseUp
|
||||
$[41] = onMouseDrag
|
||||
$[42] = onMouseEnter
|
||||
$[43] = onMouseLeave
|
||||
$[44] = ref
|
||||
$[45] = t3
|
||||
$[46] = tabIndex
|
||||
$[47] = t4
|
||||
} else {
|
||||
t4 = $[47]
|
||||
}
|
||||
|
||||
return t4
|
||||
}
|
||||
|
||||
export default Box
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzV2l0aENoaWxkcmVuIiwiUmVmIiwiRXhjZXB0IiwiRE9NRWxlbWVudCIsIkNsaWNrRXZlbnQiLCJGb2N1c0V2ZW50IiwiS2V5Ym9hcmRFdmVudCIsIlN0eWxlcyIsIndhcm4iLCJQcm9wcyIsInJlZiIsInRhYkluZGV4IiwiYXV0b0ZvY3VzIiwib25DbGljayIsImV2ZW50Iiwib25Gb2N1cyIsIm9uRm9jdXNDYXB0dXJlIiwib25CbHVyIiwib25CbHVyQ2FwdHVyZSIsIm9uS2V5RG93biIsIm9uS2V5RG93bkNhcHR1cmUiLCJvbk1vdXNlRW50ZXIiLCJvbk1vdXNlTGVhdmUiLCJCb3giLCJ0MCIsIiQiLCJfYyIsImNoaWxkcmVuIiwiZmxleERpcmVjdGlvbiIsImZsZXhHcm93IiwiZmxleFNocmluayIsImZsZXhXcmFwIiwic3R5bGUiLCJ0MSIsInQyIiwidDMiLCJ0NCIsInQ1IiwidDYiLCJ0NyIsInQ4IiwidDkiLCJ0MTAiLCJ0MTEiLCJ0MTIiLCJ0MTMiLCJ0MTQiLCJ0MTUiLCJ0MTYiLCJ0MTciLCJ0MTgiLCJ1bmRlZmluZWQiLCJpZk5vdEludGVnZXIiLCJtYXJnaW4iLCJtYXJnaW5YIiwibWFyZ2luWSIsIm1hcmdpblRvcCIsIm1hcmdpbkJvdHRvbSIsIm1hcmdpbkxlZnQiLCJtYXJnaW5SaWdodCIsInBhZGRpbmciLCJwYWRkaW5nWCIsInBhZGRpbmdZIiwicGFkZGluZ1RvcCIsInBhZGRpbmdCb3R0b20iLCJwYWRkaW5nTGVmdCIsInBhZGRpbmdSaWdodCIsImdhcCIsImNvbHVtbkdhcCIsInJvd0dhcCIsIm92ZXJmbG93WCIsIm92ZXJmbG93Iiwib3ZlcmZsb3dZIl0sInNvdXJjZXMiOlsiQm94LnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgJy4uL2dsb2JhbC5kLnRzJ1xuaW1wb3J0IFJlYWN0LCB7IHR5cGUgUHJvcHNXaXRoQ2hpbGRyZW4sIHR5cGUgUmVmIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgdHlwZSB7IEV4Y2VwdCB9IGZyb20gJ3R5cGUtZmVzdCdcbmltcG9ydCB0eXBlIHsgRE9NRWxlbWVudCB9IGZyb20gJy4uL2RvbS5qcydcbmltcG9ydCB0eXBlIHsgQ2xpY2tFdmVudCB9IGZyb20gJy4uL2V2ZW50cy9jbGljay1ldmVudC5qcydcbmltcG9ydCB0eXBlIHsgRm9jdXNFdmVudCB9IGZyb20gJy4uL2V2ZW50cy9mb2N1cy1ldmVudC5qcydcbmltcG9ydCB0eXBlIHsgS2V5Ym9hcmRFdmVudCB9IGZyb20gJy4uL2V2ZW50cy9rZXlib2FyZC1ldmVudC5qcydcbmltcG9ydCB0eXBlIHsgU3R5bGVzIH0gZnJvbSAnLi4vc3R5bGVzLmpzJ1xuaW1wb3J0ICogYXMgd2FybiBmcm9tICcuLi93YXJuLmpzJ1xuXG5leHBvcnQgdHlwZSBQcm9wcyA9IEV4Y2VwdDxTdHlsZXMsICd0ZXh0V3JhcCc+ICYge1xuICByZWY/OiBSZWY8RE9NRWxlbWVudD5cbiAgLyoqXG4gICAqIFRhYiBvcmRlciBpbmRleC4gTm9kZXMgd2l0aCBgdGFiSW5kZXggPj0gMGAgcGFydGljaXBhdGUgaW5cbiAgICogVGFiL1NoaWZ0K1RhYiBjeWNsaW5nOyBgLTFgIG1lYW5zIHByb2dyYW1tYXRpY2FsbHkgZm9jdXNhYmxlIG9ubHkuXG4gICAqL1xuICB0YWJJbmRleD86IG51bWJlclxuICAvKipcbiAgICogRm9jdXMgdGhpcyBlbGVtZW50IHdoZW4gaXQgbW91bnRzLiBMaWtlIHRoZSBIVE1MIGBhdXRvZm9jdXNgXG4gICAqIGF0dHJpYnV0ZSDigJQgdGhlIEZvY3VzTWFuYWdlciBjYWxscyBgZm9jdXMobm9kZSlgIGR1cmluZyB0aGVcbiAgICogcmVjb25jaWxlcidzIGBjb21taXRNb3VudGAgcGhhc2UuXG4gICAqL1xuICBhdXRvRm9jdXM/OiBib29sZWFuXG4gIC8qKlxuICAgKiBGaXJlZCBvbiBsZWZ0LWJ1dHRvbiBjbGljayAocHJlc3MgKyByZWxlYXNlIHdpdGhvdXQgZHJhZykuIE9ubHkgd29ya3NcbiAgICogaW5zaWRlIGA8QWx0ZXJuYXRlU2NyZWVuPmAgd2hlcmUgbW91c2UgdHJhY2tpbmcgaXMgZW5hYmxlZCDigJQgbm8tb3BcbiAgICogb3RoZXJ3aXNlLiBUaGUgZXZlbnQgYnViYmxlcyBmcm9tIHRoZSBkZWVwZXN0IGhpdCBCb3ggdXAgdGhyb3VnaFxuICAgKiBhbmNlc3RvcnM7IGNhbGwgYGV2ZW50LnN0b3BJbW1lZGlhdGVQcm9wYWdhdGlvbigpYCB0byBzdG9wIGJ1YmJsaW5nLlxuICAgKi9cbiAgb25DbGljaz86IChldmVudDogQ2xpY2tFdmVudCkgPT4gdm9pZFxuICBvbkZvY3VzPzogKGV2ZW50OiBGb2N1c0V2ZW50KSA9PiB2b2lkXG4gIG9uRm9jdXNDYXB0dXJlPzogKGV2ZW50OiBGb2N1c0V2ZW50KSA9PiB2b2lkXG4gIG9uQmx1cj86IChldmVudDogRm9jdXNFdmVudCkgPT4gdm9pZFxuICBvbkJsdXJDYXB0dXJlPzogKGV2ZW50OiBGb2N1c0V2ZW50KSA9PiB2b2lkXG4gIG9uS2V5RG93bj86IChldmVudDogS2V5Ym9hcmRFdmVudCkgPT4gdm9pZFxuICBvbktleURvd25DYXB0dXJlPzogKGV2ZW50OiBLZXlib2FyZEV2ZW50KSA9PiB2b2lkXG4gIC8qKlxuICAgKiBGaXJlZCB3aGVuIHRoZSBtb3VzZSBtb3ZlcyBpbnRvIHRoaXMgQm94J3MgcmVuZGVyZWQgcmVjdC4gTGlrZSBET01cbiAgICogYG1vdXNlZW50ZXJgLCBkb2VzIE5PVCBidWJibGUg4oCUIG1vdmluZyBiZXR3ZWVuIGNoaWxkcmVuIGRvZXMgbm90XG4gICAqIHJlLWZpcmUgb24gdGhlIHBhcmVudC4gT25seSB3b3JrcyBpbnNpZGUgYDxBbHRlcm5hdGVTY3JlZW4+YCB3aGVyZVxuICAgKiBtb2RlLTEwMDMgbW91c2UgdHJhY2tpbmcgaXMgZW5hYmxlZC5cbiAgICovXG4gIG9uTW91c2VFbnRlcj86ICgpID0+IHZvaWRcbiAgLyoqIEZpcmVkIHdoZW4gdGhlIG1vdXNlIG1vdmVzIG91dCBvZiB0aGlzIEJveCdzIHJlbmRlcmVkIHJlY3QuICovXG4gIG9uTW91c2VMZWF2ZT86ICgpID0+IHZvaWRcbn1cblxuLyoqXG4gKiBgPEJveD5gIGlzIGFuIGVzc2VudGlhbCBJbmsgY29tcG9uZW50IHRvIGJ1aWxkIHlvdXIgbGF5b3V0LiBJdCdzIGxpa2UgYDxkaXYgc3R5bGU9XCJkaXNwbGF5OiBmbGV4XCI+YCBpbiB0aGUgYnJvd3Nlci5cbiAqL1xuZnVuY3Rpb24gQm94KHtcbiAgY2hpbGRyZW4sXG4gIGZsZXhXcmFwID0gJ25vd3JhcCcsXG4gIGZsZXhEaXJlY3Rpb24gPSAncm93JyxcbiAgZmxleEdyb3cgPSAwLFxuICBmbGV4U2hyaW5rID0gMSxcbiAgcmVmLFxuICB0YWJJbmRleCxcbiAgYXV0b0ZvY3VzLFxuICBvbkNsaWNrLFxuICBvbkZvY3VzLFxuICBvbkZvY3VzQ2FwdHVyZSxcbiAgb25CbHVyLFxuICBvbkJsdXJDYXB0dXJlLFxuICBvbk1vdXNlRW50ZXIsXG4gIG9uTW91c2VMZWF2ZSxcbiAgb25LZXlEb3duLFxuICBvbktleURvd25DYXB0dXJlLFxuICAuLi5zdHlsZVxufTogUHJvcHNXaXRoQ2hpbGRyZW48UHJvcHM+KTogUmVhY3QuUmVhY3ROb2RlIHtcbiAgLy8gV2FybiBpZiBzcGFjaW5nIHZhbHVlcyBhcmUgbm90IGludGVnZXJzIHRvIHByZXZlbnQgZnJhY3Rpb25hbCBsYXlvdXQgZGltZW5zaW9uc1xuICB3YXJuLmlmTm90SW50ZWdlcihzdHlsZS5tYXJnaW4sICdtYXJnaW4nKVxuICB3YXJuLmlmTm90SW50ZWdlcihzdHlsZS5tYXJnaW5YLCAnbWFyZ2luWCcpXG4gIHdhcm4uaWZOb3RJbnRlZ2VyKHN0eWxlLm1hcmdpblksICdtYXJnaW5ZJylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUubWFyZ2luVG9wLCAnbWFyZ2luVG9wJylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUubWFyZ2luQm90dG9tLCAnbWFyZ2luQm90dG9tJylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUubWFyZ2luTGVmdCwgJ21hcmdpbkxlZnQnKVxuICB3YXJuLmlmTm90SW50ZWdlcihzdHlsZS5tYXJnaW5SaWdodCwgJ21hcmdpblJpZ2h0JylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUucGFkZGluZywgJ3B
Line truncated
|
||||
@@ -0,0 +1,236 @@
|
||||
import React, { type Ref, useEffect, useRef, useState } from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
import type { Except } from 'type-fest'
|
||||
|
||||
import type { DOMElement } from '../dom.js'
|
||||
import type { Styles } from '../styles.js'
|
||||
|
||||
import Box from './Box.js'
|
||||
type ButtonState = {
|
||||
focused: boolean
|
||||
hovered: boolean
|
||||
active: boolean
|
||||
}
|
||||
export type Props = Except<Styles, 'textWrap'> & {
|
||||
ref?: Ref<DOMElement>
|
||||
/**
|
||||
* Called when the button is activated via Enter, Space, or click.
|
||||
*/
|
||||
onAction: () => void
|
||||
/**
|
||||
* Tab order index. Defaults to 0 (in tab order).
|
||||
* Set to -1 for programmatically focusable only.
|
||||
*/
|
||||
tabIndex?: number
|
||||
/**
|
||||
* Focus this button when it mounts.
|
||||
*/
|
||||
autoFocus?: boolean
|
||||
/**
|
||||
* Render prop receiving the interactive state. Use this to
|
||||
* style children based on focus/hover/active — Button itself
|
||||
* is intentionally unstyled.
|
||||
*
|
||||
* If not provided, children render as-is (no state-dependent styling).
|
||||
*/
|
||||
children: ((state: ButtonState) => React.ReactNode) | React.ReactNode
|
||||
}
|
||||
|
||||
function Button(t0) {
|
||||
const $ = _c(30)
|
||||
let autoFocus
|
||||
let children
|
||||
let onAction
|
||||
let ref
|
||||
let style
|
||||
let t1
|
||||
|
||||
if ($[0] !== t0) {
|
||||
;({ onAction, tabIndex: t1, autoFocus, children, ref, ...style } = t0)
|
||||
$[0] = t0
|
||||
$[1] = autoFocus
|
||||
$[2] = children
|
||||
$[3] = onAction
|
||||
$[4] = ref
|
||||
$[5] = style
|
||||
$[6] = t1
|
||||
} else {
|
||||
autoFocus = $[1]
|
||||
children = $[2]
|
||||
onAction = $[3]
|
||||
ref = $[4]
|
||||
style = $[5]
|
||||
t1 = $[6]
|
||||
}
|
||||
|
||||
const tabIndex = t1 === undefined ? 0 : t1
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
const activeTimer = useRef(null)
|
||||
let t2
|
||||
let t3
|
||||
|
||||
if ($[7] === Symbol.for('react.memo_cache_sentinel')) {
|
||||
t2 = () => () => {
|
||||
if (activeTimer.current) {
|
||||
clearTimeout(activeTimer.current)
|
||||
}
|
||||
}
|
||||
|
||||
t3 = []
|
||||
$[7] = t2
|
||||
$[8] = t3
|
||||
} else {
|
||||
t2 = $[7]
|
||||
t3 = $[8]
|
||||
}
|
||||
|
||||
useEffect(t2, t3)
|
||||
let t4
|
||||
|
||||
if ($[9] !== onAction) {
|
||||
t4 = e => {
|
||||
if (e.key === 'return' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setIsActive(true)
|
||||
onAction()
|
||||
|
||||
if (activeTimer.current) {
|
||||
clearTimeout(activeTimer.current)
|
||||
}
|
||||
|
||||
activeTimer.current = setTimeout(_temp, 100, setIsActive)
|
||||
}
|
||||
}
|
||||
|
||||
$[9] = onAction
|
||||
$[10] = t4
|
||||
} else {
|
||||
t4 = $[10]
|
||||
}
|
||||
|
||||
const handleKeyDown = t4
|
||||
let t5
|
||||
|
||||
if ($[11] !== onAction) {
|
||||
t5 = _e => {
|
||||
onAction()
|
||||
}
|
||||
|
||||
$[11] = onAction
|
||||
$[12] = t5
|
||||
} else {
|
||||
t5 = $[12]
|
||||
}
|
||||
|
||||
const handleClick = t5
|
||||
let t6
|
||||
|
||||
if ($[13] === Symbol.for('react.memo_cache_sentinel')) {
|
||||
t6 = _e_0 => setIsFocused(true)
|
||||
$[13] = t6
|
||||
} else {
|
||||
t6 = $[13]
|
||||
}
|
||||
|
||||
const handleFocus = t6
|
||||
let t7
|
||||
|
||||
if ($[14] === Symbol.for('react.memo_cache_sentinel')) {
|
||||
t7 = _e_1 => setIsFocused(false)
|
||||
$[14] = t7
|
||||
} else {
|
||||
t7 = $[14]
|
||||
}
|
||||
|
||||
const handleBlur = t7
|
||||
let t8
|
||||
|
||||
if ($[15] === Symbol.for('react.memo_cache_sentinel')) {
|
||||
t8 = () => setIsHovered(true)
|
||||
$[15] = t8
|
||||
} else {
|
||||
t8 = $[15]
|
||||
}
|
||||
|
||||
const handleMouseEnter = t8
|
||||
let t9
|
||||
|
||||
if ($[16] === Symbol.for('react.memo_cache_sentinel')) {
|
||||
t9 = () => setIsHovered(false)
|
||||
$[16] = t9
|
||||
} else {
|
||||
t9 = $[16]
|
||||
}
|
||||
|
||||
const handleMouseLeave = t9
|
||||
let t10
|
||||
|
||||
if ($[17] !== children || $[18] !== isActive || $[19] !== isFocused || $[20] !== isHovered) {
|
||||
const state = {
|
||||
focused: isFocused,
|
||||
hovered: isHovered,
|
||||
active: isActive
|
||||
}
|
||||
|
||||
t10 = typeof children === 'function' ? children(state) : children
|
||||
$[17] = children
|
||||
$[18] = isActive
|
||||
$[19] = isFocused
|
||||
$[20] = isHovered
|
||||
$[21] = t10
|
||||
} else {
|
||||
t10 = $[21]
|
||||
}
|
||||
|
||||
const content = t10
|
||||
let t11
|
||||
|
||||
if (
|
||||
$[22] !== autoFocus ||
|
||||
$[23] !== content ||
|
||||
$[24] !== handleClick ||
|
||||
$[25] !== handleKeyDown ||
|
||||
$[26] !== ref ||
|
||||
$[27] !== style ||
|
||||
$[28] !== tabIndex
|
||||
) {
|
||||
t11 = (
|
||||
<Box
|
||||
autoFocus={autoFocus}
|
||||
onBlur={handleBlur}
|
||||
onClick={handleClick}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
ref={ref}
|
||||
tabIndex={tabIndex}
|
||||
{...style}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
)
|
||||
$[22] = autoFocus
|
||||
$[23] = content
|
||||
$[24] = handleClick
|
||||
$[25] = handleKeyDown
|
||||
$[26] = ref
|
||||
$[27] = style
|
||||
$[28] = tabIndex
|
||||
$[29] = t11
|
||||
} else {
|
||||
t11 = $[29]
|
||||
}
|
||||
|
||||
return t11
|
||||
}
|
||||
|
||||
function _temp(setter) {
|
||||
return setter(false)
|
||||
}
|
||||
|
||||
export default Button
|
||||
export type { ButtonState }
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlJlZiIsInVzZUNhbGxiYWNrIiwidXNlRWZmZWN0IiwidXNlUmVmIiwidXNlU3RhdGUiLCJFeGNlcHQiLCJET01FbGVtZW50IiwiQ2xpY2tFdmVudCIsIkZvY3VzRXZlbnQiLCJLZXlib2FyZEV2ZW50IiwiU3R5bGVzIiwiQm94IiwiQnV0dG9uU3RhdGUiLCJmb2N1c2VkIiwiaG92ZXJlZCIsImFjdGl2ZSIsIlByb3BzIiwicmVmIiwib25BY3Rpb24iLCJ0YWJJbmRleCIsImF1dG9Gb2N1cyIsImNoaWxkcmVuIiwic3RhdGUiLCJSZWFjdE5vZGUiLCJCdXR0b24iLCJ0MCIsIiQiLCJfYyIsInN0eWxlIiwidDEiLCJ1bmRlZmluZWQiLCJpc0ZvY3VzZWQiLCJzZXRJc0ZvY3VzZWQiLCJpc0hvdmVyZWQiLCJzZXRJc0hvdmVyZWQiLCJpc0FjdGl2ZSIsInNldElzQWN0aXZlIiwiYWN0aXZlVGltZXIiLCJ0MiIsInQzIiwiU3ltYm9sIiwiZm9yIiwiY3VycmVudCIsImNsZWFyVGltZW91dCIsInQ0IiwiZSIsImtleSIsInByZXZlbnREZWZhdWx0Iiwic2V0VGltZW91dCIsIl90ZW1wIiwiaGFuZGxlS2V5RG93biIsInQ1IiwiX2UiLCJoYW5kbGVDbGljayIsInQ2IiwiX2VfMCIsImhhbmRsZUZvY3VzIiwidDciLCJfZV8xIiwiaGFuZGxlQmx1ciIsInQ4IiwiaGFuZGxlTW91c2VFbnRlciIsInQ5IiwiaGFuZGxlTW91c2VMZWF2ZSIsInQxMCIsImNvbnRlbnQiLCJ0MTEiLCJzZXR0ZXIiXSwic291cmNlcyI6WyJCdXR0b24udHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBSZWFjdCwge1xuICB0eXBlIFJlZixcbiAgdXNlQ2FsbGJhY2ssXG4gIHVzZUVmZmVjdCxcbiAgdXNlUmVmLFxuICB1c2VTdGF0ZSxcbn0gZnJvbSAncmVhY3QnXG5pbXBvcnQgdHlwZSB7IEV4Y2VwdCB9IGZyb20gJ3R5cGUtZmVzdCdcbmltcG9ydCB0eXBlIHsgRE9NRWxlbWVudCB9IGZyb20gJy4uL2RvbS5qcydcbmltcG9ydCB0eXBlIHsgQ2xpY2tFdmVudCB9IGZyb20gJy4uL2V2ZW50cy9jbGljay1ldmVudC5qcydcbmltcG9ydCB0eXBlIHsgRm9jdXNFdmVudCB9IGZyb20gJy4uL2V2ZW50cy9mb2N1cy1ldmVudC5qcydcbmltcG9ydCB0eXBlIHsgS2V5Ym9hcmRFdmVudCB9IGZyb20gJy4uL2V2ZW50cy9rZXlib2FyZC1ldmVudC5qcydcbmltcG9ydCB0eXBlIHsgU3R5bGVzIH0gZnJvbSAnLi4vc3R5bGVzLmpzJ1xuaW1wb3J0IEJveCBmcm9tICcuL0JveC5qcydcblxudHlwZSBCdXR0b25TdGF0ZSA9IHtcbiAgZm9jdXNlZDogYm9vbGVhblxuICBob3ZlcmVkOiBib29sZWFuXG4gIGFjdGl2ZTogYm9vbGVhblxufVxuXG5leHBvcnQgdHlwZSBQcm9wcyA9IEV4Y2VwdDxTdHlsZXMsICd0ZXh0V3JhcCc+ICYge1xuICByZWY/OiBSZWY8RE9NRWxlbWVudD5cbiAgLyoqXG4gICAqIENhbGxlZCB3aGVuIHRoZSBidXR0b24gaXMgYWN0aXZhdGVkIHZpYSBFbnRlciwgU3BhY2UsIG9yIGNsaWNrLlxuICAgKi9cbiAgb25BY3Rpb246ICgpID0+IHZvaWRcbiAgLyoqXG4gICAqIFRhYiBvcmRlciBpbmRleC4gRGVmYXVsdHMgdG8gMCAoaW4gdGFiIG9yZGVyKS5cbiAgICogU2V0IHRvIC0xIGZvciBwcm9ncmFtbWF0aWNhbGx5IGZvY3VzYWJsZSBvbmx5LlxuICAgKi9cbiAgdGFiSW5kZXg/OiBudW1iZXJcbiAgLyoqXG4gICAqIEZvY3VzIHRoaXMgYnV0dG9uIHdoZW4gaXQgbW91bnRzLlxuICAgKi9cbiAgYXV0b0ZvY3VzPzogYm9vbGVhblxuICAvKipcbiAgICogUmVuZGVyIHByb3AgcmVjZWl2aW5nIHRoZSBpbnRlcmFjdGl2ZSBzdGF0ZS4gVXNlIHRoaXMgdG9cbiAgICogc3R5bGUgY2hpbGRyZW4gYmFzZWQgb24gZm9jdXMvaG92ZXIvYWN0aXZlIOKAlCBCdXR0b24gaXRzZWxmXG4gICAqIGlzIGludGVudGlvbmFsbHkgdW5zdHlsZWQuXG4gICAqXG4gICAqIElmIG5vdCBwcm92aWRlZCwgY2hpbGRyZW4gcmVuZGVyIGFzLWlzIChubyBzdGF0ZS1kZXBlbmRlbnQgc3R5bGluZykuXG4gICAqL1xuICBjaGlsZHJlbjogKChzdGF0ZTogQnV0dG9uU3RhdGUpID0+IFJlYWN0LlJlYWN0Tm9kZSkgfCBSZWFjdC5SZWFjdE5vZGVcbn1cblxuZnVuY3Rpb24gQnV0dG9uKHtcbiAgb25BY3Rpb24sXG4gIHRhYkluZGV4ID0gMCxcbiAgYXV0b0ZvY3VzLFxuICBjaGlsZHJlbixcbiAgcmVmLFxuICAuLi5zdHlsZVxufTogUHJvcHMpOiBSZWFjdC5SZWFjdE5vZGUge1xuICBjb25zdCBbaXNGb2N1c2VkLCBzZXRJc0ZvY3VzZWRdID0gdXNlU3RhdGUoZmFsc2UpXG4gIGNvbnN0IFtpc0hvdmVyZWQsIHNldElzSG92ZXJlZF0gPSB1c2VTdGF0ZShmYWxzZSlcbiAgY29uc3QgW2lzQWN0aXZlLCBzZXRJc0FjdGl2ZV0gPSB1c2VTdGF0ZShmYWxzZSlcblxuICBjb25zdCBhY3RpdmVUaW1lciA9IHVzZVJlZjxSZXR1cm5UeXBlPHR5cGVvZiBzZXRUaW1lb3V0PiB8IG51bGw+KG51bGwpXG5cbiAgdXNlRWZmZWN0KCgpID0+IHtcbiAgICByZXR1cm4gKCkgPT4ge1xuICAgICAgaWYgKGFjdGl2ZVRpbWVyLmN1cnJlbnQpIGNsZWFyVGltZW91dChhY3RpdmVUaW1lci5jdXJyZW50KVxuICAgIH1cbiAgfSwgW10pXG5cbiAgY29uc3QgaGFuZGxlS2V5RG93biA9IHVzZUNhbGxiYWNrKFxuICAgIChlOiBLZXlib2FyZEV2ZW50KSA9PiB7XG4gICAgICBpZiAoZS5rZXkgPT09ICdyZXR1cm4nIHx8IGUua2V5ID09PSAnICcpIHtcbiAgICAgICAgZS5wcmV2ZW50RGVmYXVsdCgpXG4gICAgICAgIHNldElzQWN0aXZlKHRydWUpXG4gICAgICAgIG9uQWN0aW9uKClcbiAgICAgICAgaWYgKGFjdGl2ZVRpbWVyLmN1cnJlbnQpIGNsZWFyVGltZW91dChhY3RpdmVUaW1lci5jdXJyZW50KVxuICAgICAgICBhY3RpdmVUaW1lci5jdXJyZW50ID0gc2V0VGltZW91dChcbiAgICAgICAgICBzZXR0ZXIgPT4gc2V0dGVyKGZhbHNlKSxcbiAgICAgICAgICAxMDAsXG4gICAgICAgICAgc2V0SXNBY3RpdmUsXG4gICAgICAgIClcbiAgICAgIH1cbiAgICB9LFxuICAgIFtvbkFjdGlvbl0sXG4gIClcblxuICBjb25zdCBoYW5kbGVDbGljayA9IHVzZUNhbGxiYWNrKFxuICAgIChfZTogQ2xpY2tFdmVudCkgPT4ge1xuICAgICAgb25BY3Rpb24oKVxuICAgIH0sXG4gICAgW29uQWN0aW9uXSxcbiAgKVxuXG4gIGNvbnN0IGhhbmRsZUZvY3VzID0gdXNlQ2FsbGJhY2soKF9lOiBGb2N1c0V2ZW50KSA9PiBzZXRJc0ZvY3VzZWQodHJ1ZSksIFtdKVxuICBjb25zdCBoYW5kbGVCbHVyID0gdXNlQ2FsbGJhY2soKF9lOiBGb2N1c0V2ZW50KSA9PiBzZXRJc0ZvY3VzZWQoZmFsc2UpLCBbXSlcbiAgY29uc3QgaGFuZGxlTW91c2VFbnRlciA9IHVzZUNhbGxiYWNrKCgpID0+IHNldElzSG92ZXJlZCh0cnVlKSwgW10pXG4gIGNvbnN0IGhhbmRsZU1vdXNlTGVhdmUgPSB1c2VDYWxsYmFjaygoKSA9PiBzZXRJc0hvdmVyZWQoZmFsc2UpLCBbXSlcblxuICBjb25zdCBzdGF0ZTogQnV0dG9uU3RhdGUgPSB7XG4gICAgZm9jdXNlZDogaXNGb2N1c2VkLFxuICAgIGhvdmVyZWQ6IGlzSG92ZXJlZCxcbiAgICBhY3RpdmU6IGlzQWN0aXZlLFxuICB9XG4gIGNvbnN0IGNvbnRlbnQgPSB0eXBlb2YgY2hpbGRyZW4gPT09ICdmdW5jdGlvbicgPyBjaGlsZHJlbihzdGF0ZSkgOiBjaGlsZHJlblxuXG4gIHJldHVybiAoXG4gICAgPEJveFxuICAgICAgcmVmPXtyZWZ9XG4gICAgICB0YWJJbmRleD17dGFiSW5kZXh9XG4gICAgICBhdXRvRm9jdXM9e2F1dG9Gb2N1c31cbiAgICAgIG9uS2V5RG93bj17aGFuZGxlS2V5RG93bn1cbiAgICAgIG9uQ2xpY2s9e2hhbmRsZUNsaWNrfVxuICAgICAgb25
Line truncated
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { createContext, type ReactNode, useEffect, useState } from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
|
||||
import { BLURRED_FRAME_INTERVAL_MS, FRAME_INTERVAL_MS } from '../constants.js'
|
||||
import { useTerminalFocus } from '../hooks/use-terminal-focus.js'
|
||||
export type Clock = {
|
||||
subscribe: (onChange: () => void, keepAlive: boolean) => () => void
|
||||
now: () => number
|
||||
setTickInterval: (ms: number) => void
|
||||
}
|
||||
|
||||
export function createClock(tickIntervalMs: number): Clock {
|
||||
const subscribers = new Map<() => void, boolean>()
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
let currentTickIntervalMs = tickIntervalMs
|
||||
let startTime = 0
|
||||
// Snapshot of the current tick's time, ensuring all subscribers in the same
|
||||
// tick see the same value (keeps animations synchronized)
|
||||
let tickTime = 0
|
||||
|
||||
function tick(): void {
|
||||
tickTime = Date.now() - startTime
|
||||
|
||||
for (const onChange of subscribers.keys()) {
|
||||
onChange()
|
||||
}
|
||||
}
|
||||
|
||||
function updateInterval(): void {
|
||||
const anyKeepAlive = [...subscribers.values()].some(Boolean)
|
||||
|
||||
if (anyKeepAlive) {
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
}
|
||||
|
||||
if (startTime === 0) {
|
||||
startTime = Date.now()
|
||||
}
|
||||
|
||||
interval = setInterval(tick, currentTickIntervalMs)
|
||||
} else if (interval) {
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe(onChange, keepAlive) {
|
||||
subscribers.set(onChange, keepAlive)
|
||||
updateInterval()
|
||||
|
||||
return () => {
|
||||
subscribers.delete(onChange)
|
||||
updateInterval()
|
||||
}
|
||||
},
|
||||
now() {
|
||||
if (startTime === 0) {
|
||||
startTime = Date.now()
|
||||
}
|
||||
|
||||
// When the clock interval is running, return the synchronized tickTime
|
||||
// so all subscribers in the same tick see the same value.
|
||||
// When paused (no keepAlive subscribers), return real-time to avoid
|
||||
// returning a stale tickTime from the last tick before the pause.
|
||||
if (interval && tickTime) {
|
||||
return tickTime
|
||||
}
|
||||
|
||||
return Date.now() - startTime
|
||||
},
|
||||
setTickInterval(ms) {
|
||||
if (ms === currentTickIntervalMs) {
|
||||
return
|
||||
}
|
||||
|
||||
currentTickIntervalMs = ms
|
||||
updateInterval()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ClockContext = createContext<Clock | null>(null)
|
||||
|
||||
// Own component so App.tsx doesn't re-render when the clock is created.
|
||||
// The clock value is stable (created once via useState), so the provider
|
||||
// never causes consumer re-renders on its own.
|
||||
export function ClockProvider(t0: { readonly children: ReactNode }) {
|
||||
const $ = _c(7)
|
||||
|
||||
const { children } = t0
|
||||
|
||||
const [clock] = useState(_temp)
|
||||
const focused = useTerminalFocus()
|
||||
let t1
|
||||
let t2
|
||||
|
||||
if ($[0] !== clock || $[1] !== focused) {
|
||||
t1 = () => {
|
||||
clock.setTickInterval(focused ? FRAME_INTERVAL_MS : BLURRED_FRAME_INTERVAL_MS)
|
||||
}
|
||||
|
||||
t2 = [clock, focused]
|
||||
$[0] = clock
|
||||
$[1] = focused
|
||||
$[2] = t1
|
||||
$[3] = t2
|
||||
} else {
|
||||
t1 = $[2]
|
||||
t2 = $[3]
|
||||
}
|
||||
|
||||
useEffect(t1, t2)
|
||||
let t3
|
||||
|
||||
if ($[4] !== children || $[5] !== clock) {
|
||||
t3 = <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>
|
||||
$[4] = children
|
||||
$[5] = clock
|
||||
$[6] = t3
|
||||
} else {
|
||||
t3 = $[6]
|
||||
}
|
||||
|
||||
return t3
|
||||
}
|
||||
|
||||
function _temp() {
|
||||
return createClock(FRAME_INTERVAL_MS)
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsImNyZWF0ZUNvbnRleHQiLCJ1c2VFZmZlY3QiLCJ1c2VTdGF0ZSIsIkZSQU1FX0lOVEVSVkFMX01TIiwidXNlVGVybWluYWxGb2N1cyIsIkNsb2NrIiwic3Vic2NyaWJlIiwib25DaGFuZ2UiLCJrZWVwQWxpdmUiLCJub3ciLCJzZXRUaWNrSW50ZXJ2YWwiLCJtcyIsImNyZWF0ZUNsb2NrIiwidGlja0ludGVydmFsTXMiLCJzdWJzY3JpYmVycyIsIk1hcCIsImludGVydmFsIiwiUmV0dXJuVHlwZSIsInNldEludGVydmFsIiwiY3VycmVudFRpY2tJbnRlcnZhbE1zIiwic3RhcnRUaW1lIiwidGlja1RpbWUiLCJ0aWNrIiwiRGF0ZSIsImtleXMiLCJ1cGRhdGVJbnRlcnZhbCIsImFueUtlZXBBbGl2ZSIsInZhbHVlcyIsInNvbWUiLCJCb29sZWFuIiwiY2xlYXJJbnRlcnZhbCIsInNldCIsImRlbGV0ZSIsIkNsb2NrQ29udGV4dCIsIkJMVVJSRURfVElDS19JTlRFUlZBTF9NUyIsIkNsb2NrUHJvdmlkZXIiLCJ0MCIsIiQiLCJfYyIsImNoaWxkcmVuIiwiY2xvY2siLCJfdGVtcCIsImZvY3VzZWQiLCJ0MSIsInQyIiwidDMiXSwic291cmNlcyI6WyJDbG9ja0NvbnRleHQudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBSZWFjdCwgeyBjcmVhdGVDb250ZXh0LCB1c2VFZmZlY3QsIHVzZVN0YXRlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBGUkFNRV9JTlRFUlZBTF9NUyB9IGZyb20gJy4uL2NvbnN0YW50cy5qcydcbmltcG9ydCB7IHVzZVRlcm1pbmFsRm9jdXMgfSBmcm9tICcuLi9ob29rcy91c2UtdGVybWluYWwtZm9jdXMuanMnXG5cbmV4cG9ydCB0eXBlIENsb2NrID0ge1xuICBzdWJzY3JpYmU6IChvbkNoYW5nZTogKCkgPT4gdm9pZCwga2VlcEFsaXZlOiBib29sZWFuKSA9PiAoKSA9PiB2b2lkXG4gIG5vdzogKCkgPT4gbnVtYmVyXG4gIHNldFRpY2tJbnRlcnZhbDogKG1zOiBudW1iZXIpID0+IHZvaWRcbn1cblxuZXhwb3J0IGZ1bmN0aW9uIGNyZWF0ZUNsb2NrKHRpY2tJbnRlcnZhbE1zOiBudW1iZXIpOiBDbG9jayB7XG4gIGNvbnN0IHN1YnNjcmliZXJzID0gbmV3IE1hcDwoKSA9PiB2b2lkLCBib29sZWFuPigpXG4gIGxldCBpbnRlcnZhbDogUmV0dXJuVHlwZTx0eXBlb2Ygc2V0SW50ZXJ2YWw+IHwgbnVsbCA9IG51bGxcbiAgbGV0IGN1cnJlbnRUaWNrSW50ZXJ2YWxNcyA9IHRpY2tJbnRlcnZhbE1zXG4gIGxldCBzdGFydFRpbWUgPSAwXG4gIC8vIFNuYXBzaG90IG9mIHRoZSBjdXJyZW50IHRpY2sncyB0aW1lLCBlbnN1cmluZyBhbGwgc3Vic2NyaWJlcnMgaW4gdGhlIHNhbWVcbiAgLy8gdGljayBzZWUgdGhlIHNhbWUgdmFsdWUgKGtlZXBzIGFuaW1hdGlvbnMgc3luY2hyb25pemVkKVxuICBsZXQgdGlja1RpbWUgPSAwXG5cbiAgZnVuY3Rpb24gdGljaygpOiB2b2lkIHtcbiAgICB0aWNrVGltZSA9IERhdGUubm93KCkgLSBzdGFydFRpbWVcbiAgICBmb3IgKGNvbnN0IG9uQ2hhbmdlIG9mIHN1YnNjcmliZXJzLmtleXMoKSkge1xuICAgICAgb25DaGFuZ2UoKVxuICAgIH1cbiAgfVxuXG4gIGZ1bmN0aW9uIHVwZGF0ZUludGVydmFsKCk6IHZvaWQge1xuICAgIGNvbnN0IGFueUtlZXBBbGl2ZSA9IFsuLi5zdWJzY3JpYmVycy52YWx1ZXMoKV0uc29tZShCb29sZWFuKVxuXG4gICAgaWYgKGFueUtlZXBBbGl2ZSkge1xuICAgICAgaWYgKGludGVydmFsKSB7XG4gICAgICAgIGNsZWFySW50ZXJ2YWwoaW50ZXJ2YWwpXG4gICAgICAgIGludGVydmFsID0gbnVsbFxuICAgICAgfVxuICAgICAgaWYgKHN0YXJ0VGltZSA9PT0gMCkge1xuICAgICAgICBzdGFydFRpbWUgPSBEYXRlLm5vdygpXG4gICAgICB9XG4gICAgICBpbnRlcnZhbCA9IHNldEludGVydmFsKHRpY2ssIGN1cnJlbnRUaWNrSW50ZXJ2YWxNcylcbiAgICB9IGVsc2UgaWYgKGludGVydmFsKSB7XG4gICAgICBjbGVhckludGVydmFsKGludGVydmFsKVxuICAgICAgaW50ZXJ2YWwgPSBudWxsXG4gICAgfVxuICB9XG5cbiAgcmV0dXJuIHtcbiAgICBzdWJzY3JpYmUob25DaGFuZ2UsIGtlZXBBbGl2ZSkge1xuICAgICAgc3Vic2NyaWJlcnMuc2V0KG9uQ2hhbmdlLCBrZWVwQWxpdmUpXG4gICAgICB1cGRhdGVJbnRlcnZhbCgpXG4gICAgICByZXR1cm4gKCkgPT4ge1xuICAgICAgICBzdWJzY3JpYmVycy5kZWxldGUob25DaGFuZ2UpXG4gICAgICAgIHVwZGF0ZUludGVydmFsKClcbiAgICAgIH1cbiAgICB9LFxuXG4gICAgbm93KCkge1xuICAgICAgaWYgKHN0YXJ0VGltZSA9PT0gMCkge1xuICAgICAgICBzdGFydFRpbWUgPSBEYXRlLm5vdygpXG4gICAgICB9XG4gICAgICAvLyBXaGVuIHRoZSBjbG9jayBpbnRlcnZhbCBpcyBydW5uaW5nLCByZXR1cm4gdGhlIHN5bmNocm9uaXplZCB0aWNrVGltZVxuICAgICAgLy8gc28gYWxsIHN1YnNjcmliZXJzIGluIHRoZSBzYW1lIHRpY2sgc2VlIHRoZSBzYW1lIHZhbHVlLlxuICAgICAgLy8gV2hlbiBwYXVzZWQgKG5vIGtlZXBBbGl2ZSBzdWJzY3JpYmVycyksIHJldHVybiByZWFsLXRpbWUgdG8gYXZvaWRcbiAgICAgIC8vIHJldHVybmluZyBhIHN0YWxlIHRpY2tUaW1lIGZyb20gdGhlIGxhc3QgdGljayBiZWZvcmUgdGhlIHBhdXNlLlxuICAgICAgaWYgKGludGVydmFsICYmIHRpY2tUaW1lKSB7XG4gICAgICAgIHJldHVybiB0aWNrVGltZVxuICAgICAgfVxuICAgICAgcmV0dXJuIERhdGUubm93KCkgLSBzdGFydFRpbWVcbiAgICB9LFxuXG4gICAgc2V0VGlja0ludGVydmFsKG1zKSB7XG4gICAgICBpZiAobXMgPT09IGN1cnJlbnRUaWNrSW50ZXJ2YWxNcykgcmV0dXJuXG4gICAgICBjdXJyZW50VGlja0ludGVydmFsTXMgPSBtc1xuICAgICAgdXBkYXRlSW50ZXJ2YWwoKVxuICAgIH0sXG4gIH1cbn1cblxuZXhwb3J0IGNvbnN0IENsb2NrQ29udGV4dCA9IGNyZWF0ZUNvbnRleHQ8Q2xvY2sgfCBudWxsPihudWxsKVxuXG5jb25zdCBCTFVSUkVEX1RJQ0tfSU5URVJWQUxfTVMgPSBGUkFNRV9JTlRFUlZBTF9NUyAqIDJcblxuLy8gT3duIGNvbXBvbmVudCBzbyBBcHAudHN4IGRvZXNuJ3QgcmUtcmVuZGVyIHdoZW4gdGhlIGNsb2NrIGlzIGNyZWF0ZWQuXG4vLyBUaGUgY2xvY2sgdmFsdWUgaXMgc3RhYmxlIChjcmVhdGVkIG9uY2UgdmlhIHVzZVN0YXRlKSwgc28gdGhlIHByb3ZpZGVyXG4vLyBuZXZlciBjYXVzZXMgY29uc3VtZXIgcmUtcmVuZGVycyBvbiBpdHMgb3duLlxuZXhwb3J0IGZ1bmN0aW9uIENsb2NrUHJvdmlkZXIoe1xuICBjaGlsZHJlbixcbn06IHtcbiAgY2hpbGRyZW46IFJlYWN0LlJlYWN0Tm9kZVxufSk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIGNvbnN0IFtjbG9ja10gPSB1c2VTdGF0ZSgoKSA9PiBjcmVhdGVDbG9jayhGUkFNRV9JTlRFUlZBTF9NUykpXG4gIGNvbnN0IGZvY3VzZWQgPSB1c2VUZXJtaW5hbEZvY3VzKClcblxuICB1c2VFZmZlY3QoKCkgPT4ge1xuICAgIGNsb2NrLnNldFRpY2tJbnRlcnZhbChcbiAgICAgIGZvY3VzZWQgPyBGUkFNRV9JTlRFUlZBTF9NUyA6IEJMVVJSRURfVElDS19JTlRFUlZBTF9NUyxcbiAgICApXG4gIH0sIFtjbG9jaywgZm9jdXNlZF0pXG5cbiAgcmV0dXJuIDxDbG9ja0NvbnRleHQuUHJvdmlkZXIgdmFsdWU9e2Nsb2NrfT57Y2hpbGRyZW59PC9DbG9ja0NvbnRleHQuUHJvdmlkZXI+XG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLElBQUlDLGFBQWEsRUFBRUMsU0FBUyxFQUFFQyxRQUFRLFFBQVEsT0FBTztBQUNqRSxTQUFTQyxpQkFBaUIsUUFBUSxpQkFBaUI7QUFDbkQsU0FBU0M
Line truncated
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
/**
|
||||
* Notify Ink that the physical terminal cursor was advanced by an
|
||||
* out-of-band stdout.write (e.g. the TextInput fast-echo path).
|
||||
*
|
||||
* This is a two-part notification — calling it updates both:
|
||||
*
|
||||
* 1. Ink's cached `displayCursor` (the basis log-update uses to
|
||||
* compute relative cursor moves for the next frame's preamble).
|
||||
* Without this, the next frame's preamble starts from a stale
|
||||
* parked position and the diff is rendered N cells offset.
|
||||
* This half is SKIPPED on alt-screen — every alt-screen frame
|
||||
* begins with CSI H which absolutely repositions the cursor, so
|
||||
* the relative-move basis is reset for free.
|
||||
*
|
||||
* 2. Ink's active `cursorDeclaration` (the target the cursor parks
|
||||
* at after every frame, set by `useDeclaredCursor`). Without
|
||||
* this, an unrelated component re-rendering before the deferred
|
||||
* React state catches up would publish a stale declaration and
|
||||
* visually undo the fast-echo's advance. This half applies to
|
||||
* BOTH main-screen and alt-screen — on alt-screen the cursor-
|
||||
* park branch in onRender emits an absolute CUP to
|
||||
* `rect.x + decl.relativeX`, so a stale declaration there is
|
||||
* still wrong even though displayCursor is skipped.
|
||||
*
|
||||
* `dx`/`dy` are deltas in terminal cells (positive = right/down,
|
||||
* negative = left/up). The caller is responsible for ensuring the
|
||||
* physical cursor really did move by that amount.
|
||||
*/
|
||||
export type CursorAdvanceNotifier = (dx: number, dy?: number) => void
|
||||
|
||||
const CursorAdvanceContext = createContext<CursorAdvanceNotifier>(() => {})
|
||||
|
||||
export default CursorAdvanceContext
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
import type { DOMElement } from '../dom.js'
|
||||
|
||||
export type CursorDeclaration = {
|
||||
/** Display column (terminal cell width) within the declared node */
|
||||
readonly relativeX: number
|
||||
/** Line number within the declared node */
|
||||
readonly relativeY: number
|
||||
/** The ink-box DOMElement whose yoga layout provides the absolute origin */
|
||||
readonly node: DOMElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter for the declared cursor position.
|
||||
*
|
||||
* The optional second argument makes `null` a conditional clear: the
|
||||
* declaration is only cleared if the currently-declared node matches
|
||||
* `clearIfNode`. This makes the hook safe for sibling components
|
||||
* (e.g. list items) that transfer focus among themselves — without the
|
||||
* node check, a newly-unfocused item's clear could clobber a
|
||||
* newly-focused sibling's set depending on layout-effect order.
|
||||
*/
|
||||
export type CursorDeclarationSetter = (declaration: CursorDeclaration | null, clearIfNode?: DOMElement | null) => void
|
||||
|
||||
const CursorDeclarationContext = createContext<CursorDeclarationSetter>(() => {})
|
||||
|
||||
export default CursorDeclarationContext
|
||||
@@ -0,0 +1,130 @@
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import codeExcerpt, { type CodeExcerpt } from 'code-excerpt'
|
||||
import React from 'react'
|
||||
import StackUtils from 'stack-utils'
|
||||
|
||||
import Box from './Box.js'
|
||||
import Text from './Text.js'
|
||||
|
||||
// Error's source file is reported as file:///home/user/file.js
|
||||
// This function removes the file://[cwd] part
|
||||
const cleanupPath = (path: string | undefined): string | undefined => {
|
||||
return path?.replace(`file://${process.cwd()}/`, '')
|
||||
}
|
||||
|
||||
let stackUtils: StackUtils | undefined
|
||||
|
||||
function getStackUtils(): StackUtils {
|
||||
return (stackUtils ??= new StackUtils({
|
||||
cwd: process.cwd(),
|
||||
internals: StackUtils.nodeInternals()
|
||||
}))
|
||||
}
|
||||
|
||||
type Props = {
|
||||
readonly error: Error
|
||||
}
|
||||
|
||||
export default function ErrorOverview({ error }: Props) {
|
||||
const stack = error.stack ? error.stack.split('\n').slice(1) : undefined
|
||||
const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined
|
||||
const filePath = cleanupPath(origin?.file)
|
||||
let excerpt: CodeExcerpt[] | undefined
|
||||
let lineWidth = 0
|
||||
|
||||
if (filePath && origin?.line) {
|
||||
try {
|
||||
const sourceCode = readFileSync(filePath, 'utf8')
|
||||
excerpt = codeExcerpt(sourceCode, origin.line)
|
||||
|
||||
if (excerpt) {
|
||||
for (const { line } of excerpt) {
|
||||
lineWidth = Math.max(lineWidth, String(line).length)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// file not readable — skip source context
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Box>
|
||||
<Text backgroundColor="ansi:red" color="ansi:white">
|
||||
{' '}
|
||||
ERROR{' '}
|
||||
</Text>
|
||||
|
||||
<Text> {error.message}</Text>
|
||||
</Box>
|
||||
|
||||
{origin && filePath && (
|
||||
<Box marginTop={1}>
|
||||
<Text dim>
|
||||
{filePath}:{origin.line}:{origin.column}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{origin && excerpt && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{excerpt.map(({ line: line_0, value }) => (
|
||||
<Box key={line_0}>
|
||||
<Box width={lineWidth + 1}>
|
||||
<Text
|
||||
backgroundColor={line_0 === origin.line ? 'ansi:red' : undefined}
|
||||
color={line_0 === origin.line ? 'ansi:white' : undefined}
|
||||
dim={line_0 !== origin.line}
|
||||
>
|
||||
{String(line_0).padStart(lineWidth, ' ')}:
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
backgroundColor={line_0 === origin.line ? 'ansi:red' : undefined}
|
||||
color={line_0 === origin.line ? 'ansi:white' : undefined}
|
||||
key={line_0}
|
||||
>
|
||||
{' ' + value}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error.stack && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{error.stack
|
||||
.split('\n')
|
||||
.slice(1)
|
||||
.map(line_1 => {
|
||||
const parsedLine = getStackUtils().parseLine(line_1)
|
||||
|
||||
// If the line from the stack cannot be parsed, we print out the unparsed line.
|
||||
if (!parsedLine) {
|
||||
return (
|
||||
<Box key={line_1}>
|
||||
<Text dim>- </Text>
|
||||
<Text bold>{line_1}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={line_1}>
|
||||
<Text dim>- </Text>
|
||||
<Text bold>{parsedLine.function}</Text>
|
||||
<Text dim>
|
||||
{' '}
|
||||
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:{parsedLine.column})
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJjb2RlRXhjZXJwdCIsIkNvZGVFeGNlcnB0IiwicmVhZEZpbGVTeW5jIiwiUmVhY3QiLCJTdGFja1V0aWxzIiwiQm94IiwiVGV4dCIsImNsZWFudXBQYXRoIiwicGF0aCIsInJlcGxhY2UiLCJwcm9jZXNzIiwiY3dkIiwic3RhY2tVdGlscyIsImdldFN0YWNrVXRpbHMiLCJpbnRlcm5hbHMiLCJub2RlSW50ZXJuYWxzIiwiUHJvcHMiLCJlcnJvciIsIkVycm9yIiwiRXJyb3JPdmVydmlldyIsInN0YWNrIiwic3BsaXQiLCJzbGljZSIsInVuZGVmaW5lZCIsIm9yaWdpbiIsInBhcnNlTGluZSIsImZpbGVQYXRoIiwiZmlsZSIsImV4Y2VycHQiLCJsaW5lV2lkdGgiLCJsaW5lIiwic291cmNlQ29kZSIsIk1hdGgiLCJtYXgiLCJTdHJpbmciLCJsZW5ndGgiLCJtZXNzYWdlIiwiY29sdW1uIiwibWFwIiwidmFsdWUiLCJwYWRTdGFydCIsInBhcnNlZExpbmUiLCJmdW5jdGlvbiJdLCJzb3VyY2VzIjpbIkVycm9yT3ZlcnZpZXcudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBjb2RlRXhjZXJwdCwgeyB0eXBlIENvZGVFeGNlcnB0IH0gZnJvbSAnY29kZS1leGNlcnB0J1xuaW1wb3J0IHsgcmVhZEZpbGVTeW5jIH0gZnJvbSAnZnMnXG5pbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgU3RhY2tVdGlscyBmcm9tICdzdGFjay11dGlscydcbmltcG9ydCBCb3ggZnJvbSAnLi9Cb3guanMnXG5pbXBvcnQgVGV4dCBmcm9tICcuL1RleHQuanMnXG5cbi8qIGVzbGludC1kaXNhYmxlIGN1c3RvbS1ydWxlcy9uby1wcm9jZXNzLWN3ZCAtLSBzdGFjayB0cmFjZSBmaWxlOi8vIHBhdGhzIGFyZSByZWxhdGl2ZSB0byB0aGUgcmVhbCBPUyBjd2QsIG5vdCB0aGUgdmlydHVhbCBjd2QgKi9cblxuLy8gRXJyb3IncyBzb3VyY2UgZmlsZSBpcyByZXBvcnRlZCBhcyBmaWxlOi8vL2hvbWUvdXNlci9maWxlLmpzXG4vLyBUaGlzIGZ1bmN0aW9uIHJlbW92ZXMgdGhlIGZpbGU6Ly9bY3dkXSBwYXJ0XG5jb25zdCBjbGVhbnVwUGF0aCA9IChwYXRoOiBzdHJpbmcgfCB1bmRlZmluZWQpOiBzdHJpbmcgfCB1bmRlZmluZWQgPT4ge1xuICByZXR1cm4gcGF0aD8ucmVwbGFjZShgZmlsZTovLyR7cHJvY2Vzcy5jd2QoKX0vYCwgJycpXG59XG5cbmxldCBzdGFja1V0aWxzOiBTdGFja1V0aWxzIHwgdW5kZWZpbmVkXG5mdW5jdGlvbiBnZXRTdGFja1V0aWxzKCk6IFN0YWNrVXRpbHMge1xuICByZXR1cm4gKHN0YWNrVXRpbHMgPz89IG5ldyBTdGFja1V0aWxzKHtcbiAgICBjd2Q6IHByb2Nlc3MuY3dkKCksXG4gICAgaW50ZXJuYWxzOiBTdGFja1V0aWxzLm5vZGVJbnRlcm5hbHMoKSxcbiAgfSkpXG59XG5cbi8qIGVzbGludC1lbmFibGUgY3VzdG9tLXJ1bGVzL25vLXByb2Nlc3MtY3dkICovXG5cbnR5cGUgUHJvcHMgPSB7XG4gIHJlYWRvbmx5IGVycm9yOiBFcnJvclxufVxuXG5leHBvcnQgZGVmYXVsdCBmdW5jdGlvbiBFcnJvck92ZXJ2aWV3KHsgZXJyb3IgfTogUHJvcHMpIHtcbiAgY29uc3Qgc3RhY2sgPSBlcnJvci5zdGFjayA/IGVycm9yLnN0YWNrLnNwbGl0KCdcXG4nKS5zbGljZSgxKSA6IHVuZGVmaW5lZFxuICBjb25zdCBvcmlnaW4gPSBzdGFjayA/IGdldFN0YWNrVXRpbHMoKS5wYXJzZUxpbmUoc3RhY2tbMF0hKSA6IHVuZGVmaW5lZFxuICBjb25zdCBmaWxlUGF0aCA9IGNsZWFudXBQYXRoKG9yaWdpbj8uZmlsZSlcbiAgbGV0IGV4Y2VycHQ6IENvZGVFeGNlcnB0W10gfCB1bmRlZmluZWRcbiAgbGV0IGxpbmVXaWR0aCA9IDBcblxuICBpZiAoZmlsZVBhdGggJiYgb3JpZ2luPy5saW5lKSB7XG4gICAgdHJ5IHtcbiAgICAgIC8vIGVzbGludC1kaXNhYmxlLW5leHQtbGluZSBjdXN0b20tcnVsZXMvbm8tc3luYy1mcyAtLSBzeW5jIHJlbmRlciBwYXRoOyBlcnJvciBvdmVybGF5IGNhbid0IGdvIGFzeW5jIHdpdGhvdXQgc3VzcGVuc2UgcmVzdHJ1Y3R1cmluZ1xuICAgICAgY29uc3Qgc291cmNlQ29kZSA9IHJlYWRGaWxlU3luYyhmaWxlUGF0aCwgJ3V0ZjgnKVxuICAgICAgZXhjZXJwdCA9IGNvZGVFeGNlcnB0KHNvdXJjZUNvZGUsIG9yaWdpbi5saW5lKVxuXG4gICAgICBpZiAoZXhjZXJwdCkge1xuICAgICAgICBmb3IgKGNvbnN0IHsgbGluZSB9IG9mIGV4Y2VycHQpIHtcbiAgICAgICAgICBsaW5lV2lkdGggPSBNYXRoLm1heChsaW5lV2lkdGgsIFN0cmluZyhsaW5lKS5sZW5ndGgpXG4gICAgICAgIH1cbiAgICAgIH1cbiAgICB9IGNhdGNoIHtcbiAgICAgIC8vIGZpbGUgbm90IHJlYWRhYmxlIOKAlCBza2lwIHNvdXJjZSBjb250ZXh0XG4gICAgfVxuICB9XG5cbiAgcmV0dXJuIChcbiAgICA8Qm94IGZsZXhEaXJlY3Rpb249XCJjb2x1bW5cIiBwYWRkaW5nPXsxfT5cbiAgICAgIDxCb3g+XG4gICAgICAgIDxUZXh0IGJhY2tncm91bmRDb2xvcj1cImFuc2k6cmVkXCIgY29sb3I9XCJhbnNpOndoaXRlXCI+XG4gICAgICAgICAgeycgJ31cbiAgICAgICAgICBFUlJPUnsnICd9XG4gICAgICAgIDwvVGV4dD5cblxuICAgICAgICA8VGV4dD4ge2Vycm9yLm1lc3NhZ2V9PC9UZXh0PlxuICAgICAgPC9Cb3g+XG5cbiAgICAgIHtvcmlnaW4gJiYgZmlsZVBhdGggJiYgKFxuICAgICAgICA8Qm94IG1hcmdpblRvcD17MX0+XG4gICAgICAgICAgPFRleHQgZGltPlxuICAgICAgICAgICAge2ZpbGVQYXRofTp7b3JpZ2luLmxpbmV9OntvcmlnaW4uY29sdW1ufVxuICAgICAgICAgIDwvVGV4dD5cbiAgICAgICAgPC9Cb3g+XG4gICAgICApfVxuXG4gICAgICB7b3JpZ2luICYmIGV4Y2VycHQgJiYgKFxuICAgICAgICA8Qm94IG1hcmdpblRvcD17MX0gZmxleERpcmVjdGlvbj1cImNvbHVtblwiPlxuICAgICAgICAgIHtleGNlcnB0Lm1hcCgoeyBsaW5lLCB2YWx1ZSB9KSA9PiAoXG4gICAgICAgICAgICA8Qm94IGtleT17bGluZX0+XG4gICAgICAgICAgICAgIDxCb3ggd2lkdGg9e2xpbmVXaWR0aCArIDF9PlxuICAgICAgICAgICAgICAgIDxUZXh0XG4gICAgICAgICAgICAgICAgICBkaW09e2xpbmUgIT09IG9yaWdpbi5saW5lfVxuICAgICAgICAgICAgICAgICAgYmFja2dyb3VuZENvbG9yPXtcbiAgICAgICAgICAgICAgICAgICAgbGluZSA9PT0gb3JpZ2luLmxpbmUgPyAnYW5zaTpyZWQnIDogdW5kZWZpbmVkXG4gICAgICAgICAgICAgICAgICB9XG4gICAgICAgICAgICAgICAgICBjb2xvcj17bGluZSA9PT0gb3JpZ2luLmxpbmUgPyAnYW5zaTp3aGl0ZScgOiB1bmRlZmluZWR9XG4gICAgICAgICAgICAgICAgPlxuICAgICAgICAgICAgICAgICAge1N0cmluZyhsaW5lKS5wYWRTdGFydChsaW5lV2lkdGgsICcgJyl9OlxuICAgICAgICAgICAgICAgIDwvVGV4dD5cbiAgICAgICAgICAgICAgPC9Cb3g+XG5cbiAgICAgICAgICAgICAgPFRleHRcbiAgICAgICAgICAgICAgICBrZXk9e2xpbmV9XG4gICAgICAgICAgICAgICAgYmFja2dyb3VuZENvbG9yPXtsaW5lID09PSBvcmlnaW4ubGluZSA/ICdhbnNpOnJlZCcgOiB1bmRlZmluZWR9XG4gICAgICAgICAgICAgICAgY29sb3I9e2xpbmUgPT09IG9yaWdpbi5saW5lID8gJ2Fuc2k6d2hpdGUnIDogdW5kZWZpbmVkfVxuICAgICAgICAgICAgICA+XG4gICAgICAgICAgICAgICAgeycgJyArIHZhbHVlfVxuICAgICAgICAgICAgICA8L1RleHQ+XG4gICAgICAgICAgICA8L0JveD5cbiAgICAgICAgICApKX1cbiAgICAgICAgPC9Cb3g+XG4gICAgICApfVxuXG4gICAgICB7ZXJyb3Iuc3RhY2sgJiYgKFxuICAgICAgICA8Qm94IG1hcmdpblRvcD17MX0gZmxleERpcmV
Line truncated
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
|
||||
import Text from './Text.js'
|
||||
export type Props = {
|
||||
readonly children?: ReactNode
|
||||
readonly url: string
|
||||
// Kept for backwards-compat: prior versions rendered `fallback` instead of
|
||||
// the linked content on terminals where supportsHyperlinks() was false. We
|
||||
// now always emit the hyperlink metadata so the in-process click/hover
|
||||
// dispatcher can act on it regardless of the terminal's own OSC 8 support
|
||||
// (see comment in the function body), so `fallback` is no longer wired up.
|
||||
// Leaving the prop on the interface keeps existing call sites compiling.
|
||||
readonly fallback?: ReactNode
|
||||
}
|
||||
|
||||
export default function Link({ children, url }: Props): React.ReactNode {
|
||||
// Always emit <ink-link>: the renderer stores `hyperlink` per cell in the
|
||||
// screen buffer, which the click dispatcher (Ink.getHyperlinkAt →
|
||||
// onHyperlinkClick) reads on mouseup to open URLs externally. Gating this
|
||||
// on supportsHyperlinks() broke clicks in Apple Terminal / any terminal
|
||||
// not on the OSC 8 allowlist — the cell's hyperlink field stayed empty,
|
||||
// so the click pipeline had nothing to open.
|
||||
//
|
||||
// The OSC 8 escape itself is emitted unconditionally by the renderer
|
||||
// (wrapWithOsc8Link in render-node-to-output.ts, oscLink in log-update.ts).
|
||||
// Terminals that don't understand OSC 8 silently strip it — including
|
||||
// Apple Terminal, which is why hover/click affordance has to come from
|
||||
// the in-process overlay (applyHyperlinkHoverHighlight) and not from the
|
||||
// terminal's own link rendering.
|
||||
const content = children ?? url
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<ink-link href={url}>{content}</ink-link>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
export type Props = {
|
||||
/**
|
||||
* Number of newlines to insert.
|
||||
*
|
||||
* @default 1
|
||||
*/
|
||||
readonly count?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one or more newline (\n) characters. Must be used within <Text> components.
|
||||
*/
|
||||
export default function Newline(t0: Props) {
|
||||
const $ = _c(4)
|
||||
|
||||
const { count: t1 } = t0
|
||||
|
||||
const count = t1 === undefined ? 1 : t1
|
||||
let t2
|
||||
|
||||
if ($[0] !== count) {
|
||||
t2 = '\n'.repeat(count)
|
||||
$[0] = count
|
||||
$[1] = t2
|
||||
} else {
|
||||
t2 = $[1]
|
||||
}
|
||||
|
||||
let t3
|
||||
|
||||
if ($[2] !== t2) {
|
||||
t3 = <ink-text>{t2}</ink-text>
|
||||
$[2] = t2
|
||||
$[3] = t3
|
||||
} else {
|
||||
t3 = $[3]
|
||||
}
|
||||
|
||||
return t3
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzIiwiY291bnQiLCJOZXdsaW5lIiwidDAiLCIkIiwiX2MiLCJ0MSIsInVuZGVmaW5lZCIsInQyIiwicmVwZWF0IiwidDMiXSwic291cmNlcyI6WyJOZXdsaW5lLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5cbmV4cG9ydCB0eXBlIFByb3BzID0ge1xuICAvKipcbiAgICogTnVtYmVyIG9mIG5ld2xpbmVzIHRvIGluc2VydC5cbiAgICpcbiAgICogQGRlZmF1bHQgMVxuICAgKi9cbiAgcmVhZG9ubHkgY291bnQ/OiBudW1iZXJcbn1cblxuLyoqXG4gKiBBZGRzIG9uZSBvciBtb3JlIG5ld2xpbmUgKFxcbikgY2hhcmFjdGVycy4gTXVzdCBiZSB1c2VkIHdpdGhpbiA8VGV4dD4gY29tcG9uZW50cy5cbiAqL1xuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gTmV3bGluZSh7IGNvdW50ID0gMSB9OiBQcm9wcykge1xuICByZXR1cm4gPGluay10ZXh0PnsnXFxuJy5yZXBlYXQoY291bnQpfTwvaW5rLXRleHQ+XG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUV6QixPQUFPLEtBQUtDLEtBQUssR0FBRztFQUNsQjtBQUNGO0FBQ0E7QUFDQTtBQUNBO0VBQ0UsU0FBU0MsS0FBSyxDQUFDLEVBQUUsTUFBTTtBQUN6QixDQUFDOztBQUVEO0FBQ0E7QUFDQTtBQUNBLGVBQWUsU0FBQUMsUUFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFpQjtJQUFBSixLQUFBLEVBQUFLO0VBQUEsSUFBQUgsRUFBb0I7RUFBbEIsTUFBQUYsS0FBQSxHQUFBSyxFQUFTLEtBQVRDLFNBQVMsR0FBVCxDQUFTLEdBQVRELEVBQVM7RUFBQSxJQUFBRSxFQUFBO0VBQUEsSUFBQUosQ0FBQSxRQUFBSCxLQUFBO0lBQ3ZCTyxFQUFBLE9BQUksQ0FBQUMsTUFBTyxDQUFDUixLQUFLLENBQUM7SUFBQUcsQ0FBQSxNQUFBSCxLQUFBO0lBQUFHLENBQUEsTUFBQUksRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUosQ0FBQTtFQUFBO0VBQUEsSUFBQU0sRUFBQTtFQUFBLElBQUFOLENBQUEsUUFBQUksRUFBQTtJQUE3QkUsRUFBQSxZQUF5QyxDQUE5QixDQUFBRixFQUFpQixDQUFFLEVBQTlCLFFBQXlDO0lBQUFKLENBQUEsTUFBQUksRUFBQTtJQUFBSixDQUFBLE1BQUFNLEVBQUE7RUFBQTtJQUFBQSxFQUFBLEdBQUFOLENBQUE7RUFBQTtFQUFBLE9BQXpDTSxFQUF5QztBQUFBIiwiaWdub3JlTGlzdCI6W119
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
|
||||
import Box, { type Props as BoxProps } from './Box.js'
|
||||
type Props = Omit<BoxProps, 'noSelect'> & {
|
||||
/**
|
||||
* Extend the exclusion zone from column 0 to this box's right edge,
|
||||
* for every row this box occupies. Use for gutters rendered inside a
|
||||
* wider indented container (e.g. a diff inside a tool message row):
|
||||
* without this, a multi-row drag picks up the container's leading
|
||||
* indent on rows below the prefix.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
fromLeftEdge?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks its contents as non-selectable in fullscreen text selection.
|
||||
* Cells inside this box are skipped by both the selection highlight and
|
||||
* the copied text — the gutter stays visually unchanged while the user
|
||||
* drags, making it clear what will be copied.
|
||||
*
|
||||
* Use to fence off gutters (line numbers, diff +/- sigils, list bullets)
|
||||
* so click-drag over rendered code yields clean pasteable content:
|
||||
*
|
||||
* <Box flexDirection="row">
|
||||
* <NoSelect fromLeftEdge><Text dimColor> 42 +</Text></NoSelect>
|
||||
* <Text>const x = 1</Text>
|
||||
* </Box>
|
||||
*
|
||||
* Only affects alt-screen text selection (<AlternateScreen> with mouse
|
||||
* tracking). No-op in the main-screen scrollback render where the
|
||||
* terminal's native selection is used instead.
|
||||
*/
|
||||
export function NoSelect(t0: Props) {
|
||||
const $ = _c(8)
|
||||
let boxProps
|
||||
let children
|
||||
let fromLeftEdge
|
||||
|
||||
if ($[0] !== t0) {
|
||||
;({ children, fromLeftEdge, ...boxProps } = t0)
|
||||
$[0] = t0
|
||||
$[1] = boxProps
|
||||
$[2] = children
|
||||
$[3] = fromLeftEdge
|
||||
} else {
|
||||
boxProps = $[1]
|
||||
children = $[2]
|
||||
fromLeftEdge = $[3]
|
||||
}
|
||||
|
||||
const t1 = fromLeftEdge ? 'from-left-edge' : true
|
||||
let t2
|
||||
|
||||
if ($[4] !== boxProps || $[5] !== children || $[6] !== t1) {
|
||||
t2 = (
|
||||
<Box {...boxProps} noSelect={t1}>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
$[4] = boxProps
|
||||
$[5] = children
|
||||
$[6] = t1
|
||||
$[7] = t2
|
||||
} else {
|
||||
t2 = $[7]
|
||||
}
|
||||
|
||||
return t2
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzV2l0aENoaWxkcmVuIiwiQm94IiwiUHJvcHMiLCJCb3hQcm9wcyIsIk9taXQiLCJmcm9tTGVmdEVkZ2UiLCJOb1NlbGVjdCIsInQwIiwiJCIsIl9jIiwiYm94UHJvcHMiLCJjaGlsZHJlbiIsInQxIiwidDIiXSwic291cmNlcyI6WyJOb1NlbGVjdC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0LCB7IHR5cGUgUHJvcHNXaXRoQ2hpbGRyZW4gfSBmcm9tICdyZWFjdCdcbmltcG9ydCBCb3gsIHsgdHlwZSBQcm9wcyBhcyBCb3hQcm9wcyB9IGZyb20gJy4vQm94LmpzJ1xuXG50eXBlIFByb3BzID0gT21pdDxCb3hQcm9wcywgJ25vU2VsZWN0Jz4gJiB7XG4gIC8qKlxuICAgKiBFeHRlbmQgdGhlIGV4Y2x1c2lvbiB6b25lIGZyb20gY29sdW1uIDAgdG8gdGhpcyBib3gncyByaWdodCBlZGdlLFxuICAgKiBmb3IgZXZlcnkgcm93IHRoaXMgYm94IG9jY3VwaWVzLiBVc2UgZm9yIGd1dHRlcnMgcmVuZGVyZWQgaW5zaWRlIGFcbiAgICogd2lkZXIgaW5kZW50ZWQgY29udGFpbmVyIChlLmcuIGEgZGlmZiBpbnNpZGUgYSB0b29sIG1lc3NhZ2Ugcm93KTpcbiAgICogd2l0aG91dCB0aGlzLCBhIG11bHRpLXJvdyBkcmFnIHBpY2tzIHVwIHRoZSBjb250YWluZXIncyBsZWFkaW5nXG4gICAqIGluZGVudCBvbiByb3dzIGJlbG93IHRoZSBwcmVmaXguXG4gICAqXG4gICAqIEBkZWZhdWx0IGZhbHNlXG4gICAqL1xuICBmcm9tTGVmdEVkZ2U/OiBib29sZWFuXG59XG5cbi8qKlxuICogTWFya3MgaXRzIGNvbnRlbnRzIGFzIG5vbi1zZWxlY3RhYmxlIGluIGZ1bGxzY3JlZW4gdGV4dCBzZWxlY3Rpb24uXG4gKiBDZWxscyBpbnNpZGUgdGhpcyBib3ggYXJlIHNraXBwZWQgYnkgYm90aCB0aGUgc2VsZWN0aW9uIGhpZ2hsaWdodCBhbmRcbiAqIHRoZSBjb3BpZWQgdGV4dCDigJQgdGhlIGd1dHRlciBzdGF5cyB2aXN1YWxseSB1bmNoYW5nZWQgd2hpbGUgdGhlIHVzZXJcbiAqIGRyYWdzLCBtYWtpbmcgaXQgY2xlYXIgd2hhdCB3aWxsIGJlIGNvcGllZC5cbiAqXG4gKiBVc2UgdG8gZmVuY2Ugb2ZmIGd1dHRlcnMgKGxpbmUgbnVtYmVycywgZGlmZiArLy0gc2lnaWxzLCBsaXN0IGJ1bGxldHMpXG4gKiBzbyBjbGljay1kcmFnIG92ZXIgcmVuZGVyZWQgY29kZSB5aWVsZHMgY2xlYW4gcGFzdGVhYmxlIGNvbnRlbnQ6XG4gKlxuICogICA8Qm94IGZsZXhEaXJlY3Rpb249XCJyb3dcIj5cbiAqICAgICA8Tm9TZWxlY3QgZnJvbUxlZnRFZGdlPjxUZXh0IGRpbUNvbG9yPiA0MiArPC9UZXh0PjwvTm9TZWxlY3Q+XG4gKiAgICAgPFRleHQ+Y29uc3QgeCA9IDE8L1RleHQ+XG4gKiAgIDwvQm94PlxuICpcbiAqIE9ubHkgYWZmZWN0cyBhbHQtc2NyZWVuIHRleHQgc2VsZWN0aW9uICg8QWx0ZXJuYXRlU2NyZWVuPiB3aXRoIG1vdXNlXG4gKiB0cmFja2luZykuIE5vLW9wIGluIHRoZSBtYWluLXNjcmVlbiBzY3JvbGxiYWNrIHJlbmRlciB3aGVyZSB0aGVcbiAqIHRlcm1pbmFsJ3MgbmF0aXZlIHNlbGVjdGlvbiBpcyB1c2VkIGluc3RlYWQuXG4gKi9cbmV4cG9ydCBmdW5jdGlvbiBOb1NlbGVjdCh7XG4gIGNoaWxkcmVuLFxuICBmcm9tTGVmdEVkZ2UsXG4gIC4uLmJveFByb3BzXG59OiBQcm9wc1dpdGhDaGlsZHJlbjxQcm9wcz4pOiBSZWFjdC5SZWFjdE5vZGUge1xuICByZXR1cm4gKFxuICAgIDxCb3ggey4uLmJveFByb3BzfSBub1NlbGVjdD17ZnJvbUxlZnRFZGdlID8gJ2Zyb20tbGVmdC1lZGdlJyA6IHRydWV9PlxuICAgICAge2NoaWxkcmVufVxuICAgIDwvQm94PlxuICApXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLElBQUksS0FBS0MsaUJBQWlCLFFBQVEsT0FBTztBQUNyRCxPQUFPQyxHQUFHLElBQUksS0FBS0MsS0FBSyxJQUFJQyxRQUFRLFFBQVEsVUFBVTtBQUV0RCxLQUFLRCxLQUFLLEdBQUdFLElBQUksQ0FBQ0QsUUFBUSxFQUFFLFVBQVUsQ0FBQyxHQUFHO0VBQ3hDO0FBQ0Y7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtFQUNFRSxZQUFZLENBQUMsRUFBRSxPQUFPO0FBQ3hCLENBQUM7O0FBRUQ7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0EsT0FBTyxTQUFBQyxTQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQUEsSUFBQUMsUUFBQTtFQUFBLElBQUFDLFFBQUE7RUFBQSxJQUFBTixZQUFBO0VBQUEsSUFBQUcsQ0FBQSxRQUFBRCxFQUFBO0lBQWtCO01BQUFJLFFBQUE7TUFBQU4sWUFBQTtNQUFBLEdBQUFLO0lBQUEsSUFBQUgsRUFJRTtJQUFBQyxDQUFBLE1BQUFELEVBQUE7SUFBQUMsQ0FBQSxNQUFBRSxRQUFBO0lBQUFGLENBQUEsTUFBQUcsUUFBQTtJQUFBSCxDQUFBLE1BQUFILFlBQUE7RUFBQTtJQUFBSyxRQUFBLEdBQUFGLENBQUE7SUFBQUcsUUFBQSxHQUFBSCxDQUFBO0lBQUFILFlBQUEsR0FBQUcsQ0FBQTtFQUFBO0VBRU0sTUFBQUksRUFBQSxHQUFBUCxZQUFZLEdBQVosZ0JBQXNDLEdBQXRDLElBQXNDO0VBQUEsSUFBQVEsRUFBQTtFQUFBLElBQUFMLENBQUEsUUFBQUUsUUFBQSxJQUFBRixDQUFBLFFBQUFHLFFBQUEsSUFBQUgsQ0FBQSxRQUFBSSxFQUFBO0lBQW5FQyxFQUFBLElBQUMsR0FBRyxLQUFLSCxRQUFRLEVBQVksUUFBc0MsQ0FBdEMsQ0FBQUUsRUFBcUMsQ0FBQyxDQUNoRUQsU0FBTyxDQUNWLEVBRkMsR0FBRyxDQUVFO0lBQUFILENBQUEsTUFBQUUsUUFBQTtJQUFBRixDQUFBLE1BQUFHLFFBQUE7SUFBQUgsQ0FBQSxNQUFBSSxFQUFBO0lBQUFKLENBQUEsTUFBQUssRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUwsQ0FBQTtFQUFBO0VBQUEsT0FGTkssRUFFTTtBQUFBIiwiaWdub3JlTGlzdCI6W119
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
type Props = {
|
||||
/**
|
||||
* Pre-rendered ANSI lines. Each element must be exactly one terminal row
|
||||
* (already wrapped to `width` by the producer) with ANSI escape codes inline.
|
||||
*/
|
||||
lines: string[]
|
||||
/** Column width the producer wrapped to. Sent to Yoga as the fixed leaf width. */
|
||||
width: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Bypass the <Ansi> → React tree → Yoga → squash → re-serialize roundtrip for
|
||||
* content that is already terminal-ready.
|
||||
*
|
||||
* Use this when an external renderer (e.g. the ColorDiff NAPI module) has
|
||||
* already produced ANSI-escaped, width-wrapped output. A normal <Ansi> mount
|
||||
* reparses that output into one React <Text> per style span, lays out each
|
||||
* span as a Yoga flex child, then walks the tree to re-emit the same escape
|
||||
* codes it was given. For a long transcript full of syntax-highlighted diffs
|
||||
* that roundtrip is the dominant cost of the render.
|
||||
*
|
||||
* This component emits a single Yoga leaf with a constant-time measure func
|
||||
* (width × lines.length) and hands the joined string straight to output.write(),
|
||||
* which already splits on '\n' and parses ANSI into the screen buffer.
|
||||
*/
|
||||
export function RawAnsi(t0: Props) {
|
||||
const $ = _c(6)
|
||||
|
||||
const { lines, width } = t0
|
||||
|
||||
if (lines.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let t1
|
||||
|
||||
if ($[0] !== lines) {
|
||||
t1 = lines.join('\n')
|
||||
$[0] = lines
|
||||
$[1] = t1
|
||||
} else {
|
||||
t1 = $[1]
|
||||
}
|
||||
|
||||
let t2
|
||||
|
||||
if ($[2] !== lines.length || $[3] !== t1 || $[4] !== width) {
|
||||
t2 = <ink-raw-ansi rawHeight={lines.length} rawText={t1} rawWidth={width} />
|
||||
$[2] = lines.length
|
||||
$[3] = t1
|
||||
$[4] = width
|
||||
$[5] = t2
|
||||
} else {
|
||||
t2 = $[5]
|
||||
}
|
||||
|
||||
return t2
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzIiwibGluZXMiLCJ3aWR0aCIsIlJhd0Fuc2kiLCJ0MCIsIiQiLCJfYyIsImxlbmd0aCIsInQxIiwiam9pbiIsInQyIl0sInNvdXJjZXMiOlsiUmF3QW5zaS50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0IGZyb20gJ3JlYWN0J1xuXG50eXBlIFByb3BzID0ge1xuICAvKipcbiAgICogUHJlLXJlbmRlcmVkIEFOU0kgbGluZXMuIEVhY2ggZWxlbWVudCBtdXN0IGJlIGV4YWN0bHkgb25lIHRlcm1pbmFsIHJvd1xuICAgKiAoYWxyZWFkeSB3cmFwcGVkIHRvIGB3aWR0aGAgYnkgdGhlIHByb2R1Y2VyKSB3aXRoIEFOU0kgZXNjYXBlIGNvZGVzIGlubGluZS5cbiAgICovXG4gIGxpbmVzOiBzdHJpbmdbXVxuICAvKiogQ29sdW1uIHdpZHRoIHRoZSBwcm9kdWNlciB3cmFwcGVkIHRvLiBTZW50IHRvIFlvZ2EgYXMgdGhlIGZpeGVkIGxlYWYgd2lkdGguICovXG4gIHdpZHRoOiBudW1iZXJcbn1cblxuLyoqXG4gKiBCeXBhc3MgdGhlIDxBbnNpPiDihpIgUmVhY3QgdHJlZSDihpIgWW9nYSDihpIgc3F1YXNoIOKGkiByZS1zZXJpYWxpemUgcm91bmR0cmlwIGZvclxuICogY29udGVudCB0aGF0IGlzIGFscmVhZHkgdGVybWluYWwtcmVhZHkuXG4gKlxuICogVXNlIHRoaXMgd2hlbiBhbiBleHRlcm5hbCByZW5kZXJlciAoZS5nLiB0aGUgQ29sb3JEaWZmIE5BUEkgbW9kdWxlKSBoYXNcbiAqIGFscmVhZHkgcHJvZHVjZWQgQU5TSS1lc2NhcGVkLCB3aWR0aC13cmFwcGVkIG91dHB1dC4gQSBub3JtYWwgPEFuc2k+IG1vdW50XG4gKiByZXBhcnNlcyB0aGF0IG91dHB1dCBpbnRvIG9uZSBSZWFjdCA8VGV4dD4gcGVyIHN0eWxlIHNwYW4sIGxheXMgb3V0IGVhY2hcbiAqIHNwYW4gYXMgYSBZb2dhIGZsZXggY2hpbGQsIHRoZW4gd2Fsa3MgdGhlIHRyZWUgdG8gcmUtZW1pdCB0aGUgc2FtZSBlc2NhcGVcbiAqIGNvZGVzIGl0IHdhcyBnaXZlbi4gRm9yIGEgbG9uZyB0cmFuc2NyaXB0IGZ1bGwgb2Ygc3ludGF4LWhpZ2hsaWdodGVkIGRpZmZzXG4gKiB0aGF0IHJvdW5kdHJpcCBpcyB0aGUgZG9taW5hbnQgY29zdCBvZiB0aGUgcmVuZGVyLlxuICpcbiAqIFRoaXMgY29tcG9uZW50IGVtaXRzIGEgc2luZ2xlIFlvZ2EgbGVhZiB3aXRoIGEgY29uc3RhbnQtdGltZSBtZWFzdXJlIGZ1bmNcbiAqICh3aWR0aCDDlyBsaW5lcy5sZW5ndGgpIGFuZCBoYW5kcyB0aGUgam9pbmVkIHN0cmluZyBzdHJhaWdodCB0byBvdXRwdXQud3JpdGUoKSxcbiAqIHdoaWNoIGFscmVhZHkgc3BsaXRzIG9uICdcXG4nIGFuZCBwYXJzZXMgQU5TSSBpbnRvIHRoZSBzY3JlZW4gYnVmZmVyLlxuICovXG5leHBvcnQgZnVuY3Rpb24gUmF3QW5zaSh7IGxpbmVzLCB3aWR0aCB9OiBQcm9wcyk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIGlmIChsaW5lcy5sZW5ndGggPT09IDApIHtcbiAgICByZXR1cm4gbnVsbFxuICB9XG4gIHJldHVybiAoXG4gICAgPGluay1yYXctYW5zaVxuICAgICAgcmF3VGV4dD17bGluZXMuam9pbignXFxuJyl9XG4gICAgICByYXdXaWR0aD17d2lkdGh9XG4gICAgICByYXdIZWlnaHQ9e2xpbmVzLmxlbmd0aH1cbiAgICAvPlxuICApXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUV6QixLQUFLQyxLQUFLLEdBQUc7RUFDWDtBQUNGO0FBQ0E7QUFDQTtFQUNFQyxLQUFLLEVBQUUsTUFBTSxFQUFFO0VBQ2Y7RUFDQUMsS0FBSyxFQUFFLE1BQU07QUFDZixDQUFDOztBQUVEO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBLE9BQU8sU0FBQUMsUUFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFpQjtJQUFBTCxLQUFBO0lBQUFDO0VBQUEsSUFBQUUsRUFBdUI7RUFDN0MsSUFBSUgsS0FBSyxDQUFBTSxNQUFPLEtBQUssQ0FBQztJQUFBLE9BQ2IsSUFBSTtFQUFBO0VBQ1osSUFBQUMsRUFBQTtFQUFBLElBQUFILENBQUEsUUFBQUosS0FBQTtJQUdZTyxFQUFBLEdBQUFQLEtBQUssQ0FBQVEsSUFBSyxDQUFDLElBQUksQ0FBQztJQUFBSixDQUFBLE1BQUFKLEtBQUE7SUFBQUksQ0FBQSxNQUFBRyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBSCxDQUFBO0VBQUE7RUFBQSxJQUFBSyxFQUFBO0VBQUEsSUFBQUwsQ0FBQSxRQUFBSixLQUFBLENBQUFNLE1BQUEsSUFBQUYsQ0FBQSxRQUFBRyxFQUFBLElBQUFILENBQUEsUUFBQUgsS0FBQTtJQUQzQlEsRUFBQSxnQkFJRSxDQUhTLE9BQWdCLENBQWhCLENBQUFGLEVBQWUsQ0FBQyxDQUNmTixRQUFLLENBQUxBLE1BQUksQ0FBQyxDQUNKLFNBQVksQ0FBWixDQUFBRCxLQUFLLENBQUFNLE1BQU0sQ0FBQyxHQUN2QjtJQUFBRixDQUFBLE1BQUFKLEtBQUEsQ0FBQU0sTUFBQTtJQUFBRixDQUFBLE1BQUFHLEVBQUE7SUFBQUgsQ0FBQSxNQUFBSCxLQUFBO0lBQUFHLENBQUEsTUFBQUssRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUwsQ0FBQTtFQUFBO0VBQUEsT0FKRkssRUFJRTtBQUFBIiwiaWdub3JlTGlzdCI6W119
|
||||
@@ -0,0 +1,364 @@
|
||||
import '../global.d.ts'
|
||||
|
||||
import React, { type PropsWithChildren, type Ref, useImperativeHandle, useRef, 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 Box from './Box.js'
|
||||
|
||||
const MAX_SCROLL_GEOMETRY = 1_000_000_000
|
||||
|
||||
const validUnsignedGeometry = (value: number): boolean =>
|
||||
Number.isFinite(value) && value >= 0 && value <= MAX_SCROLL_GEOMETRY
|
||||
|
||||
const validClampMaximum = (value: number): boolean => value === Number.POSITIVE_INFINITY || validUnsignedGeometry(value)
|
||||
|
||||
const validSignedGeometry = (value: number): boolean => Number.isFinite(value) && Math.abs(value) <= MAX_SCROLL_GEOMETRY
|
||||
|
||||
const safeUnsignedGeometry = (value: number | undefined): number =>
|
||||
value !== undefined && validUnsignedGeometry(value) ? value : 0
|
||||
|
||||
const safeSignedGeometry = (value: number | undefined): number =>
|
||||
value !== undefined && validSignedGeometry(value) ? value : 0
|
||||
|
||||
export type ScrollBoxHandle = {
|
||||
scrollTo: (y: number) => void
|
||||
scrollBy: (dy: number) => void
|
||||
/**
|
||||
* Offset the committed viewport after content above it changes height.
|
||||
* Unlike scrollTo, this preserves pending input, sticky state, anchor seeks,
|
||||
* and the manual-scroll timestamp.
|
||||
*/
|
||||
adjustScrollTop: (dy: number) => void
|
||||
/**
|
||||
* Scroll so `el`'s top is at the viewport top (plus `offset`). Unlike
|
||||
* scrollTo which bakes a number that's stale by the time the throttled
|
||||
* render fires, this defers the position read to render time —
|
||||
* render-node-to-output reads `el.yogaNode.getComputedTop()` in the
|
||||
* SAME Yoga pass that computes scrollHeight. Deterministic. One-shot.
|
||||
*/
|
||||
scrollToElement: (el: DOMElement, offset?: number) => void
|
||||
scrollToBottom: () => void
|
||||
getScrollTop: () => number
|
||||
getPendingDelta: () => number
|
||||
getScrollHeight: () => number
|
||||
/**
|
||||
* Like getScrollHeight, but reads Yoga directly instead of the cached
|
||||
* value written by render-node-to-output (throttled, up to 16ms stale).
|
||||
* Use when you need a fresh value in useLayoutEffect after a React commit
|
||||
* that grew content. Slightly more expensive (native Yoga call).
|
||||
*/
|
||||
getFreshScrollHeight: () => number
|
||||
getViewportHeight: () => number
|
||||
/**
|
||||
* Absolute screen-buffer row of the first visible content line (inside
|
||||
* padding). Used for drag-to-scroll edge detection.
|
||||
*/
|
||||
getViewportTop: () => number
|
||||
getLastManualScrollAt: () => number
|
||||
/**
|
||||
* True when scroll is pinned to the bottom. Set by scrollToBottom, the
|
||||
* initial stickyScroll attribute, and by the renderer when positional
|
||||
* follow fires (scrollTop at prevMax, content grows). Cleared by
|
||||
* scrollTo/scrollBy. Stable signal for "at bottom" that doesn't depend on
|
||||
* layout values (unlike scrollTop+viewportH >= scrollHeight).
|
||||
*/
|
||||
isSticky: () => boolean
|
||||
/**
|
||||
* Subscribe to scroll viewport changes. Fires for imperative scroll changes
|
||||
* (scrollTo/scrollBy/adjustScrollTop/scrollToBottom) and for renderer-computed
|
||||
* scroll bounds changes such as content growth or terminal resize. Callers
|
||||
* use this to keep virtualized ranges aligned with the visible viewport.
|
||||
*/
|
||||
subscribe: (listener: () => void) => () => void
|
||||
/**
|
||||
* Set the render-time scrollTop clamp to the currently-mounted children's
|
||||
* coverage span. Called by useVirtualScroll after computing its range;
|
||||
* render-node-to-output clamps scrollTop to [min, max] so burst scrollTo
|
||||
* calls that race past React's async re-render show the edge of mounted
|
||||
* content instead of blank spacer. Pass undefined to disable (sticky,
|
||||
* cold start).
|
||||
*/
|
||||
setClampBounds: (min: number | undefined, max: number | undefined) => void
|
||||
}
|
||||
export type ScrollBoxProps = Except<Styles, 'textWrap' | 'overflow' | 'overflowX' | 'overflowY'> & {
|
||||
ref?: Ref<ScrollBoxHandle>
|
||||
/**
|
||||
* When true, automatically pins scroll position to the bottom when content
|
||||
* grows. Unset manually via scrollTo/scrollBy to break the stickiness.
|
||||
*/
|
||||
stickyScroll?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A Box with `overflow: scroll` and an imperative scroll API.
|
||||
*
|
||||
* Children are laid out at their full Yoga-computed height inside a
|
||||
* constrained container. At render time, only children intersecting the
|
||||
* visible window (scrollTop..scrollTop+height) are rendered (viewport
|
||||
* culling). Content is translated by -scrollTop and clipped to the box bounds.
|
||||
*
|
||||
* Works best inside a fullscreen (constrained-height root) Ink tree.
|
||||
*/
|
||||
function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<ScrollBoxProps>): React.ReactNode {
|
||||
const domRef = useRef<DOMElement>(null)
|
||||
// Imperative position changes bypass React: they mutate scrollTop on the DOM node,
|
||||
// mark it dirty, and call the root's throttled scheduleRender directly.
|
||||
// The Ink renderer reads scrollTop from the node — no React state needed,
|
||||
// no reconciler overhead per wheel event. The microtask defer coalesces
|
||||
// multiple scrollBy calls in one input batch (discreteUpdates) into one
|
||||
// render — otherwise scheduleRender's leading edge fires on the FIRST
|
||||
// event before subsequent events mutate scrollTop. scrollToBottom still
|
||||
// forces a React render: sticky is attribute-observed, no DOM-only path.
|
||||
const [, forceRender] = useState(0)
|
||||
const listenersRef = useRef(new Set<() => void>())
|
||||
const manualScrollAtRef = useRef(0)
|
||||
const renderQueuedRef = useRef(false)
|
||||
|
||||
const notify = () => {
|
||||
for (const l of listenersRef.current) {
|
||||
l()
|
||||
}
|
||||
}
|
||||
|
||||
function scrollMutated(el: DOMElement): void {
|
||||
// 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()
|
||||
markDirty(el)
|
||||
markCommitStart()
|
||||
notify()
|
||||
|
||||
if (renderQueuedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
renderQueuedRef.current = true
|
||||
queueMicrotask(() => {
|
||||
renderQueuedRef.current = false
|
||||
scheduleRenderFrom(el)
|
||||
})
|
||||
}
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): ScrollBoxHandle => ({
|
||||
adjustScrollTop(dy: number) {
|
||||
const el = domRef.current
|
||||
|
||||
if (!el || !validSignedGeometry(dy)) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = safeUnsignedGeometry(el.scrollTop)
|
||||
const next = Math.max(0, current + Math.floor(dy))
|
||||
const compensation = safeSignedGeometry(el.scrollTopCompensation) + (next - current)
|
||||
|
||||
if (next === current || !validUnsignedGeometry(next) || !validSignedGeometry(compensation)) {
|
||||
return
|
||||
}
|
||||
|
||||
el.scrollTop = next
|
||||
el.scrollTopCompensation = compensation
|
||||
scrollMutated(el)
|
||||
},
|
||||
scrollTo(y: number) {
|
||||
const el = domRef.current
|
||||
|
||||
if (!el || !validSignedGeometry(y)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Explicit false overrides the DOM attribute so manual scroll
|
||||
// breaks stickiness. Render code checks ?? precedence.
|
||||
el.stickyScroll = false
|
||||
manualScrollAtRef.current = Date.now()
|
||||
el.pendingScrollDelta = undefined
|
||||
el.scrollTopCompensation = undefined
|
||||
el.scrollAnchor = undefined
|
||||
el.scrollTop = Math.max(0, Math.floor(y))
|
||||
scrollMutated(el)
|
||||
},
|
||||
scrollToElement(el: DOMElement, offset = 0) {
|
||||
const box = domRef.current
|
||||
|
||||
if (!box || !validSignedGeometry(offset)) {
|
||||
return
|
||||
}
|
||||
|
||||
box.stickyScroll = false
|
||||
manualScrollAtRef.current = Date.now()
|
||||
box.pendingScrollDelta = undefined
|
||||
box.scrollTopCompensation = undefined
|
||||
box.scrollAnchor = {
|
||||
el,
|
||||
offset
|
||||
}
|
||||
scrollMutated(box)
|
||||
},
|
||||
scrollBy(dy: number) {
|
||||
const el = domRef.current
|
||||
|
||||
if (!el || !validSignedGeometry(dy)) {
|
||||
return
|
||||
}
|
||||
|
||||
const pending = safeSignedGeometry(el.pendingScrollDelta) + Math.floor(dy)
|
||||
|
||||
if (!validSignedGeometry(pending)) {
|
||||
return
|
||||
}
|
||||
|
||||
el.stickyScroll = false
|
||||
manualScrollAtRef.current = Date.now()
|
||||
el.scrollAnchor = undefined
|
||||
el.pendingScrollDelta = pending
|
||||
scrollMutated(el)
|
||||
},
|
||||
scrollToBottom() {
|
||||
const el = domRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
el.pendingScrollDelta = undefined
|
||||
el.scrollTopCompensation = undefined
|
||||
el.stickyScroll = true
|
||||
markDirty(el)
|
||||
notify()
|
||||
forceRender(n => n + 1)
|
||||
},
|
||||
getScrollTop() {
|
||||
return safeUnsignedGeometry(domRef.current?.scrollTop)
|
||||
},
|
||||
getPendingDelta() {
|
||||
// Accumulated-but-not-yet-drained delta. useVirtualScroll needs
|
||||
// this to mount the union [committed, committed+pending] range —
|
||||
// otherwise intermediate drain frames find no children (blank).
|
||||
return safeSignedGeometry(domRef.current?.pendingScrollDelta)
|
||||
},
|
||||
getScrollHeight() {
|
||||
return safeUnsignedGeometry(domRef.current?.scrollHeight)
|
||||
},
|
||||
getFreshScrollHeight() {
|
||||
const content = domRef.current?.childNodes[0] as DOMElement | undefined
|
||||
|
||||
const height = content?.yogaNode?.getComputedHeight()
|
||||
|
||||
return validUnsignedGeometry(height ?? Number.NaN)
|
||||
? height!
|
||||
: safeUnsignedGeometry(domRef.current?.scrollHeight)
|
||||
},
|
||||
getViewportHeight() {
|
||||
return safeUnsignedGeometry(domRef.current?.scrollViewportHeight)
|
||||
},
|
||||
getViewportTop() {
|
||||
return domRef.current?.scrollViewportTop ?? 0
|
||||
},
|
||||
getLastManualScrollAt() {
|
||||
return manualScrollAtRef.current
|
||||
},
|
||||
isSticky() {
|
||||
const el = domRef.current
|
||||
|
||||
if (!el) {
|
||||
return false
|
||||
}
|
||||
|
||||
return el.stickyScroll ?? Boolean(el.attributes['stickyScroll'])
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
listenersRef.current.add(listener)
|
||||
|
||||
return () => listenersRef.current.delete(listener)
|
||||
},
|
||||
setClampBounds(min, max) {
|
||||
const el = domRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
if (min === undefined && max === undefined) {
|
||||
el.scrollClampMin = undefined
|
||||
el.scrollClampMax = undefined
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
min === undefined ||
|
||||
max === undefined ||
|
||||
!validUnsignedGeometry(min) ||
|
||||
!validClampMaximum(max) ||
|
||||
min > max
|
||||
) {
|
||||
el.scrollClampMin = undefined
|
||||
el.scrollClampMax = undefined
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
el.scrollClampMin = min
|
||||
el.scrollClampMax = max
|
||||
}
|
||||
}),
|
||||
// notify/scrollMutated are inline (no useCallback) but only close over
|
||||
// refs + imports — stable. Empty deps avoids rebuilding the handle on
|
||||
// every render (which re-registers the ref = churn).
|
||||
|
||||
[]
|
||||
)
|
||||
|
||||
// Structure: outer viewport (overflow:scroll, constrained height) >
|
||||
// inner content (flexGrow:1, flexShrink:0 — fills at least the viewport
|
||||
// but grows beyond it for tall content). flexGrow:1 lets children use
|
||||
// spacers to pin elements to the bottom of the scroll area. Yoga's
|
||||
// Overflow.Scroll prevents the viewport from growing to fit the content.
|
||||
// The renderer computes scrollHeight from the content box and culls
|
||||
// content's children based on scrollTop.
|
||||
//
|
||||
// stickyScroll is passed as a DOM attribute (via ink-box directly) so it's
|
||||
// available on the first render — ref callbacks fire after the initial
|
||||
// commit, which is too late for the first frame.
|
||||
return (
|
||||
<ink-box
|
||||
ref={(el: DOMElement | null) => {
|
||||
domRef.current = el
|
||||
|
||||
if (el) {
|
||||
el.scrollTop = safeUnsignedGeometry(el.scrollTop)
|
||||
el.notifyScrollChange = notify
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
flexWrap: 'nowrap',
|
||||
flexDirection: style.flexDirection ?? 'row',
|
||||
flexGrow: style.flexGrow ?? 0,
|
||||
flexShrink: style.flexShrink ?? 1,
|
||||
...style,
|
||||
overflowX: 'scroll',
|
||||
overflowY: 'scroll'
|
||||
}}
|
||||
{...(stickyScroll
|
||||
? {
|
||||
stickyScroll: true
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<Box flexDirection="column" flexGrow={1} flexShrink={0} width="100%">
|
||||
{children}
|
||||
</Box>
|
||||
</ink-box>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScrollBox
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzV2l0aENoaWxkcmVuIiwiUmVmIiwidXNlSW1wZXJhdGl2ZUhhbmRsZSIsInVzZVJlZiIsInVzZVN0YXRlIiwiRXhjZXB0IiwibWFya1Njcm9sbEFjdGl2aXR5IiwiRE9NRWxlbWVudCIsIm1hcmtEaXJ0eSIsInNjaGVkdWxlUmVuZGVyRnJvbSIsIm1hcmtDb21taXRTdGFydCIsIlN0eWxlcyIsIkJveCIsIlNjcm9sbEJveEhhbmRsZSIsInNjcm9sbFRvIiwieSIsInNjcm9sbEJ5IiwiZHkiLCJzY3JvbGxUb0VsZW1lbnQiLCJlbCIsIm9mZnNldCIsInNjcm9sbFRvQm90dG9tIiwiZ2V0U2Nyb2xsVG9wIiwiZ2V0UGVuZGluZ0RlbHRhIiwiZ2V0U2Nyb2xsSGVpZ2h0IiwiZ2V0RnJlc2hTY3JvbGxIZWlnaHQiLCJnZXRWaWV3cG9ydEhlaWdodCIsImdldFZpZXdwb3J0VG9wIiwiaXNTdGlja3kiLCJzdWJzY3JpYmUiLCJsaXN0ZW5lciIsInNldENsYW1wQm91bmRzIiwibWluIiwibWF4IiwiU2Nyb2xsQm94UHJvcHMiLCJyZWYiLCJzdGlja3lTY3JvbGwiLCJTY3JvbGxCb3giLCJjaGlsZHJlbiIsInN0eWxlIiwiUmVhY3ROb2RlIiwiZG9tUmVmIiwiZm9yY2VSZW5kZXIiLCJsaXN0ZW5lcnNSZWYiLCJTZXQiLCJyZW5kZXJRdWV1ZWRSZWYiLCJub3RpZnkiLCJsIiwiY3VycmVudCIsInNjcm9sbE11dGF0ZWQiLCJxdWV1ZU1pY3JvdGFzayIsInBlbmRpbmdTY3JvbGxEZWx0YSIsInVuZGVmaW5lZCIsInNjcm9sbEFuY2hvciIsInNjcm9sbFRvcCIsIk1hdGgiLCJmbG9vciIsImJveCIsIm4iLCJzY3JvbGxIZWlnaHQiLCJjb250ZW50IiwiY2hpbGROb2RlcyIsInlvZ2FOb2RlIiwiZ2V0Q29tcHV0ZWRIZWlnaHQiLCJzY3JvbGxWaWV3cG9ydEhlaWdodCIsInNjcm9sbFZpZXdwb3J0VG9wIiwiQm9vbGVhbiIsImF0dHJpYnV0ZXMiLCJhZGQiLCJkZWxldGUiLCJzY3JvbGxDbGFtcE1pbiIsInNjcm9sbENsYW1wTWF4IiwiZmxleFdyYXAiLCJmbGV4RGlyZWN0aW9uIiwiZmxleEdyb3ciLCJmbGV4U2hyaW5rIiwib3ZlcmZsb3dYIiwib3ZlcmZsb3dZIl0sInNvdXJjZXMiOlsiU2Nyb2xsQm94LnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QsIHtcbiAgdHlwZSBQcm9wc1dpdGhDaGlsZHJlbixcbiAgdHlwZSBSZWYsXG4gIHVzZUltcGVyYXRpdmVIYW5kbGUsXG4gIHVzZVJlZixcbiAgdXNlU3RhdGUsXG59IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHR5cGUgeyBFeGNlcHQgfSBmcm9tICd0eXBlLWZlc3QnXG5pbXBvcnQgeyBtYXJrU2Nyb2xsQWN0aXZpdHkgfSBmcm9tICcuLi8uLi9ib290c3RyYXAvc3RhdGUuanMnXG5pbXBvcnQgdHlwZSB7IERPTUVsZW1lbnQgfSBmcm9tICcuLi9kb20uanMnXG5pbXBvcnQgeyBtYXJrRGlydHksIHNjaGVkdWxlUmVuZGVyRnJvbSB9IGZyb20gJy4uL2RvbS5qcydcbmltcG9ydCB7IG1hcmtDb21taXRTdGFydCB9IGZyb20gJy4uL3JlY29uY2lsZXIuanMnXG5pbXBvcnQgdHlwZSB7IFN0eWxlcyB9IGZyb20gJy4uL3N0eWxlcy5qcydcbmltcG9ydCAnLi4vZ2xvYmFsLmQudHMnXG5pbXBvcnQgQm94IGZyb20gJy4vQm94LmpzJ1xuXG5leHBvcnQgdHlwZSBTY3JvbGxCb3hIYW5kbGUgPSB7XG4gIHNjcm9sbFRvOiAoeTogbnVtYmVyKSA9PiB2b2lkXG4gIHNjcm9sbEJ5OiAoZHk6IG51bWJlcikgPT4gdm9pZFxuICAvKipcbiAgICogU2Nyb2xsIHNvIGBlbGAncyB0b3AgaXMgYXQgdGhlIHZpZXdwb3J0IHRvcCAocGx1cyBgb2Zmc2V0YCkuIFVubGlrZVxuICAgKiBzY3JvbGxUbyB3aGljaCBiYWtlcyBhIG51bWJlciB0aGF0J3Mgc3RhbGUgYnkgdGhlIHRpbWUgdGhlIHRocm90dGxlZFxuICAgKiByZW5kZXIgZmlyZXMsIHRoaXMgZGVmZXJzIHRoZSBwb3NpdGlvbiByZWFkIHRvIHJlbmRlciB0aW1lIOKAlFxuICAgKiByZW5kZXItbm9kZS10by1vdXRwdXQgcmVhZHMgYGVsLnlvZ2FOb2RlLmdldENvbXB1dGVkVG9wKClgIGluIHRoZVxuICAgKiBTQU1FIFlvZ2EgcGFzcyB0aGF0IGNvbXB1dGVzIHNjcm9sbEhlaWdodC4gRGV0ZXJtaW5pc3RpYy4gT25lLXNob3QuXG4gICAqL1xuICBzY3JvbGxUb0VsZW1lbnQ6IChlbDogRE9NRWxlbWVudCwgb2Zmc2V0PzogbnVtYmVyKSA9PiB2b2lkXG4gIHNjcm9sbFRvQm90dG9tOiAoKSA9PiB2b2lkXG4gIGdldFNjcm9sbFRvcDogKCkgPT4gbnVtYmVyXG4gIGdldFBlbmRpbmdEZWx0YTogKCkgPT4gbnVtYmVyXG4gIGdldFNjcm9sbEhlaWdodDogKCkgPT4gbnVtYmVyXG4gIC8qKlxuICAgKiBMaWtlIGdldFNjcm9sbEhlaWdodCwgYnV0IHJlYWRzIFlvZ2EgZGlyZWN0bHkgaW5zdGVhZCBvZiB0aGUgY2FjaGVkXG4gICAqIHZhbHVlIHdyaXR0ZW4gYnkgcmVuZGVyLW5vZGUtdG8tb3V0cHV0ICh0aHJvdHRsZWQsIHVwIHRvIDE2bXMgc3RhbGUpLlxuICAgKiBVc2Ugd2hlbiB5b3UgbmVlZCBhIGZyZXNoIHZhbHVlIGluIHVzZUxheW91dEVmZmVjdCBhZnRlciBhIFJlYWN0IGNvbW1pdFxuICAgKiB0aGF0IGdyZXcgY29udGVudC4gU2xpZ2h0bHkgbW9yZSBleHBlbnNpdmUgKG5hdGl2ZSBZb2dhIGNhbGwpLlxuICAgKi9cbiAgZ2V0RnJlc2hTY3JvbGxIZWlnaHQ6ICgpID0+IG51bWJlclxuICBnZXRWaWV3cG9ydEhlaWdodDogKCkgPT4gbnVtYmVyXG4gIC8qKlxuICAgKiBBYnNvbHV0ZSBzY3JlZW4tYnVmZmVyIHJvdyBvZiB0aGUgZmlyc3QgdmlzaWJsZSBjb250ZW50IGxpbmUgKGluc2lkZVxuICAgKiBwYWRkaW5nKS4gVXNlZCBmb3IgZHJhZy10by1zY3JvbGwgZWRnZSBkZXRlY3Rpb24uXG4gICAqL1xuICBnZXRWaWV3cG9ydFRvcDogKCkgPT4gbnVtYmVyXG4gIC8qKlxuICAgKiBUcnVlIHdoZW4gc2Nyb2xsIGlzIHBpbm5lZCB0byB0aGUgYm90dG9tLiBTZXQgYnkgc2Nyb2xsVG9Cb3R0b20sIHRoZVxuICAgKiBpbml0aWFsIHN0aWNreVNjcm9sbCBhdHRyaWJ1dGUsIGFuZCBieSB0aGUgcmVuZGVyZXIgd2hlbiBwb3NpdGlvbmFsXG4gICAqIGZvbGxvdyBmaXJlcyAoc2Nyb2xsVG9wIGF0IHByZXZNYXgsIGNvbnRlbnQgZ3Jvd3MpLiBDbGVhcmVkIGJ5XG4gICAqIHNjcm9sbFRvL3Njcm9sbEJ5LiBTdGFibGUgc2lnbmFsIGZvciBcImF0IGJvdHRvbVwiIHRoYXQgZG9lc24ndCBkZXBlbmQgb25cbiAgICogbGF5b3V0IHZhbHVlcyAodW5saWtlIHNjcm9sbFRvcCt2aWV3cG9ydEggPj0gc2Nyb2xsSGVpZ2h0KS5cbiAgICovXG4gIGlzU3RpY2t5OiAoKSA9PiBib29sZWFuXG4gIC8qKlxuICAgKiBTdWJzY3JpYmUgdG8gaW1wZXJhdGl2ZSBzY3JvbGwgY2hhbmdlcyAoc2Nyb2xsVG8vc2Nyb2xsQnkvc2Nyb2xsVG9Cb3R0b20pLlxuICAgKiBEb2VzIE5PVCBmaXJlIGZvciBzdGlja3lTY3JvbGwgdXBkYXRlcyBkb25lIGJ5IHRoZSBJbmsgcmVuZGVyZXIg4oCUIHRob3NlXG4gICAqIGhhcHBlbiBkdXJpbmcgSW5rJ3MgcmVuZGVyIHBoYXNlIGFmdGVyIFJlYWN0IGhhcyBjb21taXR0ZWQuIENhbGxlcnMgdGhhdFxuICAgKiBjYXJlIGFib3V0IHRoZSBzdGlja3kgY2FzZSBzaG91bGQgdHJlYXQgXCJhdCBib3R0b21cIiBhcyBhIGZhbGxiYWNrLlxuICAgKi9cbiAgc3Vic2NyaWJlOiAobGlzdGVuZXI6ICgpID0+IHZvaWQpID0+ICgpID0+IHZvaWRcbiAgLyoqXG4gICAqIFNldCB0aGUgcmVuZGVyLXRpbWUgc2Nyb2xsVG9wIGNsYW1wIHRvIHRoZSBjdXJyZW50bHktbW91bnRlZCBjaGlsZHJlbidzXG4gICAqIGNvdmVyYWdlIHNwYW4uIENhbGxlZCBieSB1c2VWaXJ0dWFsU2Nyb2xsIGFmdGVyIGN
Line truncated
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
|
||||
import Box from './Box.js'
|
||||
|
||||
/**
|
||||
* A flexible space that expands along the major axis of its containing layout.
|
||||
* It's useful as a shortcut for filling all the available spaces between elements.
|
||||
*/
|
||||
export default function Spacer() {
|
||||
const $ = _c(1)
|
||||
let t0
|
||||
|
||||
if ($[0] === Symbol.for('react.memo_cache_sentinel')) {
|
||||
t0 = <Box flexGrow={1} />
|
||||
$[0] = t0
|
||||
} else {
|
||||
t0 = $[0]
|
||||
}
|
||||
|
||||
return t0
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkJveCIsIlNwYWNlciIsIiQiLCJfYyIsInQwIiwiU3ltYm9sIiwiZm9yIl0sInNvdXJjZXMiOlsiU3BhY2VyLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgQm94IGZyb20gJy4vQm94LmpzJ1xuXG4vKipcbiAqIEEgZmxleGlibGUgc3BhY2UgdGhhdCBleHBhbmRzIGFsb25nIHRoZSBtYWpvciBheGlzIG9mIGl0cyBjb250YWluaW5nIGxheW91dC5cbiAqIEl0J3MgdXNlZnVsIGFzIGEgc2hvcnRjdXQgZm9yIGZpbGxpbmcgYWxsIHRoZSBhdmFpbGFibGUgc3BhY2VzIGJldHdlZW4gZWxlbWVudHMuXG4gKi9cbmV4cG9ydCBkZWZhdWx0IGZ1bmN0aW9uIFNwYWNlcigpIHtcbiAgcmV0dXJuIDxCb3ggZmxleEdyb3c9ezF9IC8+XG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUN6QixPQUFPQyxHQUFHLE1BQU0sVUFBVTs7QUFFMUI7QUFDQTtBQUNBO0FBQ0E7QUFDQSxlQUFlLFNBQUFDLE9BQUE7RUFBQSxNQUFBQyxDQUFBLEdBQUFDLEVBQUE7RUFBQSxJQUFBQyxFQUFBO0VBQUEsSUFBQUYsQ0FBQSxRQUFBRyxNQUFBLENBQUFDLEdBQUE7SUFDTkYsRUFBQSxJQUFDLEdBQUcsQ0FBVyxRQUFDLENBQUQsR0FBQyxHQUFJO0lBQUFGLENBQUEsTUFBQUUsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUYsQ0FBQTtFQUFBO0VBQUEsT0FBcEJFLEVBQW9CO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
import { EventEmitter } from '../events/emitter.js'
|
||||
import type { TerminalQuerier } from '../terminal-querier.js'
|
||||
|
||||
export type Props = {
|
||||
readonly stdin: NodeJS.ReadStream
|
||||
readonly setRawMode: (value: boolean) => void
|
||||
readonly isRawModeSupported: boolean
|
||||
readonly exitOnCtrlC: boolean
|
||||
readonly inputEmitter: EventEmitter
|
||||
readonly querier: TerminalQuerier | null
|
||||
}
|
||||
|
||||
const StdinContext = createContext<Props>({
|
||||
stdin: process.stdin,
|
||||
inputEmitter: new EventEmitter(),
|
||||
setRawMode() {},
|
||||
isRawModeSupported: false,
|
||||
exitOnCtrlC: true,
|
||||
querier: null
|
||||
})
|
||||
|
||||
StdinContext.displayName = 'StdinContext'
|
||||
export default StdinContext
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { createContext, type ReactNode, useSyncExternalStore } from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
|
||||
import {
|
||||
getTerminalFocused,
|
||||
getTerminalFocusState,
|
||||
subscribeTerminalFocus,
|
||||
type TerminalFocusState
|
||||
} from '../terminal-focus-state.js'
|
||||
export type { TerminalFocusState }
|
||||
export type TerminalFocusContextProps = {
|
||||
readonly isTerminalFocused: boolean
|
||||
readonly terminalFocusState: TerminalFocusState
|
||||
}
|
||||
|
||||
const TerminalFocusContext = createContext<TerminalFocusContextProps>({
|
||||
isTerminalFocused: true,
|
||||
terminalFocusState: 'unknown'
|
||||
})
|
||||
|
||||
TerminalFocusContext.displayName = 'TerminalFocusContext'
|
||||
|
||||
// Separate component so App.tsx doesn't re-render on focus changes.
|
||||
// Children are a stable prop reference, so they don't re-render either —
|
||||
// only components that consume the context will re-render.
|
||||
export function TerminalFocusProvider(t0: { readonly children: ReactNode }) {
|
||||
const $ = _c(6)
|
||||
|
||||
const { children } = t0
|
||||
|
||||
const isTerminalFocused = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocused)
|
||||
const terminalFocusState = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocusState)
|
||||
let t1
|
||||
|
||||
if ($[0] !== isTerminalFocused || $[1] !== terminalFocusState) {
|
||||
t1 = {
|
||||
isTerminalFocused,
|
||||
terminalFocusState
|
||||
}
|
||||
$[0] = isTerminalFocused
|
||||
$[1] = terminalFocusState
|
||||
$[2] = t1
|
||||
} else {
|
||||
t1 = $[2]
|
||||
}
|
||||
|
||||
const value = t1
|
||||
let t2
|
||||
|
||||
if ($[3] !== children || $[4] !== value) {
|
||||
t2 = <TerminalFocusContext.Provider value={value}>{children}</TerminalFocusContext.Provider>
|
||||
$[3] = children
|
||||
$[4] = value
|
||||
$[5] = t2
|
||||
} else {
|
||||
t2 = $[5]
|
||||
}
|
||||
|
||||
return t2
|
||||
}
|
||||
|
||||
export default TerminalFocusContext
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsImNyZWF0ZUNvbnRleHQiLCJ1c2VNZW1vIiwidXNlU3luY0V4dGVybmFsU3RvcmUiLCJnZXRUZXJtaW5hbEZvY3VzZWQiLCJnZXRUZXJtaW5hbEZvY3VzU3RhdGUiLCJzdWJzY3JpYmVUZXJtaW5hbEZvY3VzIiwiVGVybWluYWxGb2N1c1N0YXRlIiwiVGVybWluYWxGb2N1c0NvbnRleHRQcm9wcyIsImlzVGVybWluYWxGb2N1c2VkIiwidGVybWluYWxGb2N1c1N0YXRlIiwiVGVybWluYWxGb2N1c0NvbnRleHQiLCJkaXNwbGF5TmFtZSIsIlRlcm1pbmFsRm9jdXNQcm92aWRlciIsInQwIiwiJCIsIl9jIiwiY2hpbGRyZW4iLCJ0MSIsInZhbHVlIiwidDIiXSwic291cmNlcyI6WyJUZXJtaW5hbEZvY3VzQ29udGV4dC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0LCB7IGNyZWF0ZUNvbnRleHQsIHVzZU1lbW8sIHVzZVN5bmNFeHRlcm5hbFN0b3JlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQge1xuICBnZXRUZXJtaW5hbEZvY3VzZWQsXG4gIGdldFRlcm1pbmFsRm9jdXNTdGF0ZSxcbiAgc3Vic2NyaWJlVGVybWluYWxGb2N1cyxcbiAgdHlwZSBUZXJtaW5hbEZvY3VzU3RhdGUsXG59IGZyb20gJy4uL3Rlcm1pbmFsLWZvY3VzLXN0YXRlLmpzJ1xuXG5leHBvcnQgdHlwZSB7IFRlcm1pbmFsRm9jdXNTdGF0ZSB9XG5cbmV4cG9ydCB0eXBlIFRlcm1pbmFsRm9jdXNDb250ZXh0UHJvcHMgPSB7XG4gIHJlYWRvbmx5IGlzVGVybWluYWxGb2N1c2VkOiBib29sZWFuXG4gIHJlYWRvbmx5IHRlcm1pbmFsRm9jdXNTdGF0ZTogVGVybWluYWxGb2N1c1N0YXRlXG59XG5cbmNvbnN0IFRlcm1pbmFsRm9jdXNDb250ZXh0ID0gY3JlYXRlQ29udGV4dDxUZXJtaW5hbEZvY3VzQ29udGV4dFByb3BzPih7XG4gIGlzVGVybWluYWxGb2N1c2VkOiB0cnVlLFxuICB0ZXJtaW5hbEZvY3VzU3RhdGU6ICd1bmtub3duJyxcbn0pXG5cbi8vIGVzbGludC1kaXNhYmxlLW5leHQtbGluZSBjdXN0b20tcnVsZXMvbm8tdG9wLWxldmVsLXNpZGUtZWZmZWN0c1xuVGVybWluYWxGb2N1c0NvbnRleHQuZGlzcGxheU5hbWUgPSAnVGVybWluYWxGb2N1c0NvbnRleHQnXG5cbi8vIFNlcGFyYXRlIGNvbXBvbmVudCBzbyBBcHAudHN4IGRvZXNuJ3QgcmUtcmVuZGVyIG9uIGZvY3VzIGNoYW5nZXMuXG4vLyBDaGlsZHJlbiBhcmUgYSBzdGFibGUgcHJvcCByZWZlcmVuY2UsIHNvIHRoZXkgZG9uJ3QgcmUtcmVuZGVyIGVpdGhlciDigJRcbi8vIG9ubHkgY29tcG9uZW50cyB0aGF0IGNvbnN1bWUgdGhlIGNvbnRleHQgd2lsbCByZS1yZW5kZXIuXG5leHBvcnQgZnVuY3Rpb24gVGVybWluYWxGb2N1c1Byb3ZpZGVyKHtcbiAgY2hpbGRyZW4sXG59OiB7XG4gIGNoaWxkcmVuOiBSZWFjdC5SZWFjdE5vZGVcbn0pOiBSZWFjdC5SZWFjdE5vZGUge1xuICBjb25zdCBpc1Rlcm1pbmFsRm9jdXNlZCA9IHVzZVN5bmNFeHRlcm5hbFN0b3JlKFxuICAgIHN1YnNjcmliZVRlcm1pbmFsRm9jdXMsXG4gICAgZ2V0VGVybWluYWxGb2N1c2VkLFxuICApXG4gIGNvbnN0IHRlcm1pbmFsRm9jdXNTdGF0ZSA9IHVzZVN5bmNFeHRlcm5hbFN0b3JlKFxuICAgIHN1YnNjcmliZVRlcm1pbmFsRm9jdXMsXG4gICAgZ2V0VGVybWluYWxGb2N1c1N0YXRlLFxuICApXG5cbiAgY29uc3QgdmFsdWUgPSB1c2VNZW1vKFxuICAgICgpID0+ICh7IGlzVGVybWluYWxGb2N1c2VkLCB0ZXJtaW5hbEZvY3VzU3RhdGUgfSksXG4gICAgW2lzVGVybWluYWxGb2N1c2VkLCB0ZXJtaW5hbEZvY3VzU3RhdGVdLFxuICApXG5cbiAgcmV0dXJuIChcbiAgICA8VGVybWluYWxGb2N1c0NvbnRleHQuUHJvdmlkZXIgdmFsdWU9e3ZhbHVlfT5cbiAgICAgIHtjaGlsZHJlbn1cbiAgICA8L1Rlcm1pbmFsRm9jdXNDb250ZXh0LlByb3ZpZGVyPlxuICApXG59XG5cbmV4cG9ydCBkZWZhdWx0IFRlcm1pbmFsRm9jdXNDb250ZXh0XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLElBQUlDLGFBQWEsRUFBRUMsT0FBTyxFQUFFQyxvQkFBb0IsUUFBUSxPQUFPO0FBQzNFLFNBQ0VDLGtCQUFrQixFQUNsQkMscUJBQXFCLEVBQ3JCQyxzQkFBc0IsRUFDdEIsS0FBS0Msa0JBQWtCLFFBQ2xCLDRCQUE0QjtBQUVuQyxjQUFjQSxrQkFBa0I7QUFFaEMsT0FBTyxLQUFLQyx5QkFBeUIsR0FBRztFQUN0QyxTQUFTQyxpQkFBaUIsRUFBRSxPQUFPO0VBQ25DLFNBQVNDLGtCQUFrQixFQUFFSCxrQkFBa0I7QUFDakQsQ0FBQztBQUVELE1BQU1JLG9CQUFvQixHQUFHVixhQUFhLENBQUNPLHlCQUF5QixDQUFDLENBQUM7RUFDcEVDLGlCQUFpQixFQUFFLElBQUk7RUFDdkJDLGtCQUFrQixFQUFFO0FBQ3RCLENBQUMsQ0FBQzs7QUFFRjtBQUNBQyxvQkFBb0IsQ0FBQ0MsV0FBVyxHQUFHLHNCQUFzQjs7QUFFekQ7QUFDQTtBQUNBO0FBQ0EsT0FBTyxTQUFBQyxzQkFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUErQjtJQUFBQztFQUFBLElBQUFILEVBSXJDO0VBQ0MsTUFBQUwsaUJBQUEsR0FBMEJOLG9CQUFvQixDQUM1Q0csc0JBQXNCLEVBQ3RCRixrQkFDRixDQUFDO0VBQ0QsTUFBQU0sa0JBQUEsR0FBMkJQLG9CQUFvQixDQUM3Q0csc0JBQXNCLEVBQ3RCRCxxQkFDRixDQUFDO0VBQUEsSUFBQWEsRUFBQTtFQUFBLElBQUFILENBQUEsUUFBQU4saUJBQUEsSUFBQU0sQ0FBQSxRQUFBTCxrQkFBQTtJQUdRUSxFQUFBO01BQUFULGlCQUFBO01BQUFDO0lBQXdDLENBQUM7SUFBQUssQ0FBQSxNQUFBTixpQkFBQTtJQUFBTSxDQUFBLE1BQUFMLGtCQUFBO0lBQUFLLENBQUEsTUFBQUcsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUgsQ0FBQTtFQUFBO0VBRGxELE1BQUFJLEtBQUEsR0FDU0QsRUFBeUM7RUFFakQsSUFBQUUsRUFBQTtFQUFBLElBQUFMLENBQUEsUUFBQUUsUUFBQSxJQUFBRixDQUFBLFFBQUFJLEtBQUE7SUFHQ0MsRUFBQSxrQ0FBc0NELEtBQUssQ0FBTEEsTUFBSSxDQUFDLENBQ3hDRixTQUFPLENBQ1YsZ0NBQWdDO0lBQUFGLENBQUEsTUFBQUUsUUFBQTtJQUFBRixDQUFBLE1BQUFJLEtBQUE7SUFBQUosQ0FBQSxNQUFBSyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBTCxDQUFBO0VBQUE7RUFBQSxPQUZoQ0ssRUFFZ0M7QUFBQTtBQUlwQyxlQUFlVCxvQkFBb0IiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createContext } from 'react'
|
||||
export type TerminalSize = {
|
||||
columns: number
|
||||
rows: number
|
||||
}
|
||||
export const TerminalSizeContext = createContext<TerminalSize | null>(null)
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJjcmVhdGVDb250ZXh0IiwiVGVybWluYWxTaXplIiwiY29sdW1ucyIsInJvd3MiLCJUZXJtaW5hbFNpemVDb250ZXh0Il0sInNvdXJjZXMiOlsiVGVybWluYWxTaXplQ29udGV4dC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgY3JlYXRlQ29udGV4dCB9IGZyb20gJ3JlYWN0J1xuXG5leHBvcnQgdHlwZSBUZXJtaW5hbFNpemUgPSB7XG4gIGNvbHVtbnM6IG51bWJlclxuICByb3dzOiBudW1iZXJcbn1cblxuZXhwb3J0IGNvbnN0IFRlcm1pbmFsU2l6ZUNvbnRleHQgPSBjcmVhdGVDb250ZXh0PFRlcm1pbmFsU2l6ZSB8IG51bGw+KG51bGwpXG4iXSwibWFwcGluZ3MiOiJBQUFBLFNBQVNBLGFBQWEsUUFBUSxPQUFPO0FBRXJDLE9BQU8sS0FBS0MsWUFBWSxHQUFHO0VBQ3pCQyxPQUFPLEVBQUUsTUFBTTtFQUNmQyxJQUFJLEVBQUUsTUFBTTtBQUNkLENBQUM7QUFFRCxPQUFPLE1BQU1DLG1CQUFtQixHQUFHSixhQUFhLENBQUNDLFlBQVksR0FBRyxJQUFJLENBQUMsQ0FBQyxJQUFJLENBQUMiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { dimColorFallback, setDimFallbackColor, shouldUseAnsiDim } from './Text.js'
|
||||
|
||||
describe('shouldUseAnsiDim', () => {
|
||||
it('disables ANSI dim on VTE terminals by default', () => {
|
||||
expect(shouldUseAnsiDim({ VTE_VERSION: '7603' } as NodeJS.ProcessEnv)).toBe(false)
|
||||
})
|
||||
|
||||
it('disables ANSI dim on Apple Terminal by default', () => {
|
||||
expect(shouldUseAnsiDim({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps ANSI dim enabled elsewhere by default', () => {
|
||||
expect(shouldUseAnsiDim({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
})
|
||||
|
||||
it('honors explicit env override', () => {
|
||||
expect(shouldUseAnsiDim({ HERMES_TUI_DIM: '1', VTE_VERSION: '7603' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
expect(shouldUseAnsiDim({ HERMES_TUI_DIM: '1', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
expect(shouldUseAnsiDim({ HERMES_TUI_DIM: '0' } as NodeJS.ProcessEnv)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dimColorFallback', () => {
|
||||
afterEach(() => {
|
||||
setDimFallbackColor(undefined)
|
||||
})
|
||||
|
||||
it('renders Apple Terminal dim as muted gray by default', () => {
|
||||
expect(dimColorFallback({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe('#6B7280')
|
||||
})
|
||||
|
||||
it('normalizes Apple Terminal names before matching', () => {
|
||||
expect(dimColorFallback({ TERM_PROGRAM: ' Apple_Terminal ' } as NodeJS.ProcessEnv)).toBe('#6B7280')
|
||||
})
|
||||
|
||||
it('does not apply when dim is explicitly configured', () => {
|
||||
expect(
|
||||
dimColorFallback({ HERMES_TUI_DIM: '1', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
dimColorFallback({ HERMES_TUI_DIM: '0', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the theme tone once one is supplied, so dim stays in-palette', () => {
|
||||
setDimFallbackColor('#936e06')
|
||||
|
||||
expect(dimColorFallback({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe('#936e06')
|
||||
})
|
||||
|
||||
it('falls back to the boot default when the theme tone is cleared', () => {
|
||||
setDimFallbackColor('#936e06')
|
||||
setDimFallbackColor(undefined)
|
||||
|
||||
expect(dimColorFallback({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe('#6B7280')
|
||||
})
|
||||
|
||||
it('stays inert on terminals that honor SGR 2, whatever the theme tone', () => {
|
||||
setDimFallbackColor('#936e06')
|
||||
|
||||
expect(dimColorFallback({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,349 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
|
||||
import type { Color, Styles } from '../styles.js'
|
||||
|
||||
const ENV_ON_RE = /^(?:1|true|yes|on)$/i
|
||||
const ENV_OFF_RE = /^(?:0|false|no|off)$/i
|
||||
const LEGACY_APPLE_DIM_COLOR: Color = '#6B7280'
|
||||
type BaseProps = {
|
||||
/**
|
||||
* Change text color. Accepts a raw color value (rgb, hex, ansi).
|
||||
*/
|
||||
readonly color?: Color
|
||||
|
||||
/**
|
||||
* Same as `color`, but for background.
|
||||
*/
|
||||
readonly backgroundColor?: Color
|
||||
|
||||
/**
|
||||
* Make the text italic.
|
||||
*/
|
||||
readonly italic?: boolean
|
||||
|
||||
/**
|
||||
* Make the text underlined.
|
||||
*/
|
||||
readonly underline?: boolean
|
||||
|
||||
/**
|
||||
* Make the text crossed with a line.
|
||||
*/
|
||||
readonly strikethrough?: boolean
|
||||
|
||||
/**
|
||||
* Inverse background and foreground colors.
|
||||
*/
|
||||
readonly inverse?: boolean
|
||||
|
||||
/**
|
||||
* This property tells Ink to wrap or truncate text if its width is larger than container.
|
||||
* If `wrap` is passed (by default), Ink will wrap text and split it into multiple lines.
|
||||
* If `truncate-*` is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off.
|
||||
*/
|
||||
readonly wrap?: Styles['textWrap']
|
||||
readonly children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Bold and dim are mutually exclusive in terminals.
|
||||
* This type ensures you can use one or the other, but not both.
|
||||
*/
|
||||
type WeightProps =
|
||||
| {
|
||||
bold?: never
|
||||
dim?: never
|
||||
}
|
||||
| {
|
||||
bold: boolean
|
||||
dim?: never
|
||||
}
|
||||
| {
|
||||
dim: boolean
|
||||
bold?: never
|
||||
}
|
||||
export type Props = BaseProps & WeightProps
|
||||
|
||||
export function shouldUseAnsiDim(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const override = (env.HERMES_TUI_DIM ?? '').trim()
|
||||
|
||||
if (ENV_ON_RE.test(override)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (ENV_OFF_RE.test(override)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ((env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal') {
|
||||
return false
|
||||
}
|
||||
|
||||
return !env.VTE_VERSION
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminals that ignore SGR 2 need a literal color instead. The tone is
|
||||
* theme-supplied (setDimFallbackColor, called from the theme effect) so it
|
||||
* stays inside the active palette; the slate below is only the pre-theme
|
||||
* boot default. A hardcoded value here reads as an off-palette foreground
|
||||
* next to themed spans on the same line — cold gray beside warm ink.
|
||||
*/
|
||||
let dimFallbackColor: Color = LEGACY_APPLE_DIM_COLOR
|
||||
|
||||
export function setDimFallbackColor(color: Color | undefined): void {
|
||||
dimFallbackColor = color || LEGACY_APPLE_DIM_COLOR
|
||||
}
|
||||
|
||||
export function dimColorFallback(env: NodeJS.ProcessEnv = process.env): Color | undefined {
|
||||
const override = (env.HERMES_TUI_DIM ?? '').trim()
|
||||
|
||||
if (ENV_ON_RE.test(override) || ENV_OFF_RE.test(override)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal' ? dimFallbackColor : undefined
|
||||
}
|
||||
|
||||
const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
|
||||
wrap: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'wrap'
|
||||
},
|
||||
'wrap-char': {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'wrap-char'
|
||||
},
|
||||
'wrap-trim': {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'wrap-trim'
|
||||
},
|
||||
end: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'end'
|
||||
},
|
||||
middle: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'middle'
|
||||
},
|
||||
'truncate-end': {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'truncate-end'
|
||||
},
|
||||
truncate: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'truncate'
|
||||
},
|
||||
'truncate-middle': {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'truncate-middle'
|
||||
},
|
||||
'truncate-start': {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'row',
|
||||
textWrap: 'truncate-start'
|
||||
}
|
||||
} as const
|
||||
|
||||
/**
|
||||
* This component can display text, and change its style to make it colorful, bold, underline, italic or strikethrough.
|
||||
*/
|
||||
export default function Text(t0: Props) {
|
||||
const $ = _c(29)
|
||||
|
||||
const {
|
||||
color,
|
||||
backgroundColor,
|
||||
bold,
|
||||
dim,
|
||||
italic: t1,
|
||||
underline: t2,
|
||||
strikethrough: t3,
|
||||
inverse: t4,
|
||||
wrap: t5,
|
||||
children
|
||||
} = t0
|
||||
|
||||
const italic = t1 === undefined ? false : t1
|
||||
const underline = t2 === undefined ? false : t2
|
||||
const strikethrough = t3 === undefined ? false : t3
|
||||
const inverse = t4 === undefined ? false : t4
|
||||
const wrap = t5 === undefined ? 'wrap' : t5
|
||||
const effectiveDim = dim && shouldUseAnsiDim()
|
||||
const effectiveColor = dim && !effectiveDim ? (color ?? dimColorFallback()) : color
|
||||
|
||||
if (children === undefined || children === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
let t6
|
||||
|
||||
if ($[0] !== effectiveColor) {
|
||||
t6 = effectiveColor && {
|
||||
color: effectiveColor
|
||||
}
|
||||
$[0] = effectiveColor
|
||||
$[1] = t6
|
||||
} else {
|
||||
t6 = $[1]
|
||||
}
|
||||
|
||||
let t7
|
||||
|
||||
if ($[2] !== backgroundColor) {
|
||||
t7 = backgroundColor && {
|
||||
backgroundColor
|
||||
}
|
||||
$[2] = backgroundColor
|
||||
$[3] = t7
|
||||
} else {
|
||||
t7 = $[3]
|
||||
}
|
||||
|
||||
let t8
|
||||
|
||||
if ($[4] !== effectiveDim) {
|
||||
t8 = effectiveDim && {
|
||||
dim: effectiveDim
|
||||
}
|
||||
$[4] = effectiveDim
|
||||
$[5] = t8
|
||||
} else {
|
||||
t8 = $[5]
|
||||
}
|
||||
|
||||
let t9
|
||||
|
||||
if ($[6] !== bold) {
|
||||
t9 = bold && {
|
||||
bold
|
||||
}
|
||||
$[6] = bold
|
||||
$[7] = t9
|
||||
} else {
|
||||
t9 = $[7]
|
||||
}
|
||||
|
||||
let t10
|
||||
|
||||
if ($[8] !== italic) {
|
||||
t10 = italic && {
|
||||
italic
|
||||
}
|
||||
$[8] = italic
|
||||
$[9] = t10
|
||||
} else {
|
||||
t10 = $[9]
|
||||
}
|
||||
|
||||
let t11
|
||||
|
||||
if ($[10] !== underline) {
|
||||
t11 = underline && {
|
||||
underline
|
||||
}
|
||||
$[10] = underline
|
||||
$[11] = t11
|
||||
} else {
|
||||
t11 = $[11]
|
||||
}
|
||||
|
||||
let t12
|
||||
|
||||
if ($[12] !== strikethrough) {
|
||||
t12 = strikethrough && {
|
||||
strikethrough
|
||||
}
|
||||
$[12] = strikethrough
|
||||
$[13] = t12
|
||||
} else {
|
||||
t12 = $[13]
|
||||
}
|
||||
|
||||
let t13
|
||||
|
||||
if ($[14] !== inverse) {
|
||||
t13 = inverse && {
|
||||
inverse
|
||||
}
|
||||
$[14] = inverse
|
||||
$[15] = t13
|
||||
} else {
|
||||
t13 = $[15]
|
||||
}
|
||||
|
||||
let t14
|
||||
|
||||
if (
|
||||
$[16] !== t10 ||
|
||||
$[17] !== t11 ||
|
||||
$[18] !== t12 ||
|
||||
$[19] !== t13 ||
|
||||
$[20] !== t6 ||
|
||||
$[21] !== t7 ||
|
||||
$[22] !== t8 ||
|
||||
$[23] !== t9
|
||||
) {
|
||||
t14 = {
|
||||
...t6,
|
||||
...t7,
|
||||
...t8,
|
||||
...t9,
|
||||
...t10,
|
||||
...t11,
|
||||
...t12,
|
||||
...t13
|
||||
}
|
||||
$[16] = t10
|
||||
$[17] = t11
|
||||
$[18] = t12
|
||||
$[19] = t13
|
||||
$[20] = t6
|
||||
$[21] = t7
|
||||
$[22] = t8
|
||||
$[23] = t9
|
||||
$[24] = t14
|
||||
} else {
|
||||
t14 = $[24]
|
||||
}
|
||||
|
||||
const textStyles = t14
|
||||
const t15 = memoizedStylesForWrap[wrap]
|
||||
let t16
|
||||
|
||||
if ($[25] !== children || $[26] !== t15 || $[27] !== textStyles) {
|
||||
t16 = (
|
||||
<ink-text style={t15} textStyles={textStyles}>
|
||||
{children}
|
||||
</ink-text>
|
||||
)
|
||||
$[25] = children
|
||||
$[26] = t15
|
||||
$[27] = textStyles
|
||||
$[28] = t16
|
||||
} else {
|
||||
t16 = $[28]
|
||||
}
|
||||
|
||||
return t16
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdE5vZGUiLCJSZWFjdCIsIkNvbG9yIiwiU3R5bGVzIiwiVGV4dFN0eWxlcyIsIkJhc2VQcm9wcyIsImNvbG9yIiwiYmFja2dyb3VuZENvbG9yIiwiaXRhbGljIiwidW5kZXJsaW5lIiwic3RyaWtldGhyb3VnaCIsImludmVyc2UiLCJ3cmFwIiwiY2hpbGRyZW4iLCJXZWlnaHRQcm9wcyIsImJvbGQiLCJkaW0iLCJQcm9wcyIsIm1lbW9pemVkU3R5bGVzRm9yV3JhcCIsIlJlY29yZCIsIk5vbk51bGxhYmxlIiwiZmxleEdyb3ciLCJmbGV4U2hyaW5rIiwiZmxleERpcmVjdGlvbiIsInRleHRXcmFwIiwiZW5kIiwibWlkZGxlIiwidHJ1bmNhdGUiLCJjb25zdCIsIlRleHQiLCJ0MCIsIiQiLCJfYyIsInQxIiwidDIiLCJ0MyIsInQ0IiwidDUiLCJ1bmRlZmluZWQiLCJ0NiIsInQ3IiwidDgiLCJ0OSIsInQxMCIsInQxMSIsInQxMiIsInQxMyIsInQxNCIsInRleHRTdHlsZXMiLCJ0MTUiLCJ0MTYiXSwic291cmNlcyI6WyJUZXh0LnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgdHlwZSB7IFJlYWN0Tm9kZSB9IGZyb20gJ3JlYWN0J1xuaW1wb3J0IFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHR5cGUgeyBDb2xvciwgU3R5bGVzLCBUZXh0U3R5bGVzIH0gZnJvbSAnLi4vc3R5bGVzLmpzJ1xuXG50eXBlIEJhc2VQcm9wcyA9IHtcbiAgLyoqXG4gICAqIENoYW5nZSB0ZXh0IGNvbG9yLiBBY2NlcHRzIGEgcmF3IGNvbG9yIHZhbHVlIChyZ2IsIGhleCwgYW5zaSkuXG4gICAqL1xuICByZWFkb25seSBjb2xvcj86IENvbG9yXG5cbiAgLyoqXG4gICAqIFNhbWUgYXMgYGNvbG9yYCwgYnV0IGZvciBiYWNrZ3JvdW5kLlxuICAgKi9cbiAgcmVhZG9ubHkgYmFja2dyb3VuZENvbG9yPzogQ29sb3JcblxuICAvKipcbiAgICogTWFrZSB0aGUgdGV4dCBpdGFsaWMuXG4gICAqL1xuICByZWFkb25seSBpdGFsaWM/OiBib29sZWFuXG5cbiAgLyoqXG4gICAqIE1ha2UgdGhlIHRleHQgdW5kZXJsaW5lZC5cbiAgICovXG4gIHJlYWRvbmx5IHVuZGVybGluZT86IGJvb2xlYW5cblxuICAvKipcbiAgICogTWFrZSB0aGUgdGV4dCBjcm9zc2VkIHdpdGggYSBsaW5lLlxuICAgKi9cbiAgcmVhZG9ubHkgc3RyaWtldGhyb3VnaD86IGJvb2xlYW5cblxuICAvKipcbiAgICogSW52ZXJzZSBiYWNrZ3JvdW5kIGFuZCBmb3JlZ3JvdW5kIGNvbG9ycy5cbiAgICovXG4gIHJlYWRvbmx5IGludmVyc2U/OiBib29sZWFuXG5cbiAgLyoqXG4gICAqIFRoaXMgcHJvcGVydHkgdGVsbHMgSW5rIHRvIHdyYXAgb3IgdHJ1bmNhdGUgdGV4dCBpZiBpdHMgd2lkdGggaXMgbGFyZ2VyIHRoYW4gY29udGFpbmVyLlxuICAgKiBJZiBgd3JhcGAgaXMgcGFzc2VkIChieSBkZWZhdWx0KSwgSW5rIHdpbGwgd3JhcCB0ZXh0IGFuZCBzcGxpdCBpdCBpbnRvIG11bHRpcGxlIGxpbmVzLlxuICAgKiBJZiBgdHJ1bmNhdGUtKmAgaXMgcGFzc2VkLCBJbmsgd2lsbCB0cnVuY2F0ZSB0ZXh0IGluc3RlYWQsIHdoaWNoIHdpbGwgcmVzdWx0IGluIG9uZSBsaW5lIG9mIHRleHQgd2l0aCB0aGUgcmVzdCBjdXQgb2ZmLlxuICAgKi9cbiAgcmVhZG9ubHkgd3JhcD86IFN0eWxlc1sndGV4dFdyYXAnXVxuXG4gIHJlYWRvbmx5IGNoaWxkcmVuPzogUmVhY3ROb2RlXG59XG5cbi8qKlxuICogQm9sZCBhbmQgZGltIGFyZSBtdXR1YWxseSBleGNsdXNpdmUgaW4gdGVybWluYWxzLlxuICogVGhpcyB0eXBlIGVuc3VyZXMgeW91IGNhbiB1c2Ugb25lIG9yIHRoZSBvdGhlciwgYnV0IG5vdCBib3RoLlxuICovXG50eXBlIFdlaWdodFByb3BzID1cbiAgfCB7IGJvbGQ/OiBuZXZlcjsgZGltPzogbmV2ZXIgfVxuICB8IHsgYm9sZDogYm9vbGVhbjsgZGltPzogbmV2ZXIgfVxuICB8IHsgZGltOiBib29sZWFuOyBib2xkPzogbmV2ZXIgfVxuXG5leHBvcnQgdHlwZSBQcm9wcyA9IEJhc2VQcm9wcyAmIFdlaWdodFByb3BzXG5cbmNvbnN0IG1lbW9pemVkU3R5bGVzRm9yV3JhcDogUmVjb3JkPE5vbk51bGxhYmxlPFN0eWxlc1sndGV4dFdyYXAnXT4sIFN0eWxlcz4gPSB7XG4gIHdyYXA6IHtcbiAgICBmbGV4R3JvdzogMCxcbiAgICBmbGV4U2hyaW5rOiAxLFxuICAgIGZsZXhEaXJlY3Rpb246ICdyb3cnLFxuICAgIHRleHRXcmFwOiAnd3JhcCcsXG4gIH0sXG4gICd3cmFwLXRyaW0nOiB7XG4gICAgZmxleEdyb3c6IDAsXG4gICAgZmxleFNocmluazogMSxcbiAgICBmbGV4RGlyZWN0aW9uOiAncm93JyxcbiAgICB0ZXh0V3JhcDogJ3dyYXAtdHJpbScsXG4gIH0sXG4gIGVuZDoge1xuICAgIGZsZXhHcm93OiAwLFxuICAgIGZsZXhTaHJpbms6IDEsXG4gICAgZmxleERpcmVjdGlvbjogJ3JvdycsXG4gICAgdGV4dFdyYXA6ICdlbmQnLFxuICB9LFxuICBtaWRkbGU6IHtcbiAgICBmbGV4R3JvdzogMCxcbiAgICBmbGV4U2hyaW5rOiAxLFxuICAgIGZsZXhEaXJlY3Rpb246ICdyb3cnLFxuICAgIHRleHRXcmFwOiAnbWlkZGxlJyxcbiAgfSxcbiAgJ3RydW5jYXRlLWVuZCc6IHtcbiAgICBmbGV4R3JvdzogMCxcbiAgICBmbGV4U2hyaW5rOiAxLFxuICAgIGZsZXhEaXJlY3Rpb246ICdyb3cnLFxuICAgIHRleHRXcmFwOiAndHJ1bmNhdGUtZW5kJyxcbiAgfSxcbiAgdHJ1bmNhdGU6IHtcbiAgICBmbGV4R3JvdzogMCxcbiAgICBmbGV4U2hyaW5rOiAxLFxuICAgIGZsZXhEaXJlY3Rpb246ICdyb3cnLFxuICAgIHRleHRXcmFwOiAndHJ1bmNhdGUnLFxuICB9LFxuICAndHJ1bmNhdGUtbWlkZGxlJzoge1xuICAgIGZsZXhHcm93OiAwLFxuICAgIGZsZXhTaHJpbms6IDEsXG4gICAgZmxleERpcmVjdGlvbjogJ3JvdycsXG4gICAgdGV4dFdyYXA6ICd0cnVuY2F0ZS1taWRkbGUnLFxuICB9LFxuICAndHJ1bmNhdGUtc3RhcnQnOiB7XG4gICAgZmxleEdyb3c6IDAsXG4gICAgZmxleFNocmluazogMSxcbiAgICBmbGV4RGlyZWN0aW9uOiAncm93JyxcbiAgICB0ZXh0V3JhcDogJ3RydW5jYXRlLXN0YXJ0JyxcbiAgfSxcbn0gYXMgY29uc3RcblxuLyoqXG4gKiBUaGlzIGNvbXBvbmVudCBjYW4gZGlzcGxheSB0ZXh0LCBhbmQgY2hhbmdlIGl0cyBzdHlsZSB0byBtYWtlIGl0IGNvbG9yZnVsLCBib2xkLCB1bmRlcmxpbmUsIGl0YWxpYyBvciBzdHJpa2V0aHJvdWdoLlxuICovXG5leHBvcnQgZGVmYXVsdCBmdW5jdGlvbiBUZXh0KHtcbiAgY29sb3IsXG4gIGJhY2tncm91bmRDb2xvcixcbiAgYm9sZCxcbiAgZGltLFxuICBpdGFsaWMgPSBmYWxzZSxcbiAgdW5kZXJsaW5lID0gZmFsc2UsXG4gIHN0cmlrZXRocm91Z2ggPSBmYWxzZSxcbiAgaW52ZXJzZSA9IGZhbHNlLFxuICB3cmFwID0gJ3dyYXAnLFxuICBjaGlsZHJlbixcbn06IFByb3BzKTogUmVhY3QuUmVhY3ROb2RlIHtcbiAgaWYgKGNoaWxkcmVuID09PSB1bmRlZmluZWQgfHwgY2hpbGRyZW4gPT09IG51bGwpIHtcbiAgICByZXR1cm4gbnVsbFxuICB9XG5cbiAgLy8gQnVpbGQgdGV4dFN0eWxlcyBvYmplY3Qgd2l0aCBvbmx5IHRoZSBwcm9wZXJ0aWVzIHRoYXQgYXJlIHNldFxuICBjb25zdCB0ZXh0U3R5bGVzOiBUZXh0U3R5bGVzID0ge1xuICAgIC4uLihjb2xvciAmJiB7IGNvbG9yIH0pLFxuICAgIC4uLihiYWNrZ3JvdW5kQ29sb3IgJiYgeyBiYWNrZ3JvdW5kQ29sb3IgfSksXG4gICAgLi4uKGRpbSAmJiB7IGRpbSB9KSxcbiAgICAuLi4oYm9sZCAmJiB7IGJvbGQgfSksXG4gICAgLi4uKGl0YWxpYyAmJiB7IGl0YWxpYyB9KSxcbiAgICAuLi4odW5kZXJsaW5lICYmIHsgdW5kZXJsaW5lIH0pLFxuICAgIC4uLih
Line truncated
|
||||
@@ -0,0 +1,19 @@
|
||||
// Shared frame interval for render throttling and animations (~60fps).
|
||||
export const FRAME_INTERVAL_MS = 16
|
||||
|
||||
// Keep clock-driven animations at full speed when terminal focus changes.
|
||||
// We still pause entirely when there are no keepAlive subscribers.
|
||||
export const BLURRED_FRAME_INTERVAL_MS = FRAME_INTERVAL_MS
|
||||
|
||||
// Issue #31486 (stdout-backpressure strand): when the previous frame's
|
||||
// stdout.write has NOT drained yet (terminal parser overwhelmed by a wide
|
||||
// CR+LF burst — CJK + ANSI tool output on a high-context session), piling
|
||||
// another write on the backed-up pipe both wastes the frame and keeps the
|
||||
// macrotask queue churning, starving the stdin 'readable' callback. We
|
||||
// instead COALESCE: skip the frame and retry on the drain tick. This ceiling
|
||||
// caps how many consecutive frames we'll coalesce before forcing a write
|
||||
// through, so a terminal whose drain callback never fires (e.g. EIO on
|
||||
// flush) can't wedge the renderer permanently — it self-heals once the pipe
|
||||
// recovers. ~10 frames at the drain-tick cadence is a few hundred ms of
|
||||
// breathing room, well under any human-perceptible render stall.
|
||||
export const MAX_COALESCED_BACKPRESSURE_FRAMES = 10
|
||||
@@ -0,0 +1,5 @@
|
||||
export type Cursor = {
|
||||
x: number
|
||||
y: number
|
||||
visible: boolean
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Optional react-devtools hook; package may be absent. */
|
||||
export {}
|
||||
@@ -0,0 +1,494 @@
|
||||
import type { FocusManager } from './focus.js'
|
||||
import { createLayoutNode } from './layout/engine.js'
|
||||
import type { LayoutNode } 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'
|
||||
import type { Styles, TextStyles } from './styles.js'
|
||||
import { expandTabs } from './tabstops.js'
|
||||
import wrapText from './wrap-text.js'
|
||||
|
||||
type InkNode = {
|
||||
parentNode: DOMElement | undefined
|
||||
yogaNode?: LayoutNode
|
||||
style: Styles
|
||||
}
|
||||
|
||||
export type TextName = '#text'
|
||||
export type ElementNames =
|
||||
'ink-root' | 'ink-box' | 'ink-text' | 'ink-virtual-text' | 'ink-link' | 'ink-progress' | 'ink-raw-ansi'
|
||||
|
||||
export type NodeNames = ElementNames | TextName
|
||||
|
||||
export type DOMElement = {
|
||||
nodeName: ElementNames
|
||||
attributes: Record<string, DOMNodeAttribute>
|
||||
childNodes: DOMNode[]
|
||||
textStyles?: TextStyles
|
||||
|
||||
// Internal properties
|
||||
onComputeLayout?: () => void
|
||||
onRender?: () => void
|
||||
onImmediateRender?: () => void
|
||||
// Used to skip empty renders during React 19's effect double-invoke in test mode
|
||||
hasRenderedContent?: boolean
|
||||
|
||||
// When true, this node needs re-rendering
|
||||
dirty: boolean
|
||||
// Set by the reconciler's hideInstance/unhideInstance; survives style updates.
|
||||
isHidden?: boolean
|
||||
// Event handlers set by the reconciler for the capture/bubble dispatcher.
|
||||
// Stored separately from attributes so handler identity changes don't
|
||||
// mark dirty and defeat the blit optimization.
|
||||
_eventHandlers?: Record<string, unknown>
|
||||
|
||||
// Scroll state for overflow: 'scroll' boxes. scrollTop is the number of
|
||||
// rows the content is scrolled down by. scrollHeight/scrollViewportHeight
|
||||
// are computed at render time and stored for imperative access. stickyScroll
|
||||
// auto-pins scrollTop to the bottom when content grows.
|
||||
scrollTop?: number
|
||||
// Accumulated scroll delta not yet applied to scrollTop. The renderer
|
||||
// drains this at SCROLL_MAX_PER_FRAME rows/frame so fast flicks show
|
||||
// intermediate frames instead of one big jump. Direction reversal
|
||||
// naturally cancels (pure accumulator, no target tracking).
|
||||
pendingScrollDelta?: number
|
||||
// One-render record of additive scrollTop changes made to preserve the
|
||||
// visual anchor after content above the viewport changes height. The
|
||||
// renderer subtracts this when evaluating positional bottom-follow and
|
||||
// defers pending input for that paint, then clears the record.
|
||||
scrollTopCompensation?: number
|
||||
// Render-time clamp bounds for virtual scroll. useVirtualScroll writes
|
||||
// the currently-mounted children's coverage span; render-node-to-output
|
||||
// clamps scrollTop to stay within it. Prevents blank screen when
|
||||
// scrollTo's direct write races past React's async re-render — instead
|
||||
// of painting spacer (blank), the renderer holds at the edge of mounted
|
||||
// content until React catches up (next commit updates these bounds and
|
||||
// the clamp releases). Undefined = no clamp (sticky-scroll, cold start).
|
||||
scrollClampMin?: number
|
||||
scrollClampMax?: number
|
||||
scrollHeight?: number
|
||||
scrollViewportHeight?: number
|
||||
scrollViewportTop?: number
|
||||
stickyScroll?: boolean
|
||||
notifyScrollChange?: () => void
|
||||
// Set by ScrollBox.scrollToElement; render-node-to-output reads
|
||||
// el.yogaNode.getComputedTop() (FRESH — same Yoga pass as scrollHeight)
|
||||
// and sets scrollTop = top + offset, then clears this. Unlike an
|
||||
// imperative scrollTo(N) which bakes in a number that's stale by the
|
||||
// time the throttled render fires, the element ref defers the position
|
||||
// read to paint time. One-shot.
|
||||
scrollAnchor?: { el: DOMElement; offset: number }
|
||||
// Only set on ink-root. The document owns focus — any node can
|
||||
// reach it by walking parentNode, like browser getRootNode().
|
||||
focusManager?: FocusManager
|
||||
// Measurement cache for ink-text nodes: avoids re-squashing and re-wrapping
|
||||
// text when yoga calls measureFunc multiple times per frame with different
|
||||
// widths during flex re-pass. Keyed by `${width}|${widthMode}`.
|
||||
_textMeasureCache?: { gen: number; entries: Map<string, { _gen: number; result: { width: number; height: number } }> }
|
||||
} & InkNode
|
||||
|
||||
export type TextNode = {
|
||||
nodeName: TextName
|
||||
nodeValue: string
|
||||
} & InkNode
|
||||
|
||||
export type DOMNode<T = { nodeName: NodeNames }> = T extends {
|
||||
nodeName: infer U
|
||||
}
|
||||
? U extends '#text'
|
||||
? TextNode
|
||||
: DOMElement
|
||||
: never
|
||||
|
||||
export type DOMNodeAttribute = boolean | string | number
|
||||
|
||||
export const createNode = (nodeName: ElementNames): DOMElement => {
|
||||
const needsYogaNode = nodeName !== 'ink-virtual-text' && nodeName !== 'ink-link' && nodeName !== 'ink-progress'
|
||||
|
||||
const node: DOMElement = {
|
||||
nodeName,
|
||||
style: {},
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
parentNode: undefined,
|
||||
yogaNode: needsYogaNode ? createLayoutNode() : undefined,
|
||||
dirty: false
|
||||
}
|
||||
|
||||
if (nodeName === 'ink-text') {
|
||||
node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node))
|
||||
} else if (nodeName === 'ink-raw-ansi') {
|
||||
node.yogaNode?.setMeasureFunc(measureRawAnsiNode.bind(null, node))
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
export const appendChildNode = (node: DOMElement, childNode: DOMElement): void => {
|
||||
if (childNode.parentNode) {
|
||||
removeChildNode(childNode.parentNode, childNode)
|
||||
}
|
||||
|
||||
childNode.parentNode = node
|
||||
node.childNodes.push(childNode)
|
||||
|
||||
if (childNode.yogaNode) {
|
||||
node.yogaNode?.insertChild(childNode.yogaNode, node.yogaNode.getChildCount())
|
||||
}
|
||||
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
export const insertBeforeNode = (node: DOMElement, newChildNode: DOMNode, beforeChildNode: DOMNode): void => {
|
||||
if (newChildNode.parentNode) {
|
||||
removeChildNode(newChildNode.parentNode, newChildNode)
|
||||
}
|
||||
|
||||
newChildNode.parentNode = node
|
||||
|
||||
const index = node.childNodes.indexOf(beforeChildNode)
|
||||
|
||||
if (index >= 0) {
|
||||
// Calculate yoga index BEFORE modifying childNodes.
|
||||
// We can't use DOM index directly because some children (like ink-progress,
|
||||
// ink-link, ink-virtual-text) don't have yogaNodes, so DOM indices don't
|
||||
// match yoga indices.
|
||||
let yogaIndex = 0
|
||||
|
||||
if (newChildNode.yogaNode && node.yogaNode) {
|
||||
for (let i = 0; i < index; i++) {
|
||||
if (node.childNodes[i]?.yogaNode) {
|
||||
yogaIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node.childNodes.splice(index, 0, newChildNode)
|
||||
|
||||
if (newChildNode.yogaNode && node.yogaNode) {
|
||||
node.yogaNode.insertChild(newChildNode.yogaNode, yogaIndex)
|
||||
}
|
||||
|
||||
markDirty(node)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
node.childNodes.push(newChildNode)
|
||||
|
||||
if (newChildNode.yogaNode) {
|
||||
node.yogaNode?.insertChild(newChildNode.yogaNode, node.yogaNode.getChildCount())
|
||||
}
|
||||
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
export const removeChildNode = (node: DOMElement, removeNode: DOMNode): void => {
|
||||
if (removeNode.yogaNode) {
|
||||
removeNode.parentNode?.yogaNode?.removeChild(removeNode.yogaNode)
|
||||
}
|
||||
|
||||
// Collect cached rects from the removed subtree so they can be cleared
|
||||
collectRemovedRects(node, removeNode)
|
||||
|
||||
removeNode.parentNode = undefined
|
||||
|
||||
const index = node.childNodes.indexOf(removeNode)
|
||||
|
||||
if (index >= 0) {
|
||||
node.childNodes.splice(index, 1)
|
||||
}
|
||||
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
function collectRemovedRects(parent: DOMElement, removed: DOMNode, underAbsolute = false): void {
|
||||
if (removed.nodeName === '#text') {
|
||||
return
|
||||
}
|
||||
|
||||
const elem = removed as DOMElement
|
||||
// If this node or any ancestor in the removed subtree was absolute,
|
||||
// its painted pixels may overlap non-siblings — flag for global blit
|
||||
// disable. Normal-flow removals only affect direct siblings, which
|
||||
// hasRemovedChild already handles.
|
||||
const isAbsolute = underAbsolute || elem.style.position === 'absolute'
|
||||
const cached = nodeCache.get(elem)
|
||||
|
||||
if (cached) {
|
||||
addPendingClear(parent, cached, isAbsolute)
|
||||
nodeCache.delete(elem)
|
||||
}
|
||||
|
||||
for (const child of elem.childNodes) {
|
||||
collectRemovedRects(parent, child, isAbsolute)
|
||||
}
|
||||
}
|
||||
|
||||
export const setAttribute = (node: DOMElement, key: string, value: DOMNodeAttribute): void => {
|
||||
// Skip 'children' - React handles children via appendChild/removeChild,
|
||||
// not attributes. React always passes a new children reference, so
|
||||
// tracking it as an attribute would mark everything dirty every render.
|
||||
if (key === 'children') {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if unchanged
|
||||
if (node.attributes[key] === value) {
|
||||
return
|
||||
}
|
||||
|
||||
node.attributes[key] = value
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
export const setStyle = (node: DOMNode, style: Styles): void => {
|
||||
// Compare style properties to avoid marking dirty unnecessarily.
|
||||
// React creates new style objects on every render even when unchanged.
|
||||
if (stylesEqual(node.style, style)) {
|
||||
return
|
||||
}
|
||||
|
||||
node.style = style
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
export const setTextStyles = (node: DOMElement, textStyles: TextStyles): void => {
|
||||
// Same dirty-check guard as setStyle: React (and buildTextStyles in Text.tsx)
|
||||
// allocate a new textStyles object on every render even when values are
|
||||
// unchanged, so compare by value to avoid markDirty -> yoga re-measurement
|
||||
// on every Text re-render.
|
||||
if (shallowEqual(node.textStyles, textStyles)) {
|
||||
return
|
||||
}
|
||||
|
||||
node.textStyles = textStyles
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
function stylesEqual(a: Styles, b: Styles): boolean {
|
||||
return shallowEqual(a, b)
|
||||
}
|
||||
|
||||
function shallowEqual<T extends object>(a: T | undefined, b: T | undefined): boolean {
|
||||
// Fast path: same object reference (or both undefined)
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (a === undefined || b === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get all keys from both objects
|
||||
const aKeys = Object.keys(a) as (keyof T)[]
|
||||
const bKeys = Object.keys(b) as (keyof T)[]
|
||||
|
||||
// Different number of properties
|
||||
if (aKeys.length !== bKeys.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare each property
|
||||
for (const key of aKeys) {
|
||||
if (a[key] !== b[key]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const createTextNode = (text: string): TextNode => {
|
||||
const node: TextNode = {
|
||||
nodeName: '#text',
|
||||
nodeValue: text,
|
||||
yogaNode: undefined,
|
||||
parentNode: undefined,
|
||||
style: {}
|
||||
}
|
||||
|
||||
setTextNodeValue(node, text)
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
const MEASURE_CACHE_CAP = 16
|
||||
|
||||
const measureTextNode = function (
|
||||
node: DOMNode,
|
||||
width: number,
|
||||
widthMode: LayoutMeasureMode
|
||||
): { width: number; height: number } {
|
||||
const elem = node.nodeName !== '#text' ? (node as DOMElement) : node.parentNode
|
||||
|
||||
if (elem && elem.nodeName === 'ink-text') {
|
||||
let cache = elem._textMeasureCache
|
||||
|
||||
if (!cache) {
|
||||
cache = { gen: 0, entries: new Map() }
|
||||
elem._textMeasureCache = cache
|
||||
}
|
||||
|
||||
const key = `${width}|${widthMode}`
|
||||
const hit = cache.entries.get(key)
|
||||
|
||||
if (hit && hit._gen === cache.gen) {
|
||||
return hit.result
|
||||
}
|
||||
|
||||
const result = computeTextMeasure(node, width, widthMode)
|
||||
|
||||
// Enforce cap with FIFO eviction to avoid unbounded growth during
|
||||
// pathological frames where yoga probes many widths.
|
||||
if (cache.entries.size >= MEASURE_CACHE_CAP) {
|
||||
const firstKey = cache.entries.keys().next().value
|
||||
|
||||
if (firstKey !== undefined) {
|
||||
cache.entries.delete(firstKey)
|
||||
}
|
||||
}
|
||||
|
||||
cache.entries.set(key, { _gen: cache.gen, result })
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return computeTextMeasure(node, width, widthMode)
|
||||
}
|
||||
|
||||
const computeTextMeasure = function (
|
||||
node: DOMNode,
|
||||
width: number,
|
||||
widthMode: LayoutMeasureMode
|
||||
): { width: number; height: number } {
|
||||
const rawText = node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node)
|
||||
|
||||
// Expand tabs for measurement (worst case: 8 spaces each).
|
||||
// Actual tab expansion happens in output.ts based on screen position.
|
||||
const text = expandTabs(rawText)
|
||||
|
||||
const dimensions = measureText(text, width)
|
||||
|
||||
// Text fits into container, no need to wrap
|
||||
if (dimensions.width <= width) {
|
||||
return dimensions
|
||||
}
|
||||
|
||||
// This is happening when <Box> is shrinking child nodes and layout asks
|
||||
// if we can fit this text node in a <1px space, so we just say "no"
|
||||
if (dimensions.width >= 1 && width > 0 && width < 1) {
|
||||
return dimensions
|
||||
}
|
||||
|
||||
// For text with embedded newlines (pre-wrapped content), avoid re-wrapping
|
||||
// at measurement width when layout is asking for intrinsic size (Undefined mode).
|
||||
// This prevents height inflation during min/max size checks.
|
||||
//
|
||||
// However, when layout provides an actual constraint (Exactly or AtMost mode),
|
||||
// we must respect it and measure at that width. Otherwise, if the actual
|
||||
// rendering width is smaller than the natural width, the text will wrap to
|
||||
// more lines than layout expects, causing content to be truncated.
|
||||
if (text.includes('\n') && widthMode === LayoutMeasureMode.Undefined) {
|
||||
const effectiveWidth = Math.max(width, dimensions.width)
|
||||
|
||||
return measureText(text, effectiveWidth)
|
||||
}
|
||||
|
||||
const textWrap = node.style?.textWrap ?? 'wrap'
|
||||
const wrappedText = wrapText(text, width, textWrap)
|
||||
|
||||
return measureText(wrappedText, width)
|
||||
}
|
||||
|
||||
// ink-raw-ansi nodes hold pre-rendered ANSI strings with known dimensions.
|
||||
// No stringWidth, no wrapping, no tab expansion — the producer (e.g. ColorDiff)
|
||||
// already wrapped to the target width and each line is exactly one terminal row.
|
||||
const measureRawAnsiNode = function (node: DOMElement): {
|
||||
width: number
|
||||
height: number
|
||||
} {
|
||||
return {
|
||||
width: node.attributes['rawWidth'] as number,
|
||||
height: node.attributes['rawHeight'] as number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a node and all its ancestors as dirty for re-rendering.
|
||||
* Also marks yoga dirty for text remeasurement if this is a text node.
|
||||
*/
|
||||
export const markDirty = (node?: DOMNode): void => {
|
||||
let current: DOMNode | undefined = node
|
||||
let markedYoga = false
|
||||
|
||||
while (current) {
|
||||
if (current.nodeName !== '#text') {
|
||||
const elem = current as DOMElement
|
||||
elem.dirty = true
|
||||
|
||||
// Only mark yoga dirty on leaf nodes that have measure functions
|
||||
if (!markedYoga && (elem.nodeName === 'ink-text' || elem.nodeName === 'ink-raw-ansi') && elem.yogaNode) {
|
||||
elem.yogaNode.markDirty()
|
||||
markedYoga = true
|
||||
}
|
||||
|
||||
// Invalidate text measurement cache — child text or style changed.
|
||||
if (elem._textMeasureCache) {
|
||||
elem._textMeasureCache.gen++
|
||||
}
|
||||
}
|
||||
|
||||
current = current.parentNode
|
||||
}
|
||||
}
|
||||
|
||||
// Walk to root and call its onRender (the throttled scheduleRender). Use for
|
||||
// DOM-level mutations (scrollTop changes) that should trigger an Ink frame
|
||||
// without going through React's reconciler. Pair with markDirty() so the
|
||||
// renderer knows which subtree to re-evaluate.
|
||||
export const scheduleRenderFrom = (node?: DOMNode): void => {
|
||||
let cur: DOMNode | undefined = node
|
||||
|
||||
while (cur?.parentNode) {
|
||||
cur = cur.parentNode
|
||||
}
|
||||
|
||||
if (cur && cur.nodeName !== '#text') {
|
||||
;(cur as DOMElement).onRender?.()
|
||||
}
|
||||
}
|
||||
|
||||
export const setTextNodeValue = (node: TextNode, text: string): void => {
|
||||
if (typeof text !== 'string') {
|
||||
text = String(text)
|
||||
}
|
||||
|
||||
// Skip if unchanged
|
||||
if (node.nodeValue === text) {
|
||||
return
|
||||
}
|
||||
|
||||
node.nodeValue = text
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
function isDOMElement(node: DOMElement | TextNode): node is DOMElement {
|
||||
return node.nodeName !== '#text'
|
||||
}
|
||||
|
||||
// Clear yogaNode references recursively before freeing.
|
||||
// freeRecursive() frees the node and ALL its children, so we must clear
|
||||
// all yogaNode references to prevent dangling pointers.
|
||||
export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => {
|
||||
if ('childNodes' in node) {
|
||||
for (const child of node.childNodes) {
|
||||
clearYogaNodeReferences(child)
|
||||
}
|
||||
|
||||
node._textMeasureCache = undefined
|
||||
}
|
||||
|
||||
node.yogaNode = undefined
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Event } from './event.js'
|
||||
|
||||
/**
|
||||
* Mouse click event. Fired on left-button release without drag, only when
|
||||
* mouse tracking is enabled (i.e. inside <AlternateScreen>).
|
||||
*
|
||||
* Bubbles from the deepest hit node up through parentNode. Call
|
||||
* stopImmediatePropagation() to prevent ancestors' onClick from firing.
|
||||
*/
|
||||
export class ClickEvent extends Event {
|
||||
/** 0-indexed screen column of the click */
|
||||
readonly col: number
|
||||
/** 0-indexed screen row of the click */
|
||||
readonly row: number
|
||||
/**
|
||||
* Click column relative to the current handler's Box (col - box.x).
|
||||
* Recomputed by dispatchClick before each handler fires, so an onClick
|
||||
* on a container sees coords relative to that container, not to any
|
||||
* child the click landed on.
|
||||
*/
|
||||
localCol = 0
|
||||
/** Click row relative to the current handler's Box (row - box.y). */
|
||||
localRow = 0
|
||||
/**
|
||||
* True if the clicked cell has no visible content (unwritten in the
|
||||
* screen buffer — both packed words are 0). Handlers can check this to
|
||||
* ignore clicks on blank space to the right of text, so accidental
|
||||
* clicks on empty terminal space don't toggle state.
|
||||
*/
|
||||
readonly cellIsBlank: boolean
|
||||
|
||||
constructor(col: number, row: number, cellIsBlank: boolean) {
|
||||
super()
|
||||
this.col = col
|
||||
this.row = row
|
||||
this.cellIsBlank = cellIsBlank
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseMultipleKeypresses } from '../parse-keypress.js'
|
||||
|
||||
import { InputEvent } from './input-event.js'
|
||||
|
||||
function parseOne(sequence: string) {
|
||||
const [keys] = parseMultipleKeypresses({ incomplete: '', mode: 'NORMAL' }, sequence)
|
||||
expect(keys).toHaveLength(1)
|
||||
|
||||
return keys[0]!
|
||||
}
|
||||
|
||||
describe('enhanced keyboard modifier parsing', () => {
|
||||
it('detects modified Enter sequences for multiline composer shortcuts', () => {
|
||||
const shiftEnter = new InputEvent(parseOne('\u001b[13;2u'))
|
||||
const ctrlEnter = new InputEvent(parseOne('\u001b[13;5u'))
|
||||
const modifyOtherShiftEnter = new InputEvent(parseOne('\u001b[27;2;13~'))
|
||||
|
||||
expect(shiftEnter.key.return).toBe(true)
|
||||
expect(shiftEnter.key.shift).toBe(true)
|
||||
expect(shiftEnter.input).toBe('')
|
||||
|
||||
expect(ctrlEnter.key.return).toBe(true)
|
||||
expect(ctrlEnter.key.ctrl).toBe(true)
|
||||
expect(ctrlEnter.input).toBe('')
|
||||
|
||||
expect(modifyOtherShiftEnter.key.return).toBe(true)
|
||||
expect(modifyOtherShiftEnter.key.shift).toBe(true)
|
||||
expect(modifyOtherShiftEnter.input).toBe('')
|
||||
})
|
||||
|
||||
it('preserves Cmd as super for kitty keyboard CSI-u sequences', () => {
|
||||
const parsed = parseOne('\u001b[99;9u')
|
||||
const event = new InputEvent(parsed)
|
||||
|
||||
expect(parsed.name).toBe('c')
|
||||
expect(event.key.meta).toBe(false)
|
||||
expect(event.key.super).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves forwarded VS Code/Cursor Cmd+C copy sequence as ctrl+super+c', () => {
|
||||
const parsed = parseOne('\u001b[99;13u')
|
||||
const event = new InputEvent(parsed)
|
||||
|
||||
expect(parsed.name).toBe('c')
|
||||
expect(event.key.ctrl).toBe(true)
|
||||
expect(event.key.super).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves Cmd on word-delete and word-navigation sequences', () => {
|
||||
const backspace = new InputEvent(parseOne('\u001b[127;9u'))
|
||||
const left = new InputEvent(parseOne('\u001b[1;9D'))
|
||||
const right = new InputEvent(parseOne('\u001b[1;9C'))
|
||||
|
||||
expect(backspace.key.backspace).toBe(true)
|
||||
expect(backspace.key.super).toBe(true)
|
||||
|
||||
expect(left.key.leftArrow).toBe(true)
|
||||
expect(left.key.super).toBe(true)
|
||||
|
||||
expect(right.key.rightArrow).toBe(true)
|
||||
expect(right.key.super).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
ContinuousEventPriority,
|
||||
DefaultEventPriority,
|
||||
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'
|
||||
|
||||
// --
|
||||
|
||||
type DispatchListener = {
|
||||
node: EventTarget
|
||||
handler: (event: TerminalEvent) => void
|
||||
phase: 'capturing' | 'at_target' | 'bubbling'
|
||||
}
|
||||
|
||||
function getHandler(
|
||||
node: EventTarget,
|
||||
eventType: string,
|
||||
capture: boolean
|
||||
): ((event: TerminalEvent) => void) | undefined {
|
||||
const handlers = node._eventHandlers
|
||||
|
||||
if (!handlers) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const mapping = HANDLER_FOR_EVENT[eventType]
|
||||
|
||||
if (!mapping) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const propName = capture ? mapping.capture : mapping.bubble
|
||||
|
||||
if (!propName) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return handlers[propName] as ((event: TerminalEvent) => void) | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all listeners for an event in dispatch order.
|
||||
*
|
||||
* Uses react-dom's two-phase accumulation pattern:
|
||||
* - Walk from target to root
|
||||
* - Capture handlers are prepended (unshift) → root-first
|
||||
* - Bubble handlers are appended (push) → target-first
|
||||
*
|
||||
* Result: [root-cap, ..., parent-cap, target-cap, target-bub, parent-bub, ..., root-bub]
|
||||
*/
|
||||
function collectListeners(target: EventTarget, event: TerminalEvent): DispatchListener[] {
|
||||
const listeners: DispatchListener[] = []
|
||||
|
||||
let node: EventTarget | undefined = target
|
||||
|
||||
while (node) {
|
||||
const isTarget = node === target
|
||||
|
||||
const captureHandler = getHandler(node, event.type, true)
|
||||
const bubbleHandler = getHandler(node, event.type, false)
|
||||
|
||||
if (captureHandler) {
|
||||
listeners.unshift({
|
||||
node,
|
||||
handler: captureHandler,
|
||||
phase: isTarget ? 'at_target' : 'capturing'
|
||||
})
|
||||
}
|
||||
|
||||
if (bubbleHandler && (event.bubbles || isTarget)) {
|
||||
listeners.push({
|
||||
node,
|
||||
handler: bubbleHandler,
|
||||
phase: isTarget ? 'at_target' : 'bubbling'
|
||||
})
|
||||
}
|
||||
|
||||
node = node.parentNode
|
||||
}
|
||||
|
||||
return listeners
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute collected listeners with propagation control.
|
||||
*
|
||||
* Before each handler, calls event._prepareForTarget(node) so event
|
||||
* subclasses can do per-node setup.
|
||||
*/
|
||||
function processDispatchQueue(listeners: DispatchListener[], event: TerminalEvent): void {
|
||||
let previousNode: EventTarget | undefined
|
||||
|
||||
for (const { node, handler, phase } of listeners) {
|
||||
if (event._isImmediatePropagationStopped()) {
|
||||
break
|
||||
}
|
||||
|
||||
if (event._isPropagationStopped() && node !== previousNode) {
|
||||
break
|
||||
}
|
||||
|
||||
event._setEventPhase(phase)
|
||||
event._setCurrentTarget(node)
|
||||
event._prepareForTarget(node)
|
||||
|
||||
try {
|
||||
handler(event)
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
}
|
||||
|
||||
previousNode = node
|
||||
}
|
||||
}
|
||||
|
||||
// --
|
||||
|
||||
/**
|
||||
* Map terminal event types to React scheduling priorities.
|
||||
* Mirrors react-dom's getEventPriority() switch.
|
||||
*/
|
||||
function getEventPriority(eventType: string): number {
|
||||
switch (eventType) {
|
||||
case 'keydown':
|
||||
|
||||
case 'keyup':
|
||||
|
||||
case 'click':
|
||||
|
||||
case 'focus':
|
||||
|
||||
case 'blur':
|
||||
|
||||
case 'paste':
|
||||
return DiscreteEventPriority as number
|
||||
|
||||
case 'resize':
|
||||
|
||||
case 'scroll':
|
||||
|
||||
case 'mousemove':
|
||||
return ContinuousEventPriority as number
|
||||
|
||||
default:
|
||||
return DefaultEventPriority as number
|
||||
}
|
||||
}
|
||||
|
||||
// --
|
||||
|
||||
type DiscreteUpdates = <A, B>(fn: (a: A, b: B) => boolean, a: A, b: B, c: undefined, d: undefined) => boolean
|
||||
|
||||
/**
|
||||
* Owns event dispatch state and the capture/bubble dispatch loop.
|
||||
*
|
||||
* The reconciler host config reads currentEvent and currentUpdatePriority
|
||||
* to implement resolveUpdatePriority, resolveEventType, and
|
||||
* resolveEventTimeStamp — mirroring how react-dom's host config reads
|
||||
* ReactDOMSharedInternals and window.event.
|
||||
*
|
||||
* discreteUpdates is injected after construction (by InkReconciler)
|
||||
* to break the import cycle.
|
||||
*/
|
||||
export class Dispatcher {
|
||||
currentEvent: TerminalEvent | null = null
|
||||
currentUpdatePriority: number = DefaultEventPriority as number
|
||||
discreteUpdates: DiscreteUpdates | null = null
|
||||
|
||||
/**
|
||||
* Infer event priority from the currently-dispatching event.
|
||||
* Called by the reconciler host config's resolveUpdatePriority
|
||||
* when no explicit priority has been set.
|
||||
*/
|
||||
resolveEventPriority(): number {
|
||||
if (this.currentUpdatePriority !== (NoEventPriority as number)) {
|
||||
return this.currentUpdatePriority
|
||||
}
|
||||
|
||||
if (this.currentEvent) {
|
||||
return getEventPriority(this.currentEvent.type)
|
||||
}
|
||||
|
||||
return DefaultEventPriority as number
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event through capture and bubble phases.
|
||||
* Returns true if preventDefault() was NOT called.
|
||||
*/
|
||||
dispatch(target: EventTarget, event: TerminalEvent): boolean {
|
||||
const previousEvent = this.currentEvent
|
||||
this.currentEvent = event
|
||||
|
||||
try {
|
||||
event._setTarget(target)
|
||||
|
||||
const listeners = collectListeners(target, event)
|
||||
processDispatchQueue(listeners, event)
|
||||
|
||||
event._setEventPhase('none')
|
||||
event._setCurrentTarget(null)
|
||||
|
||||
return !event.defaultPrevented
|
||||
} finally {
|
||||
this.currentEvent = previousEvent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch with discrete (sync) priority.
|
||||
* For user-initiated events: keyboard, click, focus, paste.
|
||||
*/
|
||||
dispatchDiscrete(target: EventTarget, event: TerminalEvent): boolean {
|
||||
if (!this.discreteUpdates) {
|
||||
return this.dispatch(target, event)
|
||||
}
|
||||
|
||||
return this.discreteUpdates((t, e) => this.dispatch(t, e), target, event, undefined, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch with continuous priority.
|
||||
* For high-frequency events: resize, scroll, mouse move.
|
||||
*/
|
||||
dispatchContinuous(target: EventTarget, event: TerminalEvent): boolean {
|
||||
const previousPriority = this.currentUpdatePriority
|
||||
|
||||
try {
|
||||
this.currentUpdatePriority = ContinuousEventPriority as number
|
||||
|
||||
return this.dispatch(target, event)
|
||||
} finally {
|
||||
this.currentUpdatePriority = previousPriority
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { EventEmitter as NodeEventEmitter } from 'events'
|
||||
|
||||
import { Event } from './event.js'
|
||||
|
||||
// Similar to node's builtin EventEmitter, but is also aware of our `Event`
|
||||
// class, and so `emit` respects `stopImmediatePropagation()`.
|
||||
export class EventEmitter extends NodeEventEmitter {
|
||||
constructor() {
|
||||
super()
|
||||
// Disable the default maxListeners warning. In React, many components
|
||||
// can legitimately listen to the same event (e.g., useInput hooks).
|
||||
// The default limit of 10 causes spurious warnings.
|
||||
this.setMaxListeners(0)
|
||||
}
|
||||
|
||||
override emit(type: string | symbol, ...args: unknown[]): boolean {
|
||||
// Delegate to node for `error`, since it's not treated like a normal event
|
||||
if (type === 'error') {
|
||||
return super.emit(type, ...args)
|
||||
}
|
||||
|
||||
const listeners = this.rawListeners(type)
|
||||
|
||||
if (listeners.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ccEvent = args[0] instanceof Event ? args[0] : null
|
||||
|
||||
for (const listener of listeners) {
|
||||
listener.apply(this, args)
|
||||
|
||||
if (ccEvent?.didStopImmediatePropagation()) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ClickEvent } from './click-event.js'
|
||||
import type { FocusEvent } from './focus-event.js'
|
||||
import type { KeyboardEvent } from './keyboard-event.js'
|
||||
import type { MouseEvent } from './mouse-event.js'
|
||||
import type { PasteEvent } from './paste-event.js'
|
||||
import type { ResizeEvent } from './resize-event.js'
|
||||
|
||||
type KeyboardEventHandler = (event: KeyboardEvent) => void
|
||||
type FocusEventHandler = (event: FocusEvent) => void
|
||||
type PasteEventHandler = (event: PasteEvent) => void
|
||||
type ResizeEventHandler = (event: ResizeEvent) => void
|
||||
type ClickEventHandler = (event: ClickEvent) => void
|
||||
type MouseEventHandler = (event: MouseEvent) => void
|
||||
type HoverEventHandler = () => void
|
||||
|
||||
/**
|
||||
* Props for event handlers on Box and other host components.
|
||||
*
|
||||
* Follows the React/DOM naming convention:
|
||||
* - onEventName: handler for bubble phase
|
||||
* - onEventNameCapture: handler for capture phase
|
||||
*/
|
||||
export type EventHandlerProps = {
|
||||
onKeyDown?: KeyboardEventHandler
|
||||
onKeyDownCapture?: KeyboardEventHandler
|
||||
|
||||
onFocus?: FocusEventHandler
|
||||
onFocusCapture?: FocusEventHandler
|
||||
onBlur?: FocusEventHandler
|
||||
onBlurCapture?: FocusEventHandler
|
||||
|
||||
onPaste?: PasteEventHandler
|
||||
onPasteCapture?: PasteEventHandler
|
||||
|
||||
onResize?: ResizeEventHandler
|
||||
|
||||
onClick?: ClickEventHandler
|
||||
onMouseDown?: MouseEventHandler
|
||||
onMouseUp?: MouseEventHandler
|
||||
onMouseDrag?: MouseEventHandler
|
||||
onMouseEnter?: HoverEventHandler
|
||||
onMouseLeave?: HoverEventHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse lookup: event type string → handler prop names.
|
||||
* Used by the dispatcher for O(1) handler lookup per node.
|
||||
*/
|
||||
export const HANDLER_FOR_EVENT: Record<
|
||||
string,
|
||||
{ bubble?: keyof EventHandlerProps; capture?: keyof EventHandlerProps }
|
||||
> = {
|
||||
keydown: { bubble: 'onKeyDown', capture: 'onKeyDownCapture' },
|
||||
focus: { bubble: 'onFocus', capture: 'onFocusCapture' },
|
||||
blur: { bubble: 'onBlur', capture: 'onBlurCapture' },
|
||||
paste: { bubble: 'onPaste', capture: 'onPasteCapture' },
|
||||
resize: { bubble: 'onResize' },
|
||||
click: { bubble: 'onClick' },
|
||||
mousedown: { bubble: 'onMouseDown' },
|
||||
mouseup: { bubble: 'onMouseUp' },
|
||||
mousedrag: { bubble: 'onMouseDrag' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set of all event handler prop names, for the reconciler to detect
|
||||
* event props and store them in _eventHandlers instead of attributes.
|
||||
*/
|
||||
export const EVENT_HANDLER_PROPS = new Set<string>([
|
||||
'onKeyDown',
|
||||
'onKeyDownCapture',
|
||||
'onFocus',
|
||||
'onFocusCapture',
|
||||
'onBlur',
|
||||
'onBlurCapture',
|
||||
'onPaste',
|
||||
'onPasteCapture',
|
||||
'onResize',
|
||||
'onClick',
|
||||
'onMouseDown',
|
||||
'onMouseUp',
|
||||
'onMouseDrag',
|
||||
'onMouseEnter',
|
||||
'onMouseLeave'
|
||||
])
|
||||
@@ -0,0 +1,11 @@
|
||||
export class Event {
|
||||
private _didStopImmediatePropagation = false
|
||||
|
||||
didStopImmediatePropagation(): boolean {
|
||||
return this._didStopImmediatePropagation
|
||||
}
|
||||
|
||||
stopImmediatePropagation(): void {
|
||||
this._didStopImmediatePropagation = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { type EventTarget, TerminalEvent } from './terminal-event.js'
|
||||
|
||||
/**
|
||||
* Focus event for component focus changes.
|
||||
*
|
||||
* Dispatched when focus moves between elements. 'focus' fires on the
|
||||
* newly focused element, 'blur' fires on the previously focused one.
|
||||
* Both bubble, matching react-dom's use of focusin/focusout semantics
|
||||
* so parent components can observe descendant focus changes.
|
||||
*/
|
||||
export class FocusEvent extends TerminalEvent {
|
||||
readonly relatedTarget: EventTarget | null
|
||||
|
||||
constructor(type: 'focus' | 'blur', relatedTarget: EventTarget | null = null) {
|
||||
super(type, { bubbles: true, cancelable: false })
|
||||
this.relatedTarget = relatedTarget
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { nonAlphanumericKeys, type ParsedKey } from '../parse-keypress.js'
|
||||
|
||||
import { Event } from './event.js'
|
||||
|
||||
const inputForSpecialSequence = (name: string): string =>
|
||||
name === 'space' ? ' ' : name === 'return' || name === 'escape' ? '' : name
|
||||
|
||||
export type Key = {
|
||||
upArrow: boolean
|
||||
downArrow: boolean
|
||||
leftArrow: boolean
|
||||
rightArrow: boolean
|
||||
pageDown: boolean
|
||||
pageUp: boolean
|
||||
wheelUp: boolean
|
||||
wheelDown: boolean
|
||||
home: boolean
|
||||
end: boolean
|
||||
return: boolean
|
||||
escape: boolean
|
||||
ctrl: boolean
|
||||
shift: boolean
|
||||
fn: boolean
|
||||
tab: boolean
|
||||
backspace: boolean
|
||||
delete: boolean
|
||||
meta: boolean
|
||||
super: boolean
|
||||
}
|
||||
|
||||
function parseKey(keypress: ParsedKey): [Key, string] {
|
||||
const key: Key = {
|
||||
upArrow: keypress.name === 'up',
|
||||
downArrow: keypress.name === 'down',
|
||||
leftArrow: keypress.name === 'left',
|
||||
rightArrow: keypress.name === 'right',
|
||||
pageDown: keypress.name === 'pagedown',
|
||||
pageUp: keypress.name === 'pageup',
|
||||
wheelUp: keypress.name === 'wheelup',
|
||||
wheelDown: keypress.name === 'wheeldown',
|
||||
home: keypress.name === 'home',
|
||||
end: keypress.name === 'end',
|
||||
return: keypress.name === 'return',
|
||||
escape: keypress.name === 'escape',
|
||||
fn: keypress.fn,
|
||||
ctrl: keypress.ctrl,
|
||||
shift: keypress.shift,
|
||||
tab: keypress.name === 'tab',
|
||||
backspace: keypress.name === 'backspace',
|
||||
delete: keypress.name === 'delete',
|
||||
// `parseKeypress` parses \u001B\u001B[A (meta + up arrow) as meta = false
|
||||
// but with option = true, so we need to take this into account here
|
||||
// to avoid breaking changes in Ink.
|
||||
// TODO(vadimdemedes): consider removing this in the next major version.
|
||||
meta: keypress.meta || keypress.name === 'escape' || keypress.option,
|
||||
// Super (Cmd on macOS / Win key) — only arrives via kitty keyboard
|
||||
// protocol CSI u sequences. Distinct from meta (Alt/Option) so
|
||||
// bindings like cmd+c can be expressed separately from opt+c.
|
||||
super: keypress.super
|
||||
}
|
||||
|
||||
let input = keypress.ctrl ? keypress.name : keypress.sequence
|
||||
|
||||
// Handle undefined input case
|
||||
if (input === undefined) {
|
||||
input = ''
|
||||
}
|
||||
|
||||
// When ctrl is set, keypress.name for space is the literal word "space".
|
||||
// Convert to actual space character for consistency with the CSI u branch
|
||||
// (which maps 'space' → ' '). Without this, ctrl+space leaks the literal
|
||||
// word "space" into text input.
|
||||
if (keypress.ctrl && input === 'space') {
|
||||
input = ' '
|
||||
}
|
||||
|
||||
// Suppress unrecognized escape sequences that were parsed as function keys
|
||||
// (matched by FN_KEY_RE) but have no name in the keyName map.
|
||||
// Examples: ESC[25~ (F13/Right Alt on Windows), ESC[26~ (F14), etc.
|
||||
// Without this, the ESC prefix is stripped below and the remainder (e.g.,
|
||||
// "[25~") leaks into the input as literal text.
|
||||
if (keypress.code && !keypress.name) {
|
||||
input = ''
|
||||
}
|
||||
|
||||
// (SGR mouse-report fragments used to be scrubbed here. They no longer reach
|
||||
// this layer: the tokenizer keeps an incomplete CSI buffered across a
|
||||
// watchdog flush and reassembles it on the next feed instead of force-
|
||||
// emitting the partial as input. See termio/tokenize.ts.)
|
||||
|
||||
// Strip meta if it's still remaining after `parseKeypress`
|
||||
// TODO(vadimdemedes): remove this in the next major version.
|
||||
if (input.startsWith('\u001B')) {
|
||||
input = input.slice(1)
|
||||
}
|
||||
|
||||
// Track whether we've already processed this as a special sequence
|
||||
// that converted input to the key name (CSI u or application keypad mode).
|
||||
// For these, we don't want to clear input with nonAlphanumericKeys check.
|
||||
let processedAsSpecialSequence = false
|
||||
|
||||
// Handle CSI u sequences (Kitty keyboard protocol): after stripping ESC,
|
||||
// we're left with "[codepoint;modifieru" (e.g., "[98;3u" for Alt+b).
|
||||
// Use the parsed key name instead for input handling. Require a digit
|
||||
// after [ — real CSI u is always [<digits>…u, and a bare startsWith('[')
|
||||
// false-matches X10 mouse at row 85 (Cy = 85+32 = 'u'), leaking the
|
||||
// literal text "mouse" into the prompt via processedAsSpecialSequence.
|
||||
if (/^\[\d/.test(input) && input.endsWith('u')) {
|
||||
if (!keypress.name) {
|
||||
// Unmapped Kitty functional key (Caps Lock 57358, F13–F35, KP nav,
|
||||
// bare modifiers, etc.) — keycodeToName() returned undefined. Swallow
|
||||
// so the raw "[57358u" doesn't leak into the prompt. See #38781.
|
||||
input = ''
|
||||
} else {
|
||||
input = inputForSpecialSequence(keypress.name)
|
||||
}
|
||||
|
||||
processedAsSpecialSequence = true
|
||||
}
|
||||
|
||||
// Handle xterm modifyOtherKeys sequences: after stripping ESC, we're left
|
||||
// with "[27;modifier;keycode~" (e.g., "[27;3;98~" for Alt+b). Same
|
||||
// extraction as CSI u — without this, printable-char keycodes (single-letter
|
||||
// names) skip the nonAlphanumericKeys clear and leak "[27;..." as input.
|
||||
if (input.startsWith('[27;') && input.endsWith('~')) {
|
||||
if (!keypress.name) {
|
||||
// Unmapped modifyOtherKeys keycode — swallow for consistency with
|
||||
// the CSI u handler above. Practically untriggerable today (xterm
|
||||
// modifyOtherKeys only sends ASCII keycodes, all mapped), but
|
||||
// guards against future terminal behavior.
|
||||
input = ''
|
||||
} else {
|
||||
input = inputForSpecialSequence(keypress.name)
|
||||
}
|
||||
|
||||
processedAsSpecialSequence = true
|
||||
}
|
||||
|
||||
// Handle application keypad mode sequences: after stripping ESC,
|
||||
// we're left with "O<letter>" (e.g., "Op" for numpad 0, "Oy" for numpad 9).
|
||||
// Use the parsed key name (the digit character) for input handling.
|
||||
if (input.startsWith('O') && input.length === 2 && keypress.name && keypress.name.length === 1) {
|
||||
input = keypress.name
|
||||
processedAsSpecialSequence = true
|
||||
}
|
||||
|
||||
// Clear input for non-alphanumeric keys (arrows, function keys, etc.)
|
||||
// Skip this for CSI u and application keypad mode sequences since
|
||||
// those were already converted to their proper input characters.
|
||||
if (!processedAsSpecialSequence && keypress.name && nonAlphanumericKeys.includes(keypress.name)) {
|
||||
input = ''
|
||||
}
|
||||
|
||||
// Set shift=true for uppercase letters (A-Z)
|
||||
// Must check it's actually a letter, not just any char unchanged by toUpperCase
|
||||
if (input.length === 1 && typeof input[0] === 'string' && input[0] >= 'A' && input[0] <= 'Z') {
|
||||
key.shift = true
|
||||
}
|
||||
|
||||
return [key, input]
|
||||
}
|
||||
|
||||
export class InputEvent extends Event {
|
||||
readonly keypress: ParsedKey
|
||||
readonly key: Key
|
||||
readonly input: string
|
||||
|
||||
constructor(keypress: ParsedKey) {
|
||||
super()
|
||||
const [key, input] = parseKey(keypress)
|
||||
|
||||
this.keypress = keypress
|
||||
this.key = key
|
||||
this.input = input
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ParsedKey } from '../parse-keypress.js'
|
||||
|
||||
import { TerminalEvent } from './terminal-event.js'
|
||||
|
||||
/**
|
||||
* Keyboard event dispatched through the DOM tree via capture/bubble.
|
||||
*
|
||||
* Follows browser KeyboardEvent semantics: `key` is the literal character
|
||||
* for printable keys ('a', '3', ' ', '/') and a multi-char name for
|
||||
* special keys ('down', 'return', 'escape', 'f1'). The idiomatic
|
||||
* printable-char check is `e.key.length === 1`.
|
||||
*/
|
||||
export class KeyboardEvent extends TerminalEvent {
|
||||
readonly key: string
|
||||
readonly ctrl: boolean
|
||||
readonly shift: boolean
|
||||
readonly meta: boolean
|
||||
readonly superKey: boolean
|
||||
readonly fn: boolean
|
||||
|
||||
constructor(parsedKey: ParsedKey) {
|
||||
super('keydown', { bubbles: true, cancelable: true })
|
||||
|
||||
this.key = keyFromParsed(parsedKey)
|
||||
this.ctrl = parsedKey.ctrl
|
||||
this.shift = parsedKey.shift
|
||||
this.meta = parsedKey.meta || parsedKey.option
|
||||
this.superKey = parsedKey.super
|
||||
this.fn = parsedKey.fn
|
||||
}
|
||||
}
|
||||
|
||||
function keyFromParsed(parsed: ParsedKey): string {
|
||||
const seq = parsed.sequence ?? ''
|
||||
const name = parsed.name ?? ''
|
||||
|
||||
// Ctrl combos: sequence is a control byte (\x03 for ctrl+c), name is the
|
||||
// letter. Browsers report e.key === 'c' with e.ctrlKey === true.
|
||||
if (parsed.ctrl) {
|
||||
return name
|
||||
}
|
||||
|
||||
// Single printable char (space through ~, plus anything above ASCII):
|
||||
// use the literal char. Browsers report e.key === '3', not 'Digit3'.
|
||||
if (seq.length === 1) {
|
||||
const code = seq.charCodeAt(0)
|
||||
|
||||
if (code >= 0x20 && code !== 0x7f) {
|
||||
return seq
|
||||
}
|
||||
}
|
||||
|
||||
// Special keys (arrows, F-keys, return, tab, escape, etc.): sequence is
|
||||
// either an escape sequence (\x1b[B) or a control byte (\r, \t), so use
|
||||
// the parsed name. Browsers report e.key === 'ArrowDown'.
|
||||
return name || seq
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Event } from './event.js'
|
||||
|
||||
export class MouseEvent extends Event {
|
||||
readonly col: number
|
||||
readonly row: number
|
||||
localCol = 0
|
||||
localRow = 0
|
||||
readonly cellIsBlank: boolean
|
||||
readonly button: number
|
||||
|
||||
constructor(col: number, row: number, cellIsBlank: boolean, button: number) {
|
||||
super()
|
||||
this.col = col
|
||||
this.row = row
|
||||
this.cellIsBlank = cellIsBlank
|
||||
this.button = button
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { TerminalEvent } from './terminal-event.js'
|
||||
|
||||
export class PasteEvent extends TerminalEvent {
|
||||
readonly text: string
|
||||
|
||||
constructor(text: string) {
|
||||
super('paste', { bubbles: true, cancelable: true })
|
||||
this.text = text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { TerminalEvent } from './terminal-event.js'
|
||||
|
||||
export class ResizeEvent extends TerminalEvent {
|
||||
readonly columns: number
|
||||
readonly rows: number
|
||||
|
||||
constructor(columns: number, rows: number) {
|
||||
super('resize', { bubbles: true, cancelable: true })
|
||||
this.columns = columns
|
||||
this.rows = rows
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Event } from './event.js'
|
||||
|
||||
type EventPhase = 'none' | 'capturing' | 'at_target' | 'bubbling'
|
||||
|
||||
type TerminalEventInit = {
|
||||
bubbles?: boolean
|
||||
cancelable?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for all terminal events with DOM-style propagation.
|
||||
*
|
||||
* Extends Event so existing event types (ClickEvent, InputEvent,
|
||||
* TerminalFocusEvent) share a common ancestor and can migrate later.
|
||||
*
|
||||
* Mirrors the browser's Event API: target, currentTarget, eventPhase,
|
||||
* stopPropagation(), preventDefault(), timeStamp.
|
||||
*/
|
||||
export class TerminalEvent extends Event {
|
||||
readonly type: string
|
||||
readonly timeStamp: number
|
||||
readonly bubbles: boolean
|
||||
readonly cancelable: boolean
|
||||
|
||||
private _target: EventTarget | null = null
|
||||
private _currentTarget: EventTarget | null = null
|
||||
private _eventPhase: EventPhase = 'none'
|
||||
private _propagationStopped = false
|
||||
private _defaultPrevented = false
|
||||
|
||||
constructor(type: string, init?: TerminalEventInit) {
|
||||
super()
|
||||
this.type = type
|
||||
this.timeStamp = performance.now()
|
||||
this.bubbles = init?.bubbles ?? true
|
||||
this.cancelable = init?.cancelable ?? true
|
||||
}
|
||||
|
||||
get target(): EventTarget | null {
|
||||
return this._target
|
||||
}
|
||||
|
||||
get currentTarget(): EventTarget | null {
|
||||
return this._currentTarget
|
||||
}
|
||||
|
||||
get eventPhase(): EventPhase {
|
||||
return this._eventPhase
|
||||
}
|
||||
|
||||
get defaultPrevented(): boolean {
|
||||
return this._defaultPrevented
|
||||
}
|
||||
|
||||
stopPropagation(): void {
|
||||
this._propagationStopped = true
|
||||
}
|
||||
|
||||
override stopImmediatePropagation(): void {
|
||||
super.stopImmediatePropagation()
|
||||
this._propagationStopped = true
|
||||
}
|
||||
|
||||
preventDefault(): void {
|
||||
if (this.cancelable) {
|
||||
this._defaultPrevented = true
|
||||
}
|
||||
}
|
||||
|
||||
// -- Internal setters used by the Dispatcher
|
||||
|
||||
/** @internal */
|
||||
_setTarget(target: EventTarget): void {
|
||||
this._target = target
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_setCurrentTarget(target: EventTarget | null): void {
|
||||
this._currentTarget = target
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_setEventPhase(phase: EventPhase): void {
|
||||
this._eventPhase = phase
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_isPropagationStopped(): boolean {
|
||||
return this._propagationStopped
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_isImmediatePropagationStopped(): boolean {
|
||||
return this.didStopImmediatePropagation()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for subclasses to do per-node setup before each handler fires.
|
||||
* Default is a no-op.
|
||||
*/
|
||||
_prepareForTarget(_target: EventTarget): void {}
|
||||
}
|
||||
|
||||
export type EventTarget = {
|
||||
parentNode: EventTarget | undefined
|
||||
_eventHandlers?: Record<string, unknown>
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Event } from './event.js'
|
||||
|
||||
export type TerminalFocusEventType = 'terminalfocus' | 'terminalblur'
|
||||
|
||||
/**
|
||||
* Event fired when the terminal window gains or loses focus.
|
||||
*
|
||||
* Uses DECSET 1004 focus reporting - the terminal sends:
|
||||
* - CSI I (\x1b[I) when the terminal gains focus
|
||||
* - CSI O (\x1b[O) when the terminal loses focus
|
||||
*/
|
||||
export class TerminalFocusEvent extends Event {
|
||||
readonly type: TerminalFocusEventType
|
||||
|
||||
constructor(type: TerminalFocusEventType) {
|
||||
super()
|
||||
this.type = type
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { DOMElement } from './dom.js'
|
||||
import { FocusEvent } from './events/focus-event.js'
|
||||
|
||||
const MAX_FOCUS_STACK = 32
|
||||
|
||||
/**
|
||||
* DOM-like focus manager for the Ink terminal UI.
|
||||
*
|
||||
* Pure state — tracks activeElement and a focus stack. Has no reference
|
||||
* to the tree; callers pass the root when tree walks are needed.
|
||||
*
|
||||
* Stored on the root DOMElement so any node can reach it by walking
|
||||
* parentNode (like browser's `node.ownerDocument`).
|
||||
*/
|
||||
export class FocusManager {
|
||||
activeElement: DOMElement | null = null
|
||||
private dispatchFocusEvent: (target: DOMElement, event: FocusEvent) => boolean
|
||||
private enabled = true
|
||||
private focusStack: DOMElement[] = []
|
||||
|
||||
constructor(dispatchFocusEvent: (target: DOMElement, event: FocusEvent) => boolean) {
|
||||
this.dispatchFocusEvent = dispatchFocusEvent
|
||||
}
|
||||
|
||||
focus(node: DOMElement): void {
|
||||
if (node === this.activeElement) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const previous = this.activeElement
|
||||
|
||||
if (previous) {
|
||||
// Deduplicate before pushing to prevent unbounded growth from Tab cycling
|
||||
const idx = this.focusStack.indexOf(previous)
|
||||
|
||||
if (idx !== -1) {
|
||||
this.focusStack.splice(idx, 1)
|
||||
}
|
||||
|
||||
this.focusStack.push(previous)
|
||||
|
||||
if (this.focusStack.length > MAX_FOCUS_STACK) {
|
||||
this.focusStack.shift()
|
||||
}
|
||||
|
||||
this.dispatchFocusEvent(previous, new FocusEvent('blur', node))
|
||||
}
|
||||
|
||||
this.activeElement = node
|
||||
this.dispatchFocusEvent(node, new FocusEvent('focus', previous))
|
||||
}
|
||||
|
||||
blur(): void {
|
||||
if (!this.activeElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const previous = this.activeElement
|
||||
this.activeElement = null
|
||||
this.dispatchFocusEvent(previous, new FocusEvent('blur', null))
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the reconciler when a node is removed from the tree.
|
||||
* Handles both the exact node and any focused descendant within
|
||||
* the removed subtree. Dispatches blur and restores focus from stack.
|
||||
*/
|
||||
handleNodeRemoved(node: DOMElement, root: DOMElement): void {
|
||||
// Remove the node and any descendants from the stack
|
||||
this.focusStack = this.focusStack.filter(n => n !== node && isInTree(n, root))
|
||||
|
||||
// Check if activeElement is the removed node OR a descendant
|
||||
if (!this.activeElement) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.activeElement !== node && isInTree(this.activeElement, root)) {
|
||||
return
|
||||
}
|
||||
|
||||
const removed = this.activeElement
|
||||
this.activeElement = null
|
||||
this.dispatchFocusEvent(removed, new FocusEvent('blur', null))
|
||||
|
||||
// Restore focus to the most recent still-mounted element
|
||||
while (this.focusStack.length > 0) {
|
||||
const candidate = this.focusStack.pop()!
|
||||
|
||||
if (isInTree(candidate, root)) {
|
||||
this.activeElement = candidate
|
||||
this.dispatchFocusEvent(candidate, new FocusEvent('focus', removed))
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleAutoFocus(node: DOMElement): void {
|
||||
this.focus(node)
|
||||
}
|
||||
|
||||
handleClickFocus(node: DOMElement): void {
|
||||
const tabIndex = node.attributes['tabIndex']
|
||||
|
||||
if (typeof tabIndex !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
this.focus(node)
|
||||
}
|
||||
|
||||
enable(): void {
|
||||
this.enabled = true
|
||||
}
|
||||
|
||||
disable(): void {
|
||||
this.enabled = false
|
||||
}
|
||||
|
||||
focusNext(root: DOMElement): void {
|
||||
this.moveFocus(1, root)
|
||||
}
|
||||
|
||||
focusPrevious(root: DOMElement): void {
|
||||
this.moveFocus(-1, root)
|
||||
}
|
||||
|
||||
private moveFocus(direction: 1 | -1, root: DOMElement): void {
|
||||
if (!this.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const tabbable = collectTabbable(root)
|
||||
|
||||
if (tabbable.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentIndex = this.activeElement ? tabbable.indexOf(this.activeElement) : -1
|
||||
|
||||
const nextIndex =
|
||||
currentIndex === -1
|
||||
? direction === 1
|
||||
? 0
|
||||
: tabbable.length - 1
|
||||
: (currentIndex + direction + tabbable.length) % tabbable.length
|
||||
|
||||
const next = tabbable[nextIndex]
|
||||
|
||||
if (next) {
|
||||
this.focus(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectTabbable(root: DOMElement): DOMElement[] {
|
||||
const result: DOMElement[] = []
|
||||
walkTree(root, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function walkTree(node: DOMElement, result: DOMElement[]): void {
|
||||
const tabIndex = node.attributes['tabIndex']
|
||||
|
||||
if (typeof tabIndex === 'number' && tabIndex >= 0) {
|
||||
result.push(node)
|
||||
}
|
||||
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeName !== '#text') {
|
||||
walkTree(child, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isInTree(node: DOMElement, root: DOMElement): boolean {
|
||||
let current: DOMElement | undefined = node
|
||||
|
||||
while (current) {
|
||||
if (current === root) {
|
||||
return true
|
||||
}
|
||||
|
||||
current = current.parentNode
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up to root and return it. The root is the node that holds
|
||||
* the FocusManager — like browser's `node.getRootNode()`.
|
||||
*/
|
||||
export function getRootNode(node: DOMElement): DOMElement {
|
||||
let current: DOMElement | undefined = node
|
||||
|
||||
while (current) {
|
||||
if (current.focusManager) {
|
||||
return current
|
||||
}
|
||||
|
||||
current = current.parentNode
|
||||
}
|
||||
|
||||
throw new Error('Node is not in a tree with a FocusManager')
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up to root and return its FocusManager.
|
||||
* Like browser's `node.ownerDocument` — focus belongs to the root.
|
||||
*/
|
||||
export function getFocusManager(node: DOMElement): FocusManager {
|
||||
return getRootNode(node).focusManager!
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Cursor } from './cursor.js'
|
||||
import type { Size } from './layout/geometry.js'
|
||||
import type { ScrollHint } from './render-node-to-output.js'
|
||||
import { type CharPool, createScreen, type HyperlinkPool, type Screen, type StylePool } from './screen.js'
|
||||
|
||||
export type Frame = {
|
||||
readonly screen: Screen
|
||||
readonly viewport: Size
|
||||
readonly cursor: Cursor
|
||||
/** DECSTBM scroll optimization hint (alt-screen only, null otherwise). */
|
||||
readonly scrollHint?: ScrollHint | null
|
||||
/** A ScrollBox has remaining pendingScrollDelta — schedule another frame. */
|
||||
readonly scrollDrainPending?: boolean
|
||||
/** Absolute overlay moved/resized — schedule corrective frame without prevScreen. */
|
||||
readonly absoluteOverlayMoved?: boolean
|
||||
}
|
||||
|
||||
export function emptyFrame(
|
||||
rows: number,
|
||||
columns: number,
|
||||
stylePool: StylePool,
|
||||
charPool: CharPool,
|
||||
hyperlinkPool: HyperlinkPool
|
||||
): Frame {
|
||||
return {
|
||||
screen: createScreen(0, 0, stylePool, charPool, hyperlinkPool),
|
||||
viewport: { width: columns, height: rows },
|
||||
cursor: { x: 0, y: 0, visible: true }
|
||||
}
|
||||
}
|
||||
|
||||
export type FlickerReason = 'resize' | 'offscreen' | 'clear'
|
||||
|
||||
export type FrameEvent = {
|
||||
durationMs: number
|
||||
/** Phase breakdown in ms + patch count. Populated when the ink instance
|
||||
* has frame-timing instrumentation enabled (via onFrame wiring). */
|
||||
phases?: {
|
||||
/** createRenderer output: DOM → yoga layout → screen buffer */
|
||||
renderer: number
|
||||
/** LogUpdate.render(): screen diff → Patch[] (the hot path this PR optimizes) */
|
||||
diff: number
|
||||
/** optimize(): patch merge/dedupe */
|
||||
optimize: number
|
||||
/** writeDiffToTerminal(): serialize patches → ANSI → stdout */
|
||||
write: number
|
||||
/** Pre-optimize patch count (proxy for how much changed this frame) */
|
||||
patches: number
|
||||
/** Post-optimize patch count. */
|
||||
optimizedPatches: number
|
||||
/** Bytes written to stdout this frame. */
|
||||
writeBytes: number
|
||||
/** Whether stdout.write returned false. */
|
||||
backpressure: boolean
|
||||
/** Previous stdout.write callback latency; 0 if drained before next frame. */
|
||||
prevFrameDrainMs: number
|
||||
/** yoga calculateLayout() time (runs in resetAfterCommit, before onRender) */
|
||||
yoga: number
|
||||
/** React reconcile time: scrollMutated → resetAfterCommit. 0 if no commit. */
|
||||
commit: number
|
||||
/** layoutNode() calls this frame (recursive, includes cache-hit returns) */
|
||||
yogaVisited: number
|
||||
/** measureFunc (text wrap/width) calls — the expensive part */
|
||||
yogaMeasured: number
|
||||
/** early returns via _hasL single-slot cache */
|
||||
yogaCacheHits: number
|
||||
/** total yoga Node instances alive (create - free). Growth = leak. */
|
||||
yogaLive: number
|
||||
}
|
||||
flickers: Array<{
|
||||
desiredHeight: number
|
||||
availableHeight: number
|
||||
reason: FlickerReason
|
||||
}>
|
||||
}
|
||||
|
||||
export type Patch =
|
||||
| { type: 'stdout'; content: string }
|
||||
| { type: 'clear'; count: number }
|
||||
| {
|
||||
type: 'clearTerminal'
|
||||
reason: FlickerReason
|
||||
// Populated by log-update when a scrollback diff triggers the reset.
|
||||
debug?: { triggerY: number; prevLine: string; nextLine: string }
|
||||
}
|
||||
| { type: 'cursorHide' }
|
||||
| { type: 'cursorShow' }
|
||||
| { type: 'cursorMove'; x: number; y: number }
|
||||
| { type: 'cursorTo'; col: number }
|
||||
| { type: 'carriageReturn' }
|
||||
| { type: 'hyperlink'; uri: string }
|
||||
// Pre-serialized style transition string from StylePool.transition() —
|
||||
// cached by (fromId, toId), zero allocations after warmup.
|
||||
| { type: 'styleStr'; str: string }
|
||||
|
||||
export type Diff = Patch[]
|
||||
|
||||
/**
|
||||
* Determines whether the screen should be cleared based on the current and previous frame.
|
||||
* Returns the reason for clearing, or undefined if no clear is needed.
|
||||
*
|
||||
* Screen clearing is triggered when:
|
||||
* 1. Terminal has been resized (viewport dimensions changed) → 'resize'
|
||||
* 2. Current frame screen height exceeds available terminal rows → 'offscreen'
|
||||
* 3. Previous frame screen height exceeded available terminal rows → 'offscreen'
|
||||
*/
|
||||
export function shouldClearScreen(prevFrame: Frame, frame: Frame): FlickerReason | undefined {
|
||||
const didResize =
|
||||
frame.viewport.height !== prevFrame.viewport.height || frame.viewport.width !== prevFrame.viewport.width
|
||||
|
||||
if (didResize) {
|
||||
return 'resize'
|
||||
}
|
||||
|
||||
const currentFrameOverflows = frame.screen.height >= frame.viewport.height
|
||||
|
||||
const previousFrameOverflowed = prevFrame.screen.height >= prevFrame.viewport.height
|
||||
|
||||
if (currentFrameOverflows || previousFrameOverflowed) {
|
||||
return 'offscreen'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { LayoutEdge, type LayoutNode } from './layout/node.js'
|
||||
|
||||
/**
|
||||
* Returns the yoga node's content width (computed width minus padding and
|
||||
* border).
|
||||
*
|
||||
* Warning: can return a value WIDER than the parent container. In a
|
||||
* column-direction flex parent, width is the cross axis — align-items:
|
||||
* stretch never shrinks children below their intrinsic size, so the text
|
||||
* node overflows (standard CSS behavior). Yoga measures leaf nodes in two
|
||||
* passes: the AtMost pass determines width, the Exactly pass determines
|
||||
* height. getComputedWidth() reflects the wider AtMost result while
|
||||
* getComputedHeight() reflects the narrower Exactly result. Callers that
|
||||
* use this for wrapping should clamp to actual available screen space so
|
||||
* the rendered line count stays consistent with the layout height.
|
||||
*/
|
||||
const getMaxWidth = (yogaNode: LayoutNode): number => {
|
||||
return (
|
||||
yogaNode.getComputedWidth() -
|
||||
yogaNode.getComputedPadding(LayoutEdge.Left) -
|
||||
yogaNode.getComputedPadding(LayoutEdge.Right) -
|
||||
yogaNode.getComputedBorder(LayoutEdge.Left) -
|
||||
yogaNode.getComputedBorder(LayoutEdge.Right)
|
||||
)
|
||||
}
|
||||
|
||||
export default getMaxWidth
|
||||
@@ -0,0 +1 @@
|
||||
export {}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { appendChildNode, createNode } from './dom.js'
|
||||
import { dispatchClick, hitTest } from './hit-test.js'
|
||||
import { nodeCache } from './node-cache.js'
|
||||
|
||||
const rect = (node: ReturnType<typeof createNode>, x: number, y: number, width: number, height: number) => {
|
||||
nodeCache.set(node, { x, y, width, height })
|
||||
}
|
||||
|
||||
describe('hit-test', () => {
|
||||
it('hits absolutely positioned children that paint outside their parent rect', () => {
|
||||
const root = createNode('ink-root')
|
||||
const parent = createNode('ink-box')
|
||||
const wrapper = createNode('ink-box')
|
||||
const overlay = createNode('ink-box')
|
||||
const row = createNode('ink-box')
|
||||
const seen: string[] = []
|
||||
|
||||
appendChildNode(root, parent)
|
||||
appendChildNode(parent, wrapper)
|
||||
appendChildNode(wrapper, overlay)
|
||||
appendChildNode(overlay, row)
|
||||
|
||||
overlay.style.position = 'absolute'
|
||||
row._eventHandlers = { onClick: () => seen.push('row') }
|
||||
|
||||
rect(root, 0, 0, 120, 40)
|
||||
rect(parent, 0, 30, 120, 1)
|
||||
rect(wrapper, 0, 30, 120, 1)
|
||||
rect(overlay, 0, 20, 96, 6)
|
||||
rect(row, 1, 22, 80, 1)
|
||||
|
||||
expect(hitTest(root, 2, 22)).toBe(row)
|
||||
expect(dispatchClick(root, 2, 22)).toBe(true)
|
||||
expect(seen).toEqual(['row'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { DOMElement } from './dom.js'
|
||||
import { ClickEvent } from './events/click-event.js'
|
||||
import type { EventHandlerProps } from './events/event-handlers.js'
|
||||
import { MouseEvent } from './events/mouse-event.js'
|
||||
import { nodeCache } from './node-cache.js'
|
||||
|
||||
function hitTestAbsoluteDescendants(node: DOMElement, col: number, row: number): DOMElement | null {
|
||||
for (let i = node.childNodes.length - 1; i >= 0; i--) {
|
||||
const child = node.childNodes[i]!
|
||||
|
||||
if (child.nodeName === '#text') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!nodeCache.get(child)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (child.style.position === 'absolute') {
|
||||
const hit = hitTest(child, col, row)
|
||||
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
|
||||
const nestedHit = hitTestAbsoluteDescendants(child, col, row)
|
||||
|
||||
if (nestedHit) {
|
||||
return nestedHit
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the deepest DOM element whose rendered rect contains (col, row).
|
||||
*
|
||||
* Uses the nodeCache populated by renderNodeToOutput — rects are in screen
|
||||
* coordinates with all offsets (including scrollTop translation) already
|
||||
* applied. Children are traversed in reverse so later siblings (painted on
|
||||
* top) win. Nodes not in nodeCache (not rendered this frame, or lacking a
|
||||
* yogaNode) are skipped along with their subtrees.
|
||||
*
|
||||
* Returns the hit node even if it has no onClick — dispatchClick walks up
|
||||
* via parentNode to find handlers.
|
||||
*/
|
||||
export function hitTest(node: DOMElement, col: number, row: number): DOMElement | null {
|
||||
const rect = nodeCache.get(node)
|
||||
|
||||
if (!rect) {
|
||||
return null
|
||||
}
|
||||
|
||||
const inside = col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height
|
||||
|
||||
if (!inside) {
|
||||
return hitTestAbsoluteDescendants(node, col, row)
|
||||
}
|
||||
|
||||
// Later siblings paint on top; reversed traversal returns topmost hit.
|
||||
for (let i = node.childNodes.length - 1; i >= 0; i--) {
|
||||
const child = node.childNodes[i]!
|
||||
|
||||
if (child.nodeName === '#text') {
|
||||
continue
|
||||
}
|
||||
|
||||
const hit = hitTest(child, col, row)
|
||||
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* Hit-test the root at (col, row) and bubble a ClickEvent from the deepest
|
||||
* containing node up through parentNode. Only nodes with an onClick handler
|
||||
* fire. Stops when a handler calls stopImmediatePropagation(). Returns
|
||||
* true if at least one onClick handler fired.
|
||||
*/
|
||||
export function dispatchClick(root: DOMElement, col: number, row: number, cellIsBlank = false): boolean {
|
||||
let target: DOMElement | undefined = hitTest(root, col, row) ?? undefined
|
||||
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Click-to-focus: find the closest focusable ancestor and focus it.
|
||||
// root is always ink-root, which owns the FocusManager.
|
||||
if (root.focusManager) {
|
||||
let focusTarget: DOMElement | undefined = target
|
||||
|
||||
while (focusTarget) {
|
||||
if (typeof focusTarget.attributes['tabIndex'] === 'number') {
|
||||
root.focusManager.handleClickFocus(focusTarget)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
focusTarget = focusTarget.parentNode
|
||||
}
|
||||
}
|
||||
|
||||
const event = new ClickEvent(col, row, cellIsBlank)
|
||||
let handled = false
|
||||
|
||||
while (target) {
|
||||
const handler = target._eventHandlers?.onClick as ((event: ClickEvent) => void) | undefined
|
||||
|
||||
if (handler) {
|
||||
handled = true
|
||||
const rect = nodeCache.get(target)
|
||||
|
||||
if (rect) {
|
||||
event.localCol = col - rect.x
|
||||
event.localRow = row - rect.y
|
||||
}
|
||||
|
||||
handler(event)
|
||||
|
||||
if (event.didStopImmediatePropagation()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
target = target.parentNode
|
||||
}
|
||||
|
||||
return handled
|
||||
}
|
||||
|
||||
type MouseHandler = 'onMouseDown' | 'onMouseUp' | 'onMouseDrag'
|
||||
|
||||
export function dispatchMouse(
|
||||
root: DOMElement,
|
||||
col: number,
|
||||
row: number,
|
||||
handlerName: MouseHandler,
|
||||
button: number,
|
||||
cellIsBlank = false,
|
||||
target?: DOMElement
|
||||
): DOMElement | undefined {
|
||||
let node: DOMElement | undefined = target ?? hitTest(root, col, row) ?? undefined
|
||||
|
||||
if (!node) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const event = new MouseEvent(col, row, cellIsBlank, button)
|
||||
let handled: DOMElement | undefined
|
||||
|
||||
while (node) {
|
||||
const handler = node._eventHandlers?.[handlerName] as ((event: MouseEvent) => void) | undefined
|
||||
|
||||
if (handler) {
|
||||
handled ??= node
|
||||
const rect = nodeCache.get(node)
|
||||
|
||||
if (rect) {
|
||||
event.localCol = col - rect.x
|
||||
event.localRow = row - rect.y
|
||||
}
|
||||
|
||||
handler(event)
|
||||
|
||||
if (event.didStopImmediatePropagation()) {
|
||||
return handled
|
||||
}
|
||||
}
|
||||
|
||||
node = node.parentNode
|
||||
}
|
||||
|
||||
return handled
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire onMouseEnter/onMouseLeave as the pointer moves. Like DOM
|
||||
* mouseenter/mouseleave: does NOT bubble — moving between children does
|
||||
* not re-fire on the parent. Walks up from the hit node collecting every
|
||||
* ancestor with a hover handler; diffs against the previous hovered set;
|
||||
* fires leave on the nodes exited, enter on the nodes entered.
|
||||
*
|
||||
* Mutates `hovered` in place so the caller (App instance) can hold it
|
||||
* across calls. Clears the set when the hit is null (cursor moved into a
|
||||
* non-rendered gap or off the root rect).
|
||||
*/
|
||||
export function dispatchHover(root: DOMElement, col: number, row: number, hovered: Set<DOMElement>): void {
|
||||
const next = new Set<DOMElement>()
|
||||
let node: DOMElement | undefined = hitTest(root, col, row) ?? undefined
|
||||
|
||||
while (node) {
|
||||
const h = node._eventHandlers as EventHandlerProps | undefined
|
||||
|
||||
if (h?.onMouseEnter || h?.onMouseLeave) {
|
||||
next.add(node)
|
||||
}
|
||||
|
||||
node = node.parentNode
|
||||
}
|
||||
|
||||
for (const old of hovered) {
|
||||
if (!next.has(old)) {
|
||||
hovered.delete(old)
|
||||
|
||||
// Skip handlers on detached nodes (removed between mouse events)
|
||||
if (old.parentNode) {
|
||||
;(old._eventHandlers as EventHandlerProps | undefined)?.onMouseLeave?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const n of next) {
|
||||
if (!hovered.has(n)) {
|
||||
hovered.add(n)
|
||||
;(n._eventHandlers as EventHandlerProps | undefined)?.onMouseEnter?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
|
||||
import { ClockContext } from '../components/ClockContext.js'
|
||||
import type { DOMElement } from '../dom.js'
|
||||
|
||||
import { useTerminalViewport } from './use-terminal-viewport.js'
|
||||
|
||||
/**
|
||||
* Hook for synchronized animations that pause when offscreen.
|
||||
*
|
||||
* Returns a ref to attach to the animated element and the current animation time.
|
||||
* All instances share the same clock, so animations stay in sync.
|
||||
* The clock only runs when at least one keepAlive subscriber exists.
|
||||
*
|
||||
* Pass `null` to pause — unsubscribes from the clock so no ticks fire.
|
||||
* Time freezes at the last value and resumes from the current clock time
|
||||
* when a number is passed again.
|
||||
*
|
||||
* @param intervalMs - How often to update, or null to pause
|
||||
* @returns [ref, time] - Ref to attach to element, elapsed time in ms
|
||||
*
|
||||
* @example
|
||||
* function Spinner() {
|
||||
* const [ref, time] = useAnimationFrame(120)
|
||||
* const frame = Math.floor(time / 120) % FRAMES.length
|
||||
* return <Box ref={ref}>{FRAMES[frame]}</Box>
|
||||
* }
|
||||
*
|
||||
* The clock automatically slows when the terminal is blurred,
|
||||
* so consumers don't need to handle focus state.
|
||||
*/
|
||||
export function useAnimationFrame(
|
||||
intervalMs: number | null = 16
|
||||
): [ref: (element: DOMElement | null) => void, time: number] {
|
||||
const clock = useContext(ClockContext)
|
||||
const [viewportRef, { isVisible }] = useTerminalViewport()
|
||||
const [time, setTime] = useState(() => clock?.now() ?? 0)
|
||||
|
||||
const active = isVisible && intervalMs !== null
|
||||
|
||||
useEffect(() => {
|
||||
if (!clock || !active) {
|
||||
return
|
||||
}
|
||||
|
||||
let lastUpdate = clock.now()
|
||||
|
||||
const onChange = (): void => {
|
||||
const now = clock.now()
|
||||
|
||||
if (now - lastUpdate >= intervalMs!) {
|
||||
lastUpdate = now
|
||||
setTime(now)
|
||||
}
|
||||
}
|
||||
|
||||
// keepAlive: true — visible animations drive the clock
|
||||
return clock.subscribe(onChange, true)
|
||||
}, [clock, intervalMs, active])
|
||||
|
||||
return [viewportRef, time]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useContext } from 'react'
|
||||
|
||||
import AppContext from '../components/AppContext.js'
|
||||
|
||||
/**
|
||||
* `useApp` is a React hook, which exposes a method to manually exit the app (unmount).
|
||||
*/
|
||||
const useApp = () => useContext(AppContext)
|
||||
export default useApp
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useContext } from 'react'
|
||||
|
||||
import CursorAdvanceContext, { type CursorAdvanceNotifier } from '../components/CursorAdvanceContext.js'
|
||||
|
||||
/**
|
||||
* Returns a function that notifies Ink the physical terminal cursor was
|
||||
* advanced out-of-band (e.g. by a direct stdout.write from the
|
||||
* TextInput fast-echo bypass).
|
||||
*
|
||||
* Calling the returned function updates two pieces of Ink state:
|
||||
*
|
||||
* - `displayCursor` — the cached parked-cursor position log-update
|
||||
* uses as the relative-move basis for the next frame. Skipped on
|
||||
* alt-screen, where every frame's CSI H resets the cursor anyway.
|
||||
*
|
||||
* - The active `cursorDeclaration` — the target the cursor parks at
|
||||
* after every frame. Bumped on BOTH main- and alt-screen, because
|
||||
* onRender's alt-screen park branch emits an absolute CUP from
|
||||
* this value and a stale declaration there is still visibly wrong.
|
||||
* The next React commit that publishes a fresh declaration
|
||||
* supersedes the bump.
|
||||
*
|
||||
* The caller is responsible for the stdout write itself; this hook
|
||||
* only reports the resulting cursor delta. Pass `dx` and optional
|
||||
* `dy` in terminal cells (positive = moved right/down, negative =
|
||||
* moved left/up).
|
||||
*
|
||||
* If the host isn't an Ink render root (test stubs, non-Ink renderer)
|
||||
* the returned callback is a safe no-op.
|
||||
*/
|
||||
export function useCursorAdvance(): CursorAdvanceNotifier {
|
||||
return useContext(CursorAdvanceContext)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
|
||||
|
||||
import CursorDeclarationContext from '../components/CursorDeclarationContext.js'
|
||||
import type { DOMElement } from '../dom.js'
|
||||
|
||||
/**
|
||||
* Declares where the terminal cursor should be parked after each frame.
|
||||
*
|
||||
* Terminal emulators render IME preedit text at the physical cursor
|
||||
* position, and screen readers / screen magnifiers track the native
|
||||
* cursor — so parking it at the text input's caret makes CJK input
|
||||
* appear inline and lets accessibility tools follow the input.
|
||||
*
|
||||
* Returns a ref callback to attach to the Box that contains the input.
|
||||
* The declared (line, column) is interpreted relative to that Box's
|
||||
* nodeCache rect (populated by renderNodeToOutput).
|
||||
*
|
||||
* Timing: Both ref attach and useLayoutEffect fire in React's layout
|
||||
* phase — after resetAfterCommit calls scheduleRender. scheduleRender
|
||||
* defers onRender via queueMicrotask, so onRender runs AFTER layout
|
||||
* effects commit and reads the fresh declaration on the first frame
|
||||
* (no one-keystroke lag). Test env uses onImmediateRender (synchronous,
|
||||
* no microtask), so tests compensate by calling ink.onRender()
|
||||
* explicitly after render.
|
||||
*/
|
||||
export function useDeclaredCursor({
|
||||
line,
|
||||
column,
|
||||
active
|
||||
}: {
|
||||
line: number
|
||||
column: number
|
||||
active: boolean
|
||||
}): (element: DOMElement | null) => void {
|
||||
const setCursorDeclaration = useContext(CursorDeclarationContext)
|
||||
const nodeRef = useRef<DOMElement | null>(null)
|
||||
|
||||
const setNode = useCallback((node: DOMElement | null) => {
|
||||
nodeRef.current = node
|
||||
}, [])
|
||||
|
||||
// When active, set unconditionally. When inactive, clear conditionally
|
||||
// (only if the currently-declared node is ours). The node-identity check
|
||||
// handles two hazards:
|
||||
// 1. A memo()ized active instance elsewhere (e.g. the search input in
|
||||
// a memo'd Footer) doesn't re-render this commit — an inactive
|
||||
// instance re-rendering here must not clobber it.
|
||||
// 2. Sibling handoff (menu focus moving between list items) — when
|
||||
// focus moves opposite to sibling order, the newly-inactive item's
|
||||
// effect runs AFTER the newly-active item's set. Without the node
|
||||
// check it would clobber.
|
||||
// No dep array: must re-declare every commit so the active instance
|
||||
// re-claims the declaration after another instance's unmount-cleanup or
|
||||
// sibling handoff nulls it.
|
||||
useLayoutEffect(() => {
|
||||
const node = nodeRef.current
|
||||
|
||||
if (active && node) {
|
||||
setCursorDeclaration({ relativeX: column, relativeY: line, node })
|
||||
} else {
|
||||
setCursorDeclaration(null, node)
|
||||
}
|
||||
})
|
||||
|
||||
// Clear on unmount (conditionally — another instance may own by then).
|
||||
// Separate effect with empty deps so cleanup only fires once — not on
|
||||
// every line/column change, which would transiently null between commits.
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
setCursorDeclaration(null, nodeRef.current)
|
||||
}
|
||||
}, [setCursorDeclaration])
|
||||
|
||||
return setNode
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import instances from '../instances.js'
|
||||
|
||||
export type RunExternalProcess = () => Promise<void>
|
||||
|
||||
export async function withInkSuspended(run: RunExternalProcess): Promise<void> {
|
||||
const ink = instances.get(process.stdout)
|
||||
|
||||
if (!ink) {
|
||||
await run()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ink.enterAlternateScreen()
|
||||
|
||||
try {
|
||||
await run()
|
||||
} finally {
|
||||
ink.exitAlternateScreen()
|
||||
}
|
||||
}
|
||||
|
||||
export function useExternalProcess(): (run: RunExternalProcess) => Promise<void> {
|
||||
return useCallback((run: RunExternalProcess) => withInkSuspended(run), [])
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useLayoutEffect } from 'react'
|
||||
import { useEventCallback } from 'usehooks-ts'
|
||||
|
||||
import type { InputEvent, Key } from '../events/input-event.js'
|
||||
|
||||
import useStdin from './use-stdin.js'
|
||||
|
||||
type Handler = (input: string, key: Key, event: InputEvent) => void
|
||||
|
||||
type Options = {
|
||||
/**
|
||||
* Enable or disable capturing of user input.
|
||||
* Useful when there are multiple useInput hooks used at once to avoid handling the same input several times.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook is used for handling user input.
|
||||
* It's a more convenient alternative to using `StdinContext` and listening to `data` events.
|
||||
* The callback you pass to `useInput` is called for each character when user enters any input.
|
||||
* However, if user pastes text and it's more than one character, the callback will be called only once and the whole string will be passed as `input`.
|
||||
*
|
||||
* ```
|
||||
* import {useInput} from 'ink';
|
||||
*
|
||||
* const UserInput = () => {
|
||||
* useInput((input, key) => {
|
||||
* if (input === 'q') {
|
||||
* // Exit program
|
||||
* }
|
||||
*
|
||||
* if (key.leftArrow) {
|
||||
* // Left arrow key pressed
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* return …
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
const useInput = (inputHandler: Handler, options: Options = {}) => {
|
||||
const { setRawMode, exitOnCtrlC, inputEmitter } = useStdin()
|
||||
|
||||
// useLayoutEffect (not useEffect) so that raw mode is enabled synchronously
|
||||
// during React's commit phase, before render() returns. With useEffect, raw
|
||||
// mode setup is deferred to the next event loop tick via React's scheduler,
|
||||
// leaving the terminal in cooked mode — keystrokes echo and the cursor is
|
||||
// visible until the effect fires.
|
||||
useLayoutEffect(() => {
|
||||
if (options.isActive === false) {
|
||||
return
|
||||
}
|
||||
|
||||
setRawMode(true)
|
||||
|
||||
return () => {
|
||||
setRawMode(false)
|
||||
}
|
||||
}, [options.isActive, setRawMode])
|
||||
|
||||
// Register the listener once on mount so its slot in the EventEmitter's
|
||||
// listener array is stable. If isActive were in the effect's deps, the
|
||||
// listener would re-append on false→true, moving it behind listeners
|
||||
// that registered while it was inactive — breaking
|
||||
// stopImmediatePropagation() ordering. useEventCallback keeps the
|
||||
// reference stable while reading latest isActive/inputHandler from
|
||||
// closure (it syncs via useLayoutEffect, so it's compiler-safe).
|
||||
const handleData = useEventCallback((event: InputEvent) => {
|
||||
if (options.isActive === false) {
|
||||
return
|
||||
}
|
||||
|
||||
const { input, key } = event
|
||||
|
||||
// If app is not supposed to exit on Ctrl+C, then let input listener handle it
|
||||
// Note: discreteUpdates is called at the App level when emitting events,
|
||||
// so all listeners are already within a high-priority update context.
|
||||
if (!(input === 'c' && key.ctrl) || !exitOnCtrlC) {
|
||||
inputHandler(input, key, event)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
inputEmitter?.on('input', handleData)
|
||||
|
||||
return () => {
|
||||
inputEmitter?.removeListener('input', handleData)
|
||||
}
|
||||
}, [inputEmitter, handleData])
|
||||
}
|
||||
|
||||
export default useInput
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { ClockContext } from '../components/ClockContext.js'
|
||||
|
||||
/**
|
||||
* Returns the clock time, updating at the given interval.
|
||||
* Subscribes as non-keepAlive — won't keep the clock alive on its own,
|
||||
* but updates whenever a keepAlive subscriber (e.g. the spinner)
|
||||
* is driving the clock.
|
||||
*
|
||||
* Use this to drive pure time-based computations (shimmer position,
|
||||
* frame index) from the shared clock.
|
||||
*/
|
||||
export function useAnimationTimer(intervalMs: number): number {
|
||||
const clock = useContext(ClockContext)
|
||||
const [time, setTime] = useState(() => clock?.now() ?? 0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!clock) {
|
||||
return
|
||||
}
|
||||
|
||||
let lastUpdate = clock.now()
|
||||
|
||||
const onChange = (): void => {
|
||||
const now = clock.now()
|
||||
|
||||
if (now - lastUpdate >= intervalMs) {
|
||||
lastUpdate = now
|
||||
setTime(now)
|
||||
}
|
||||
}
|
||||
|
||||
return clock.subscribe(onChange, false)
|
||||
}, [clock, intervalMs])
|
||||
|
||||
return time
|
||||
}
|
||||
|
||||
/**
|
||||
* Interval hook backed by the shared Clock.
|
||||
*
|
||||
* Unlike `useInterval` from `usehooks-ts` (which creates its own setInterval),
|
||||
* this piggybacks on the single shared clock so all timers consolidate into
|
||||
* one wake-up. Pass `null` for intervalMs to pause.
|
||||
*/
|
||||
export function useInterval(callback: () => void, intervalMs: number | null): void {
|
||||
const callbackRef = useRef(callback)
|
||||
callbackRef.current = callback
|
||||
|
||||
const clock = useContext(ClockContext)
|
||||
|
||||
useEffect(() => {
|
||||
if (!clock || intervalMs === null) {
|
||||
return
|
||||
}
|
||||
|
||||
let lastUpdate = clock.now()
|
||||
|
||||
const onChange = (): void => {
|
||||
const now = clock.now()
|
||||
|
||||
if (now - lastUpdate >= intervalMs) {
|
||||
lastUpdate = now
|
||||
callbackRef.current()
|
||||
}
|
||||
}
|
||||
|
||||
return clock.subscribe(onChange, false)
|
||||
}, [clock, intervalMs])
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* Set the search highlight query on the Ink instance. Non-empty → all
|
||||
* visible occurrences are inverted on the next frame (SGR 7, screen-buffer
|
||||
* overlay, same damage machinery as selection). Empty → clears.
|
||||
*
|
||||
* This is a screen-space highlight — it matches the RENDERED text, not the
|
||||
* source message text. Works for anything visible (bash output, file paths,
|
||||
* error messages) regardless of where it came from in the message tree. A
|
||||
* query that matched in source but got truncated/ellipsized in rendering
|
||||
* won't highlight; that's acceptable — we highlight what you see.
|
||||
*/
|
||||
export function useSearchHighlight(): {
|
||||
setQuery: (query: string) => void
|
||||
/** Paint an existing DOM subtree (from the MAIN tree) to a fresh
|
||||
* Screen at its natural height, scan. Element-relative positions
|
||||
* (row 0 = element top). Zero context duplication — the element
|
||||
* IS the one built with all real providers. */
|
||||
scanElement: (el: DOMElement) => MatchPosition[]
|
||||
/** Position-based CURRENT highlight. Every frame writes yellow at
|
||||
* positions[currentIdx] + rowOffset. The scan-highlight (inverse on
|
||||
* all matches) still runs — this overlays on top. rowOffset tracks
|
||||
* scroll; positions stay stable (message-relative). null clears. */
|
||||
setPositions: (
|
||||
state: {
|
||||
positions: MatchPosition[]
|
||||
rowOffset: number
|
||||
currentIdx: number
|
||||
} | null
|
||||
) => void
|
||||
} {
|
||||
useContext(StdinContext) // anchor to App subtree for hook rules
|
||||
const ink = instances.get(process.stdout)
|
||||
|
||||
return useMemo(() => {
|
||||
if (!ink) {
|
||||
return {
|
||||
setQuery: () => {},
|
||||
scanElement: () => [],
|
||||
setPositions: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
setQuery: (query: string) => ink.setSearchHighlight(query),
|
||||
scanElement: (el: DOMElement) => ink.scanElementSubtree(el),
|
||||
setPositions: state => ink.setSearchPositions(state)
|
||||
}
|
||||
}, [ink])
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useContext, useMemo, useSyncExternalStore } from 'react'
|
||||
|
||||
import StdinContext from '../components/StdinContext.js'
|
||||
import instances from '../instances.js'
|
||||
import { type FocusMove, type SelectionState, shiftAnchor } from '../selection.js'
|
||||
|
||||
/**
|
||||
* Access to text selection operations on the Ink instance (fullscreen only).
|
||||
* Returns no-op functions when fullscreen mode is disabled.
|
||||
*/
|
||||
export function useSelection(): {
|
||||
copySelection: () => Promise<string>
|
||||
/** Copy without clearing the highlight (for copy-on-select). */
|
||||
copySelectionNoClear: () => Promise<string>
|
||||
clearSelection: () => void
|
||||
hasSelection: () => boolean
|
||||
/** Read the raw mutable selection state (for drag-to-scroll). */
|
||||
getState: () => SelectionState | null
|
||||
/** Subscribe to selection mutations (start/update/finish/clear). */
|
||||
subscribe: (cb: () => void) => () => void
|
||||
/** Shift the anchor row by dRow, clamped to [minRow, maxRow]. */
|
||||
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
||||
/** Shift anchor AND focus by dRow (keyboard scroll: whole selection
|
||||
* tracks content). Clamped points get col reset to the full-width edge
|
||||
* since their content was captured by captureScrolledRows. Reads
|
||||
* screen.width from the ink instance for the col-reset boundary. */
|
||||
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
||||
/** Keyboard selection extension (shift+arrow): move focus, anchor fixed.
|
||||
* Left/right wrap across rows; up/down clamp at viewport edges. */
|
||||
moveFocus: (move: FocusMove) => void
|
||||
/** Capture text from rows about to scroll out of the viewport (call
|
||||
* BEFORE scrollBy so the screen buffer still has the outgoing rows). */
|
||||
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
|
||||
/** Set the selection highlight bg color (theme-piping; solid bg
|
||||
* replaces the old SGR-7 inverse so syntax highlighting stays readable
|
||||
* under selection). Call once on mount + whenever theme changes. */
|
||||
setSelectionBgColor: (color: string) => void
|
||||
/** Monotonic counter incremented on every selection mutation. */
|
||||
version: () => number
|
||||
} {
|
||||
// Look up the Ink instance via stdout — same pattern as instances map.
|
||||
// StdinContext is available (it's always provided), and the Ink instance
|
||||
// is keyed by stdout which we can get from process.stdout since there's
|
||||
// only one Ink instance per process in practice.
|
||||
useContext(StdinContext) // anchor to App subtree for hook rules
|
||||
const ink = instances.get(process.stdout)
|
||||
|
||||
// Memoize so callers can safely use the return value in dependency arrays.
|
||||
// ink is a singleton per stdout — stable across renders.
|
||||
return useMemo(() => {
|
||||
if (!ink) {
|
||||
return {
|
||||
copySelection: async () => '',
|
||||
copySelectionNoClear: async () => '',
|
||||
clearSelection: () => {},
|
||||
hasSelection: () => false,
|
||||
getState: () => null,
|
||||
subscribe: () => () => {},
|
||||
shiftAnchor: () => {},
|
||||
shiftSelection: () => {},
|
||||
moveFocus: () => {},
|
||||
captureScrolledRows: () => {},
|
||||
setSelectionBgColor: () => {},
|
||||
version: () => 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
copySelection: () => ink.copySelection(),
|
||||
copySelectionNoClear: () => ink.copySelectionNoClear(),
|
||||
clearSelection: () => ink.clearTextSelection(),
|
||||
hasSelection: () => ink.hasTextSelection(),
|
||||
getState: () => ink.selection,
|
||||
subscribe: (cb: () => void) => ink.subscribeToSelectionChange(cb),
|
||||
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => shiftAnchor(ink.selection, dRow, minRow, maxRow),
|
||||
shiftSelection: (dRow, minRow, maxRow) => ink.shiftSelectionForScroll(dRow, minRow, maxRow),
|
||||
moveFocus: (move: FocusMove) => ink.moveSelectionFocus(move),
|
||||
captureScrolledRows: (firstRow, lastRow, side) => ink.captureScrolledRows(firstRow, lastRow, side),
|
||||
setSelectionBgColor: (color: string) => ink.setSelectionBgColor(color),
|
||||
version: () => ink.getSelectionVersion()
|
||||
}
|
||||
}, [ink])
|
||||
}
|
||||
|
||||
const NO_SUBSCRIBE = () => () => {}
|
||||
const ALWAYS_FALSE = () => false
|
||||
|
||||
/**
|
||||
* Reactive selection-exists state. Re-renders the caller when a text
|
||||
* selection is created or cleared. Always returns false outside
|
||||
* fullscreen mode (selection is only available in alt-screen).
|
||||
*/
|
||||
export function useHasSelection(): boolean {
|
||||
useContext(StdinContext)
|
||||
const ink = instances.get(process.stdout)
|
||||
|
||||
return useSyncExternalStore(
|
||||
ink ? ink.subscribeToSelectionChange : NO_SUBSCRIBE,
|
||||
ink ? ink.hasTextSelection : ALWAYS_FALSE
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useContext } from 'react'
|
||||
|
||||
import StdinContext from '../components/StdinContext.js'
|
||||
|
||||
/**
|
||||
* `useStdin` is a React hook, which exposes stdin stream.
|
||||
*/
|
||||
const useStdin = () => useContext(StdinContext)
|
||||
export default useStdin
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useContext, useEffect, useRef } from 'react'
|
||||
|
||||
import { CLEAR_TAB_STATUS, supportsTabStatus, tabStatus, wrapForMultiplexer } from '../termio/osc.js'
|
||||
import type { Color } from '../termio/types.js'
|
||||
import { TerminalWriteContext } from '../useTerminalNotification.js'
|
||||
|
||||
export type TabStatusKind = 'idle' | 'busy' | 'waiting'
|
||||
|
||||
const rgb = (r: number, g: number, b: number): Color => ({
|
||||
type: 'rgb',
|
||||
r,
|
||||
g,
|
||||
b
|
||||
})
|
||||
|
||||
// Per the OSC 21337 usage guide's suggested mapping.
|
||||
const TAB_STATUS_PRESETS: Record<TabStatusKind, { indicator: Color; status: string; statusColor: Color }> = {
|
||||
idle: {
|
||||
indicator: rgb(0, 215, 95),
|
||||
status: 'Idle',
|
||||
statusColor: rgb(136, 136, 136)
|
||||
},
|
||||
busy: {
|
||||
indicator: rgb(255, 149, 0),
|
||||
status: 'Working…',
|
||||
statusColor: rgb(255, 149, 0)
|
||||
},
|
||||
waiting: {
|
||||
indicator: rgb(95, 135, 255),
|
||||
status: 'Waiting',
|
||||
statusColor: rgb(95, 135, 255)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Declaratively set the tab-status indicator (OSC 21337).
|
||||
*
|
||||
* Emits a colored dot + short status text to the tab sidebar. Terminals
|
||||
* that don't support OSC 21337 discard the sequence silently, so this is
|
||||
* safe to call unconditionally. Wrapped for tmux/screen passthrough.
|
||||
*
|
||||
* Pass `null` to opt out. If a status was previously set, transitioning to
|
||||
* `null` emits CLEAR_TAB_STATUS so toggling off mid-session doesn't leave
|
||||
* a stale dot. Process-exit cleanup is handled by ink.tsx's unmount path.
|
||||
*/
|
||||
export function useTabStatus(kind: TabStatusKind | null): void {
|
||||
const writeRaw = useContext(TerminalWriteContext)
|
||||
const prevKindRef = useRef<TabStatusKind | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// When kind transitions from non-null to null (e.g. user toggles off
|
||||
// showStatusInTerminalTab mid-session), clear the stale dot.
|
||||
if (kind === null) {
|
||||
if (prevKindRef.current !== null && writeRaw && supportsTabStatus()) {
|
||||
writeRaw(wrapForMultiplexer(CLEAR_TAB_STATUS))
|
||||
}
|
||||
|
||||
prevKindRef.current = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
prevKindRef.current = kind
|
||||
|
||||
if (!writeRaw || !supportsTabStatus()) {
|
||||
return
|
||||
}
|
||||
|
||||
writeRaw(wrapForMultiplexer(tabStatus(TAB_STATUS_PRESETS[kind])))
|
||||
}, [kind, writeRaw])
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useContext } from 'react'
|
||||
|
||||
import TerminalFocusContext from '../components/TerminalFocusContext.js'
|
||||
|
||||
/**
|
||||
* Hook to check if the terminal has focus.
|
||||
*
|
||||
* Uses DECSET 1004 focus reporting - the terminal sends escape sequences
|
||||
* when it gains or loses focus. These are handled automatically
|
||||
* by Ink and filtered from useInput.
|
||||
*
|
||||
* @returns true if the terminal is focused (or focus state is unknown)
|
||||
*/
|
||||
export function useTerminalFocus(): boolean {
|
||||
const { isTerminalFocused } = useContext(TerminalFocusContext)
|
||||
|
||||
return isTerminalFocused
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useContext, useEffect } from 'react'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
|
||||
import { OSC, osc } from '../termio/osc.js'
|
||||
import { TerminalWriteContext } from '../useTerminalNotification.js'
|
||||
|
||||
/**
|
||||
* Declaratively set the terminal tab/window title.
|
||||
*
|
||||
* Pass a single string to set both the tab and window title (OSC 0).
|
||||
* Pass `{ tab, window }` to set them independently: the short `tab` string
|
||||
* goes to OSC 1 (icon/tab label) and the longer `window` string goes to
|
||||
* OSC 2 (window title bar). This matters for terminals like Apple
|
||||
* Terminal.app whose narrow background tabs truncate the title from the
|
||||
* left — a single long OSC 0 string leaves only the tail visible, while a
|
||||
* separate short OSC 1 keeps the session name readable.
|
||||
*
|
||||
* Pass `null` to opt out — the hook becomes a no-op and leaves the
|
||||
* terminal title untouched.
|
||||
*
|
||||
* On Windows, uses `process.title` (classic conhost doesn't support OSC).
|
||||
*/
|
||||
export function useTerminalTitle(title: string | TerminalTitlePair | null): void {
|
||||
const writeRaw = useContext(TerminalWriteContext)
|
||||
|
||||
useEffect(() => {
|
||||
if (title === null || !writeRaw) {
|
||||
return
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const clean = stripAnsi(typeof title === 'string' ? title : (title.window ?? title.tab ?? ''))
|
||||
process.title = clean
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof title === 'string') {
|
||||
writeRaw(osc(OSC.SET_TITLE_AND_ICON, stripAnsi(title)))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Separate tab (OSC 1) and window (OSC 2) titles so narrow tab bars
|
||||
// show the short session name instead of a truncated tail.
|
||||
const tab = stripAnsi(title.tab ?? '')
|
||||
const window = stripAnsi(title.window ?? '')
|
||||
|
||||
if (tab && window) {
|
||||
writeRaw(osc(OSC.SET_ICON, tab) + osc(OSC.SET_TITLE, window))
|
||||
} else if (window) {
|
||||
writeRaw(osc(OSC.SET_TITLE_AND_ICON, window))
|
||||
} else if (tab) {
|
||||
writeRaw(osc(OSC.SET_TITLE_AND_ICON, tab))
|
||||
}
|
||||
}, [title, writeRaw])
|
||||
}
|
||||
|
||||
export interface TerminalTitlePair {
|
||||
/** Short title for the tab/icon label (OSC 1). */
|
||||
tab?: string
|
||||
/** Full title for the window title bar (OSC 2). */
|
||||
window?: string
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
|
||||
|
||||
import { TerminalSizeContext } from '../components/TerminalSizeContext.js'
|
||||
import type { DOMElement } from '../dom.js'
|
||||
|
||||
type ViewportEntry = {
|
||||
/**
|
||||
* Whether the element is currently within the terminal viewport
|
||||
*/
|
||||
isVisible: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect if a component is within the terminal viewport.
|
||||
*
|
||||
* Returns a callback ref and a viewport entry object.
|
||||
* Attach the ref to the component you want to track.
|
||||
*
|
||||
* The entry is updated during the layout phase (useLayoutEffect) so callers
|
||||
* always read fresh values during render. Visibility changes do NOT trigger
|
||||
* re-renders on their own — callers that re-render for other reasons (e.g.
|
||||
* animation ticks, state changes) will pick up the latest value naturally.
|
||||
* This avoids infinite update loops when combined with other layout effects
|
||||
* that also call setState.
|
||||
*
|
||||
* @example
|
||||
* const [ref, entry] = useTerminalViewport()
|
||||
* return <Box ref={ref}><Animation enabled={entry.isVisible}>...</Animation></Box>
|
||||
*/
|
||||
export function useTerminalViewport(): [ref: (element: DOMElement | null) => void, entry: ViewportEntry] {
|
||||
const terminalSize = useContext(TerminalSizeContext)
|
||||
const elementRef = useRef<DOMElement | null>(null)
|
||||
const entryRef = useRef<ViewportEntry>({ isVisible: true })
|
||||
|
||||
const setElement = useCallback((el: DOMElement | null) => {
|
||||
elementRef.current = el
|
||||
}, [])
|
||||
|
||||
// Runs on every render because yoga layout values can change
|
||||
// without React being aware. Only updates the ref — no setState
|
||||
// to avoid cascading re-renders during the commit phase.
|
||||
// Walks the DOM ancestor chain fresh each time to avoid holding stale
|
||||
// references after yoga tree rebuilds.
|
||||
useLayoutEffect(() => {
|
||||
const element = elementRef.current
|
||||
|
||||
if (!element?.yogaNode || !terminalSize) {
|
||||
return
|
||||
}
|
||||
|
||||
const height = element.yogaNode.getComputedHeight()
|
||||
const rows = terminalSize.rows
|
||||
|
||||
// Walk the DOM parent chain (not yoga.getParent()) so we can detect
|
||||
// scroll containers and subtract their scrollTop. Yoga computes layout
|
||||
// positions without scroll offset — scrollTop is applied at render time.
|
||||
// Without this, an element inside a ScrollBox whose yoga position exceeds
|
||||
// terminalRows would be considered offscreen even when scrolled into view
|
||||
// (e.g., the spinner in fullscreen mode after enough messages accumulate).
|
||||
let absoluteTop = element.yogaNode.getComputedTop()
|
||||
let parent: DOMElement | undefined = element.parentNode
|
||||
let root = element.yogaNode
|
||||
|
||||
while (parent) {
|
||||
if (parent.yogaNode) {
|
||||
absoluteTop += parent.yogaNode.getComputedTop()
|
||||
root = parent.yogaNode
|
||||
}
|
||||
|
||||
// scrollTop is only ever set on scroll containers (by ScrollBox + renderer).
|
||||
// Non-scroll nodes have undefined scrollTop → falsy fast-path.
|
||||
if (parent.scrollTop) {
|
||||
absoluteTop -= parent.scrollTop
|
||||
}
|
||||
|
||||
parent = parent.parentNode
|
||||
}
|
||||
|
||||
// Only the root's height matters
|
||||
const screenHeight = root.getComputedHeight()
|
||||
|
||||
const bottom = absoluteTop + height
|
||||
// When content overflows the viewport (screenHeight > rows), the
|
||||
// cursor-restore at frame end scrolls one extra row into scrollback.
|
||||
// log-update.ts accounts for this with scrollbackRows = viewportY + 1.
|
||||
// We must match, otherwise an element at the boundary is considered
|
||||
// "visible" here (animation keeps ticking) but its row is treated as
|
||||
// scrollback by log-update (content change → full reset → flicker).
|
||||
const cursorRestoreScroll = screenHeight > rows ? 1 : 0
|
||||
const viewportY = Math.max(0, screenHeight - rows) + cursorRestoreScroll
|
||||
const viewportBottom = viewportY + rows
|
||||
const visible = bottom > viewportY && absoluteTop < viewportBottom
|
||||
|
||||
if (visible !== entryRef.current.isVisible) {
|
||||
entryRef.current = { isVisible: visible }
|
||||
}
|
||||
})
|
||||
|
||||
return [setElement, entryRef.current]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cellAtIndex, CellWidth, type Screen, setCellStyleId, type StylePool } from './screen.js'
|
||||
|
||||
/**
|
||||
* Highlight every cell whose OSC 8 hyperlink matches `hoveredUrl` by inverting
|
||||
* its style. This is the cursor-hover affordance for clickable links: terminal
|
||||
* applications can't change the system mouse cursor, so we light up the link
|
||||
* itself when the pointer is over it. Same overlay machinery as
|
||||
* applySearchHighlight — post-layout, pure SGR, picked up by the diff.
|
||||
*
|
||||
* Returns true if any cell was highlighted. The caller decides whether to
|
||||
* promote that into a full-frame damage request — for hover specifically,
|
||||
* full damage is only useful on enter/leave/change transitions (so the
|
||||
* previous frame's inverted cells get re-emitted), not on every steady-state
|
||||
* frame the pointer sits on the link.
|
||||
*/
|
||||
export function applyHyperlinkHoverHighlight(
|
||||
screen: Screen,
|
||||
hoveredUrl: string | undefined,
|
||||
stylePool: StylePool
|
||||
): boolean {
|
||||
if (!hoveredUrl) {
|
||||
return false
|
||||
}
|
||||
|
||||
const w = screen.width
|
||||
const height = screen.height
|
||||
let applied = false
|
||||
|
||||
for (let row = 0; row < height; row++) {
|
||||
const rowOff = row * w
|
||||
|
||||
for (let col = 0; col < w; col++) {
|
||||
const cell = cellAtIndex(screen, rowOff + col)
|
||||
|
||||
// Skip SpacerTail — the head cell at col-1 owns the hyperlink, and
|
||||
// setCellStyleId on the tail would split the styling of a wide-char
|
||||
// glyph mid-cell. The head's restyle covers both halves.
|
||||
if (cell.width === CellWidth.SpacerTail) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.hyperlink !== hoveredUrl) {
|
||||
continue
|
||||
}
|
||||
|
||||
applied = true
|
||||
setCellStyleId(screen, col, row, stylePool.withInverse(cell.styleId))
|
||||
}
|
||||
}
|
||||
|
||||
return applied
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Text from './components/Text.js'
|
||||
import { MAX_COALESCED_BACKPRESSURE_FRAMES } from './constants.js'
|
||||
import Ink from './ink.js'
|
||||
|
||||
// Regression for issue #31486 (stdout-backpressure strand): when the
|
||||
// previous frame's stdout.write has not drained (the terminal parser is
|
||||
// overwhelmed — a wide CR+LF burst on a high-context session), the renderer
|
||||
// must COALESCE rather than pile another write on the backed-up pipe. Piling
|
||||
// writes keeps the macrotask queue hot and starves the stdin 'readable'
|
||||
// callback, which is the observed freeze. The coalesce must be bounded: after
|
||||
// MAX_COALESCED_BACKPRESSURE_FRAMES skipped frames it forces a write through
|
||||
// so a terminal whose drain callback never fires can't wedge the renderer.
|
||||
|
||||
/**
|
||||
* A TTY whose write() reports backpressure (returns false) and WITHHOLDS the
|
||||
* drain callback until fireDrain() is called — simulating a wedged terminal
|
||||
* parser. Each write records its drain callback so the test controls timing.
|
||||
*/
|
||||
class WedgedTty extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
columns = 20
|
||||
rows = 5
|
||||
isTTY = true
|
||||
private pendingDrains: Array<(err?: Error | null) => void> = []
|
||||
|
||||
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
|
||||
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
|
||||
if (cb) {
|
||||
// Hold the callback — do NOT fire it. This leaves the renderer's
|
||||
// pendingWriteStart non-null, the backpressure signal it coalesces on.
|
||||
this.pendingDrains.push(cb)
|
||||
}
|
||||
|
||||
// Report backpressure.
|
||||
return false
|
||||
}
|
||||
|
||||
/** Fire all withheld drain callbacks, simulating the pipe recovering. */
|
||||
fireDrain(): void {
|
||||
const drains = this.pendingDrains
|
||||
this.pendingDrains = []
|
||||
|
||||
for (const cb of drains) {
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
get pendingDrainCount(): number {
|
||||
return this.pendingDrains.length
|
||||
}
|
||||
}
|
||||
|
||||
/** A normal fast TTY: write succeeds and drains synchronously. */
|
||||
class FastTty extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
columns = 20
|
||||
rows = 5
|
||||
isTTY = true
|
||||
|
||||
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
|
||||
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
cb?.()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const makeInk = (stdout: WedgedTty | FastTty) => {
|
||||
const stdin = new EventEmitter() as unknown as NodeJS.ReadStream
|
||||
const stderr = new FastTty()
|
||||
|
||||
return new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
}
|
||||
|
||||
describe('Ink stdout backpressure coalescing (issue #31486)', () => {
|
||||
it('coalesces frames while the previous write has not drained', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const stdout = new WedgedTty()
|
||||
const ink = makeInk(stdout)
|
||||
|
||||
ink.render(React.createElement(Text, null, 'hello'))
|
||||
ink.onRender()
|
||||
|
||||
// First frame wrote (and reported backpressure; drain withheld).
|
||||
const writesAfterFirst = stdout.chunks.length
|
||||
expect(writesAfterFirst).toBeGreaterThan(0)
|
||||
expect(stdout.pendingDrainCount).toBe(1)
|
||||
|
||||
// Subsequent renders while the write is still pending must coalesce —
|
||||
// no new bytes written, a retry timer scheduled instead.
|
||||
ink.render(React.createElement(Text, null, 'world'))
|
||||
ink.onRender()
|
||||
expect(stdout.chunks.length).toBe(writesAfterFirst)
|
||||
|
||||
ink.onRender()
|
||||
expect(stdout.chunks.length).toBe(writesAfterFirst)
|
||||
|
||||
ink.unmount()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('resumes writing once the wedged pipe drains', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const stdout = new WedgedTty()
|
||||
const ink = makeInk(stdout)
|
||||
|
||||
ink.render(React.createElement(Text, null, 'hello'))
|
||||
ink.onRender()
|
||||
const writesAfterFirst = stdout.chunks.length
|
||||
|
||||
// Backed up: this render coalesces.
|
||||
ink.render(React.createElement(Text, null, 'changed'))
|
||||
ink.onRender()
|
||||
expect(stdout.chunks.length).toBe(writesAfterFirst)
|
||||
|
||||
// Pipe recovers — drain callback fires, clearing pendingWriteStart.
|
||||
stdout.fireDrain()
|
||||
|
||||
// The retry tick now finds the pipe drained and writes the pending frame.
|
||||
vi.runAllTimers()
|
||||
expect(stdout.chunks.length).toBeGreaterThan(writesAfterFirst)
|
||||
|
||||
ink.unmount()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('forces a write through after the coalesce ceiling so it never wedges forever', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const stdout = new WedgedTty()
|
||||
const ink = makeInk(stdout)
|
||||
|
||||
ink.render(React.createElement(Text, null, 'hello'))
|
||||
ink.onRender()
|
||||
const writesAfterFirst = stdout.chunks.length
|
||||
|
||||
// Mark content dirty and drive renders. The drain callback NEVER fires
|
||||
// (pendingDrainCount stays > 0). After MAX_COALESCED_BACKPRESSURE_FRAMES
|
||||
// coalesced retries, the renderer must force a write through.
|
||||
ink.render(React.createElement(Text, null, 'forced'))
|
||||
|
||||
// Drive enough retry ticks to exceed the ceiling.
|
||||
for (let i = 0; i <= MAX_COALESCED_BACKPRESSURE_FRAMES + 2; i++) {
|
||||
vi.advanceTimersByTime(4)
|
||||
}
|
||||
|
||||
// A write was forced through despite the never-firing drain callback.
|
||||
expect(stdout.chunks.length).toBeGreaterThan(writesAfterFirst)
|
||||
|
||||
ink.unmount()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('never coalesces on a fast terminal that drains synchronously', () => {
|
||||
const stdout = new FastTty()
|
||||
const ink = makeInk(stdout)
|
||||
|
||||
ink.render(React.createElement(Text, null, 'a'))
|
||||
ink.onRender()
|
||||
const afterA = stdout.chunks.length
|
||||
expect(afterA).toBeGreaterThan(0)
|
||||
|
||||
// Each changed render writes immediately — synchronous drain clears the
|
||||
// backpressure signal before the next frame, so nothing is coalesced.
|
||||
ink.render(React.createElement(Text, null, 'b'))
|
||||
ink.onRender()
|
||||
expect(stdout.chunks.length).toBeGreaterThan(afterA)
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,234 @@
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import Text from './components/Text.js'
|
||||
import Ink from './ink.js'
|
||||
|
||||
class FakeTty extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
columns = 40
|
||||
rows = 8
|
||||
isTTY = true
|
||||
|
||||
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
|
||||
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
cb?.()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function makeInk() {
|
||||
const stdout = new FakeTty()
|
||||
const stdin = new FakeTty()
|
||||
const stderr = new FakeTty()
|
||||
|
||||
const ink = new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
return { ink, stdout, stdin, stderr }
|
||||
}
|
||||
|
||||
// Cast helper instead of exposing __get*ForTest methods on production Ink —
|
||||
// these are internal frame/cursor caches we only inspect from tests.
|
||||
type InkPrivate = {
|
||||
displayCursor: { x: number; y: number } | null
|
||||
cursorDeclaration: { node: unknown; relativeX: number; relativeY: number } | null
|
||||
frontFrame: { cursor: { x: number; y: number } }
|
||||
}
|
||||
const peek = (ink: Ink): InkPrivate => ink as unknown as InkPrivate
|
||||
|
||||
// Closes the cursor-drift bug: when TextInput's fast-echo path writes a
|
||||
// printable character directly to stdout, the hardware cursor advances by
|
||||
// one cell BUT Ink's `displayCursor` cache (used as the basis for the
|
||||
// next frame's relative cursor preamble) wasn't being updated. On long
|
||||
// sessions an unrelated re-render (status bar timer, streaming
|
||||
// reasoning, etc.) would then park the hardware cursor N cells offset
|
||||
// from the actual caret — visible as "extra whitespace between my last
|
||||
// typed character and the cursor block".
|
||||
describe('Ink.noteExternalCursorAdvance', () => {
|
||||
it('bumps an already-tracked displayCursor by the given delta', () => {
|
||||
const { ink } = makeInk()
|
||||
|
||||
ink.render(React.createElement(Text, null, 'hi'))
|
||||
ink.onRender()
|
||||
|
||||
// Seed a known parked position directly. In production this is set by
|
||||
// the cursor-park branch in onRender when a useDeclaredCursor caller
|
||||
// commits a declaration; this test bypasses React for hermeticity.
|
||||
peek(ink).displayCursor = { x: 5, y: 0 }
|
||||
|
||||
ink.noteExternalCursorAdvance(3)
|
||||
expect(peek(ink).displayCursor).toEqual({ x: 8, y: 0 })
|
||||
|
||||
ink.noteExternalCursorAdvance(-1)
|
||||
expect(peek(ink).displayCursor).toEqual({ x: 7, y: 0 })
|
||||
|
||||
ink.noteExternalCursorAdvance(0, 2)
|
||||
expect(peek(ink).displayCursor).toEqual({ x: 7, y: 2 })
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
|
||||
it('seeds displayCursor from frontFrame.cursor when nothing was parked', () => {
|
||||
const { ink } = makeInk()
|
||||
|
||||
ink.render(React.createElement(Text, null, 'hello'))
|
||||
ink.onRender()
|
||||
|
||||
expect(peek(ink).displayCursor).toBeNull()
|
||||
const base = { x: peek(ink).frontFrame.cursor.x, y: peek(ink).frontFrame.cursor.y }
|
||||
|
||||
ink.noteExternalCursorAdvance(4)
|
||||
expect(peek(ink).displayCursor).toEqual({ x: base.x + 4, y: base.y })
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
|
||||
it('is a no-op when the delta is zero', () => {
|
||||
const { ink } = makeInk()
|
||||
|
||||
ink.render(React.createElement(Text, null, 'hi'))
|
||||
ink.onRender()
|
||||
|
||||
ink.noteExternalCursorAdvance(0)
|
||||
expect(peek(ink).displayCursor).toBeNull()
|
||||
|
||||
ink.noteExternalCursorAdvance(0, 0)
|
||||
expect(peek(ink).displayCursor).toBeNull()
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
|
||||
it('skips displayCursor on alt-screen — CSI H resets every frame', () => {
|
||||
const { ink } = makeInk()
|
||||
|
||||
ink.setAltScreenActive(true)
|
||||
ink.render(React.createElement(Text, null, 'hi'))
|
||||
ink.onRender()
|
||||
peek(ink).displayCursor = { x: 5, y: 0 }
|
||||
|
||||
ink.noteExternalCursorAdvance(3)
|
||||
|
||||
expect(peek(ink).displayCursor).toEqual({ x: 5, y: 0 })
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
|
||||
// Closes Copilot follow-up on PR #26717: the default TUI wraps the
|
||||
// composer in <AlternateScreen>, so alt-screen is the production
|
||||
// path. CSI H only resets the log-update relative-move basis — the
|
||||
// declared cursor target is still consulted by onRender's alt-screen
|
||||
// park branch (`cursorPosition(row, col)` using rect + decl). So
|
||||
// cursorDeclaration MUST advance on alt-screen too, even though
|
||||
// displayCursor doesn't need to.
|
||||
it('still advances cursorDeclaration on alt-screen', () => {
|
||||
const { ink } = makeInk()
|
||||
|
||||
ink.setAltScreenActive(true)
|
||||
ink.render(React.createElement(Text, null, 'hi'))
|
||||
ink.onRender()
|
||||
|
||||
const fakeNode = {} as unknown as Record<string, unknown>
|
||||
|
||||
peek(ink).cursorDeclaration = { node: fakeNode, relativeX: 7, relativeY: 0 }
|
||||
peek(ink).displayCursor = { x: 12, y: 0 }
|
||||
|
||||
ink.noteExternalCursorAdvance(3)
|
||||
|
||||
// displayCursor untouched on alt-screen
|
||||
expect(peek(ink).displayCursor).toEqual({ x: 12, y: 0 })
|
||||
// declaration still advanced — onRender's alt-screen park reads this
|
||||
expect(peek(ink).cursorDeclaration).toEqual({ node: fakeNode, relativeX: 10, relativeY: 0 })
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
|
||||
// Closes Copilot review feedback on PR #26717: even after the
|
||||
// TextInput-level fix where layout reads `curRef.current` directly,
|
||||
// there's still a window where a fast-echo wrote to stdout but the
|
||||
// current cursor declaration on Ink (set by an earlier render's
|
||||
// useDeclaredCursor commit) points at the PRE-keystroke caret
|
||||
// column. If we advanced only `displayCursor`, an unrelated re-render
|
||||
// in that window would re-run onRender's cursor-park branch with the
|
||||
// stale declaration and visually undo the fast-echo's advance. We
|
||||
// must bump BOTH so the cursor stays anchored to the physical caret
|
||||
// until the next React commit publishes a fresh declaration
|
||||
// (computed from `curRef.current` via the cursorLayout call in
|
||||
// textInput.tsx) that supersedes the bump.
|
||||
it('advances the active cursorDeclaration in lock-step with displayCursor', () => {
|
||||
const { ink } = makeInk()
|
||||
|
||||
ink.render(React.createElement(Text, null, 'hi'))
|
||||
ink.onRender()
|
||||
|
||||
const fakeNode = {} as unknown as Record<string, unknown>
|
||||
|
||||
peek(ink).cursorDeclaration = { node: fakeNode, relativeX: 7, relativeY: 0 }
|
||||
peek(ink).displayCursor = { x: 12, y: 0 }
|
||||
|
||||
ink.noteExternalCursorAdvance(3)
|
||||
|
||||
expect(peek(ink).displayCursor).toEqual({ x: 15, y: 0 })
|
||||
expect(peek(ink).cursorDeclaration).toEqual({ node: fakeNode, relativeX: 10, relativeY: 0 })
|
||||
|
||||
ink.noteExternalCursorAdvance(-1)
|
||||
expect(peek(ink).displayCursor).toEqual({ x: 14, y: 0 })
|
||||
expect(peek(ink).cursorDeclaration).toEqual({ node: fakeNode, relativeX: 9, relativeY: 0 })
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
|
||||
// Closes Copilot follow-up on PR #26717: the dy half of the notifier
|
||||
// contract was tested for `displayCursor` but not for
|
||||
// `cursorDeclaration.relativeY`. Newlines in fast-echoed text never
|
||||
// hit the bypass today (canFastAppendShape rejects '\n'), but `dy`
|
||||
// is part of the public API and must propagate symmetrically with
|
||||
// dx so future callers (e.g. multi-line paste shortcuts) don't get
|
||||
// a half-implemented contract.
|
||||
it('advances cursorDeclaration.relativeY when dy is non-zero', () => {
|
||||
const { ink } = makeInk()
|
||||
|
||||
ink.render(React.createElement(Text, null, 'hi'))
|
||||
ink.onRender()
|
||||
|
||||
const fakeNode = {} as unknown as Record<string, unknown>
|
||||
|
||||
peek(ink).cursorDeclaration = { node: fakeNode, relativeX: 2, relativeY: 1 }
|
||||
peek(ink).displayCursor = { x: 4, y: 2 }
|
||||
|
||||
ink.noteExternalCursorAdvance(1, 3)
|
||||
|
||||
expect(peek(ink).displayCursor).toEqual({ x: 5, y: 5 })
|
||||
expect(peek(ink).cursorDeclaration).toEqual({ node: fakeNode, relativeX: 3, relativeY: 4 })
|
||||
|
||||
// Negative dy too — cursor moving up across visual rows.
|
||||
ink.noteExternalCursorAdvance(0, -2)
|
||||
expect(peek(ink).displayCursor).toEqual({ x: 5, y: 3 })
|
||||
expect(peek(ink).cursorDeclaration).toEqual({ node: fakeNode, relativeX: 3, relativeY: 2 })
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
|
||||
it('leaves cursorDeclaration unchanged when no declaration is active', () => {
|
||||
const { ink } = makeInk()
|
||||
|
||||
ink.render(React.createElement(Text, null, 'hi'))
|
||||
ink.onRender()
|
||||
|
||||
expect(peek(ink).cursorDeclaration).toBeNull()
|
||||
|
||||
ink.noteExternalCursorAdvance(3)
|
||||
|
||||
expect(peek(ink).cursorDeclaration).toBeNull()
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,320 @@
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import Box from './components/Box.js'
|
||||
import Text from './components/Text.js'
|
||||
import Ink from './ink.js'
|
||||
import { ERASE_SCREEN, ERASE_SCROLLBACK } from './termio/csi.js'
|
||||
import { DISABLE_MOUSE_TRACKING } from './termio/dec.js'
|
||||
|
||||
/**
|
||||
* Focus-regain recovery (DECSET 1004 focus-in).
|
||||
*
|
||||
* Two properties are asserted against the RESULTING SCREEN, not against which
|
||||
* bytes were emitted:
|
||||
*
|
||||
* 1. Healing — a row that is stale on the physical screen but BLANK in the
|
||||
* new frame must be gone. The cell diff skips blank-over-blank, so a
|
||||
* buffer-only reset leaves it behind; the clear is what removes it.
|
||||
* 2. Atomicity — the clear must ride in the SAME write() as the repaint, so
|
||||
* no frame can be presented between "screen cleared" and "content drawn".
|
||||
* A separate erase write is the visible flash on an ordinary tab switch.
|
||||
*
|
||||
* Both hold on the alt screen and on the main screen (INLINE_MODE / Termux).
|
||||
*/
|
||||
|
||||
/** Minimal terminal emulator: replays ANSI into a cell grid. */
|
||||
class TermModel {
|
||||
private readonly rows: string[][]
|
||||
private cx = 0
|
||||
private cy = 0
|
||||
|
||||
constructor(
|
||||
private readonly width: number,
|
||||
private readonly height: number
|
||||
) {
|
||||
this.rows = Array.from({ length: height }, () => Array.from({ length: width }, () => ' '))
|
||||
}
|
||||
|
||||
private put(ch: string): void {
|
||||
if (this.cy >= 0 && this.cy < this.height && this.cx >= 0 && this.cx < this.width) {
|
||||
this.rows[this.cy]![this.cx] = ch
|
||||
}
|
||||
|
||||
this.cx++
|
||||
|
||||
if (this.cx >= this.width) {
|
||||
this.cx = 0
|
||||
this.cy++
|
||||
}
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
let i = 0
|
||||
|
||||
while (i < data.length) {
|
||||
const ch = data[i]!
|
||||
|
||||
if (ch === '\x1b') {
|
||||
if (data[i + 1] !== '[') {
|
||||
i += 2
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
let j = i + 2
|
||||
|
||||
while (j < data.length && !/[A-Za-z]/.test(data[j]!)) {
|
||||
j++
|
||||
}
|
||||
|
||||
const final = data[j]
|
||||
const params = data.slice(i + 2, j)
|
||||
i = j + 1
|
||||
|
||||
// DEC private modes (mouse, sync, cursor visibility) don't move cells.
|
||||
if (params.startsWith('?')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const nums = params.split(';').map(p => (p === '' ? undefined : Number(p)))
|
||||
const n = nums[0] ?? 1
|
||||
|
||||
switch (final) {
|
||||
case 'H':
|
||||
this.cy = (nums[0] ?? 1) - 1
|
||||
this.cx = (nums[1] ?? 1) - 1
|
||||
|
||||
break
|
||||
|
||||
case 'J':
|
||||
if ((nums[0] ?? 0) === 2 || (nums[0] ?? 0) === 3) {
|
||||
for (const row of this.rows) {
|
||||
row.fill(' ')
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'K':
|
||||
if (this.cy >= 0 && this.cy < this.height) {
|
||||
for (let x = this.cx; x < this.width; x++) {
|
||||
this.rows[this.cy]![x] = ' '
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'A':
|
||||
this.cy -= n
|
||||
|
||||
break
|
||||
|
||||
case 'B':
|
||||
this.cy += n
|
||||
|
||||
break
|
||||
|
||||
case 'C':
|
||||
this.cx += n
|
||||
|
||||
break
|
||||
|
||||
case 'D':
|
||||
this.cx -= n
|
||||
|
||||
break
|
||||
|
||||
case 'G':
|
||||
this.cx = n - 1
|
||||
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '\r') {
|
||||
this.cx = 0
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '\n') {
|
||||
this.cy++
|
||||
this.cx = 0
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
this.put(ch)
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
text(): string {
|
||||
return this.rows.map(r => r.join('').trimEnd()).join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTty extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
columns = 40
|
||||
rows = 8
|
||||
isTTY = true
|
||||
|
||||
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
|
||||
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
cb?.()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
drain(): string {
|
||||
const out = this.chunks.join('')
|
||||
this.chunks = []
|
||||
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
type InkPrivate = {
|
||||
handleTerminalFocusChange: (isFocused: boolean) => void
|
||||
}
|
||||
|
||||
const peek = (ink: Ink): InkPrivate => ink as unknown as InkPrivate
|
||||
const tick = () => new Promise<void>(resolve => queueMicrotask(resolve))
|
||||
|
||||
const STALE = 'STATUSROW downloading 42%'
|
||||
|
||||
// Tall frame -> short frame: the vacated row is BLANK in the new frame, which
|
||||
// is exactly the case the cell diff skips.
|
||||
const tall = () =>
|
||||
React.createElement(
|
||||
Box,
|
||||
{ flexDirection: 'column' },
|
||||
React.createElement(Text, null, 'hello'),
|
||||
React.createElement(Text, null, STALE)
|
||||
)
|
||||
|
||||
const short = () => React.createElement(Box, { flexDirection: 'column' }, React.createElement(Text, null, 'hello'))
|
||||
|
||||
async function focusRegain(altScreen: boolean, env?: Record<string, string>) {
|
||||
const restore: Array<[string, string | undefined]> = []
|
||||
|
||||
for (const [k, v] of Object.entries(env ?? {})) {
|
||||
restore.push([k, process.env[k]])
|
||||
process.env[k] = v
|
||||
}
|
||||
|
||||
try {
|
||||
return await runFocusRegain(altScreen)
|
||||
} finally {
|
||||
for (const [k, v] of restore) {
|
||||
if (v === undefined) {
|
||||
delete process.env[k]
|
||||
} else {
|
||||
process.env[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runFocusRegain(altScreen: boolean) {
|
||||
const stdout = new FakeTty()
|
||||
const stdin = new FakeTty()
|
||||
const stderr = new FakeTty()
|
||||
const term = new TermModel(40, 8)
|
||||
|
||||
const ink = new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
if (altScreen) {
|
||||
ink.setAltScreenActive(true, 'all')
|
||||
}
|
||||
|
||||
ink.render(tall())
|
||||
ink.onRender()
|
||||
await tick()
|
||||
term.write(stdout.drain())
|
||||
|
||||
// Hidden/throttled tab: Ink emits the shrunk frame, the emulator drops it.
|
||||
// Ink's virtual frame now says "short"; the physical screen still shows the
|
||||
// status row.
|
||||
ink.render(short())
|
||||
ink.onRender()
|
||||
await tick()
|
||||
stdout.drain()
|
||||
|
||||
const beforeFocus = term.text()
|
||||
|
||||
peek(ink).handleTerminalFocusChange(true)
|
||||
await tick()
|
||||
|
||||
const chunks = [...stdout.chunks]
|
||||
term.write(stdout.drain())
|
||||
ink.unmount()
|
||||
|
||||
return { beforeFocus, afterFocus: term.text(), chunks }
|
||||
}
|
||||
|
||||
describe.each([
|
||||
{ altScreen: true, name: 'alt screen' },
|
||||
{ altScreen: false, name: 'main screen (INLINE_MODE)' }
|
||||
])('Ink focus recovery — $name', ({ altScreen }) => {
|
||||
it('clears the stale row and repaints the current frame', async () => {
|
||||
const { beforeFocus, afterFocus } = await focusRegain(altScreen)
|
||||
|
||||
// Precondition: the physical screen really is stale before focus-in.
|
||||
expect(beforeFocus).toContain(STALE)
|
||||
|
||||
expect(afterFocus).not.toContain(STALE)
|
||||
expect(afterFocus).toContain('hello')
|
||||
// The repaint must not duplicate content it just redrew.
|
||||
expect(afterFocus.match(/hello/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits the clear in the same write as the repaint', async () => {
|
||||
const { chunks } = await focusRegain(altScreen)
|
||||
|
||||
const eraseChunks = chunks.filter(c => c.includes(ERASE_SCREEN))
|
||||
|
||||
expect(eraseChunks).toHaveLength(1)
|
||||
// Atomic: clear + content in one write, so no blank frame can be shown.
|
||||
expect(eraseChunks[0]).toContain('hello')
|
||||
})
|
||||
|
||||
it('never erases scrollback (CSI 3J) on an ordinary focus regain', async () => {
|
||||
// Apple Terminal opts into a scrollback-deep erase, but only to clear
|
||||
// reflow artifacts after a RESIZE. A tab switch must not take the user's
|
||||
// history with it.
|
||||
const { chunks } = await focusRegain(altScreen, { TERM_PROGRAM: 'Apple_Terminal' })
|
||||
|
||||
expect(chunks.join('')).not.toContain(ERASE_SCROLLBACK)
|
||||
})
|
||||
|
||||
it('re-asserts terminal modes so mouse tracking survives a hidden pane', async () => {
|
||||
// An emulator that dropped the DEC mouse modes while hidden would
|
||||
// otherwise stay dead until the DECRQM watchdog's next probe. Mouse
|
||||
// tracking is alt-screen-scoped (reassertTerminalModes returns early on
|
||||
// main screen, where altScreenMouseTracking is always 'off'), so only
|
||||
// assert the re-arm where tracking exists.
|
||||
const { chunks } = await focusRegain(altScreen)
|
||||
|
||||
if (altScreen) {
|
||||
expect(chunks.join('')).toContain(DISABLE_MOUSE_TRACKING)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import Text from './components/Text.js'
|
||||
import Ink from './ink.js'
|
||||
import { CURSOR_HOME, ERASE_SCREEN } from './termio/csi.js'
|
||||
|
||||
class FakeTty extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
columns = 20
|
||||
rows = 5
|
||||
isTTY = true
|
||||
|
||||
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
|
||||
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
cb?.()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const tick = () => new Promise<void>(resolve => queueMicrotask(resolve))
|
||||
|
||||
describe('Ink resize healing', () => {
|
||||
it('heals same-dimension alt-screen resize events with an erase before repaint', async () => {
|
||||
const stdout = new FakeTty()
|
||||
const stdin = new FakeTty()
|
||||
const stderr = new FakeTty()
|
||||
|
||||
const ink = new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
ink.setAltScreenActive(true)
|
||||
ink.render(React.createElement(Text, null, 'hello'))
|
||||
ink.onRender()
|
||||
stdout.chunks = []
|
||||
|
||||
stdout.emit('resize')
|
||||
ink.onRender()
|
||||
await tick()
|
||||
|
||||
// The heal may also erase scrollback (CSI 3J interposed between 2J and H)
|
||||
// depending on which recovery path runs, so assert the invariant — screen
|
||||
// erased, then content repainted after — rather than an exact byte run.
|
||||
const out = stdout.chunks.join('')
|
||||
expect(out).toContain(ERASE_SCREEN)
|
||||
expect(out).toContain(CURSOR_HOME)
|
||||
expect(out.indexOf(ERASE_SCREEN)).toBeLessThan(out.lastIndexOf('hello'))
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
|
||||
// Regression for issue #18449: dragging the terminal back and forth quickly
|
||||
// emits a BURST of resize events (the single-event test above only covers one
|
||||
// tick). Each tick resets the frame buffers and arms needsEraseBeforePaint, so
|
||||
// the burst must still converge to a clean erase+repaint — a stacked event
|
||||
// must never consume the erase and leave the final paint as a partial diff
|
||||
// that lets stale glyphs survive.
|
||||
it('converges to a clean erased frame after a rapid resize burst', async () => {
|
||||
const stdout = new FakeTty()
|
||||
const stdin = new FakeTty()
|
||||
const stderr = new FakeTty()
|
||||
|
||||
const ink = new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
ink.setAltScreenActive(true)
|
||||
ink.render(React.createElement(Text, null, 'hello'))
|
||||
ink.onRender()
|
||||
stdout.chunks = []
|
||||
|
||||
// Wobble the dimensions like a drag — widen, shrink, grow rows — then
|
||||
// settle back on the STARTING geometry. Even though the net dimensions are
|
||||
// unchanged, a host reflow during the burst can have scattered glyphs, so
|
||||
// the renderer must still heal rather than treat the end state as a no-op.
|
||||
const wobble: Array<[number, number]> = [
|
||||
[30, 5],
|
||||
[12, 9],
|
||||
[25, 4],
|
||||
[20, 5]
|
||||
]
|
||||
|
||||
for (const [columns, rows] of wobble) {
|
||||
stdout.columns = columns
|
||||
stdout.rows = rows
|
||||
stdout.emit('resize')
|
||||
}
|
||||
|
||||
ink.onRender()
|
||||
await tick()
|
||||
|
||||
// The heal can erase scrollback too (CSI 3J interposed), so assert the
|
||||
// semantic invariant rather than an exact byte sequence: the screen was
|
||||
// erased and the content was repainted AFTER the erase — i.e. the final
|
||||
// frame is a clean repaint, not a partial diff over drifted cells.
|
||||
const out = stdout.chunks.join('')
|
||||
expect(out).toContain(ERASE_SCREEN)
|
||||
expect(out).toContain(CURSOR_HOME)
|
||||
expect(out.indexOf(ERASE_SCREEN)).toBeLessThan(out.lastIndexOf('hello'))
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
|
||||
// The burst above ends on a same-dimension event; this isolates that worst
|
||||
// case on its own — a resize event whose dims equal the last known geometry
|
||||
// (the terminal restored the buffer / reflowed without a net size change)
|
||||
// must still arm the erase, because the physical screen may carry drift the
|
||||
// diff path cannot see (see log-update "drift repro").
|
||||
it('heals a same-dimension resize even when no React commit changes the tree', async () => {
|
||||
const stdout = new FakeTty()
|
||||
const stdin = new FakeTty()
|
||||
const stderr = new FakeTty()
|
||||
|
||||
const ink = new Ink({
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
ink.setAltScreenActive(true)
|
||||
ink.render(React.createElement(Text, null, 'hello'))
|
||||
ink.onRender()
|
||||
stdout.chunks = []
|
||||
|
||||
// Dimensions are identical to the initial render — the tree never changes.
|
||||
stdout.emit('resize')
|
||||
ink.onRender()
|
||||
await tick()
|
||||
|
||||
const out = stdout.chunks.join('')
|
||||
expect(out).toContain(ERASE_SCREEN)
|
||||
expect(out).toContain(CURSOR_HOME)
|
||||
expect(out.indexOf(ERASE_SCREEN)).toBeLessThan(out.lastIndexOf('hello'))
|
||||
|
||||
ink.unmount()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,10 @@
|
||||
// Store all instances of Ink (instance.js) to ensure that consecutive render() calls
|
||||
// use the same instance of Ink and don't create a new one
|
||||
//
|
||||
// This map has to be stored in a separate file, because render.js creates instances,
|
||||
// but instance.js should delete itself from the map on unmount
|
||||
|
||||
import type Ink from './ink.js'
|
||||
|
||||
const instances = new Map<NodeJS.WriteStream, Ink>()
|
||||
export default instances
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { LayoutNode } from './node.js'
|
||||
import { createYogaLayoutNode } from './yoga.js'
|
||||
|
||||
export function createLayoutNode(): LayoutNode {
|
||||
return createYogaLayoutNode()
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
export type Point = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type Size = {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type Rectangle = Point & Size
|
||||
|
||||
/** Edge insets (padding, margin, border) */
|
||||
export type Edges = {
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
left: number
|
||||
}
|
||||
|
||||
/** Create uniform edges */
|
||||
export function edges(all: number): Edges
|
||||
export function edges(vertical: number, horizontal: number): Edges
|
||||
export function edges(top: number, right: number, bottom: number, left: number): Edges
|
||||
|
||||
export function edges(a: number, b?: number, c?: number, d?: number): Edges {
|
||||
if (b === undefined) {
|
||||
return { top: a, right: a, bottom: a, left: a }
|
||||
}
|
||||
|
||||
if (c === undefined) {
|
||||
return { top: a, right: b, bottom: a, left: b }
|
||||
}
|
||||
|
||||
return { top: a, right: b, bottom: c, left: d! }
|
||||
}
|
||||
|
||||
/** Add two edge values */
|
||||
export function addEdges(a: Edges, b: Edges): Edges {
|
||||
return {
|
||||
top: a.top + b.top,
|
||||
right: a.right + b.right,
|
||||
bottom: a.bottom + b.bottom,
|
||||
left: a.left + b.left
|
||||
}
|
||||
}
|
||||
|
||||
/** Zero edges constant */
|
||||
export const ZERO_EDGES: Edges = { top: 0, right: 0, bottom: 0, left: 0 }
|
||||
|
||||
/** Convert partial edges to full edges with defaults */
|
||||
export function resolveEdges(partial?: Partial<Edges>): Edges {
|
||||
return {
|
||||
top: partial?.top ?? 0,
|
||||
right: partial?.right ?? 0,
|
||||
bottom: partial?.bottom ?? 0,
|
||||
left: partial?.left ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
export function unionRect(a: Rectangle, b: Rectangle): Rectangle {
|
||||
const minX = Math.min(a.x, b.x)
|
||||
const minY = Math.min(a.y, b.y)
|
||||
const maxX = Math.max(a.x + a.width, b.x + b.width)
|
||||
const maxY = Math.max(a.y + a.height, b.y + b.height)
|
||||
|
||||
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
|
||||
}
|
||||
|
||||
export function clampRect(rect: Rectangle, size: Size): Rectangle {
|
||||
const minX = Math.max(0, rect.x)
|
||||
const minY = Math.max(0, rect.y)
|
||||
const maxX = Math.min(size.width - 1, rect.x + rect.width - 1)
|
||||
const maxY = Math.min(size.height - 1, rect.y + rect.height - 1)
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: Math.max(0, maxX - minX + 1),
|
||||
height: Math.max(0, maxY - minY + 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function withinBounds(size: Size, point: Point): boolean {
|
||||
return point.x >= 0 && point.y >= 0 && point.x < size.width && point.y < size.height
|
||||
}
|
||||
|
||||
export function clamp(value: number, min?: number, max?: number): number {
|
||||
if (min !== undefined && value < min) {
|
||||
return min
|
||||
}
|
||||
|
||||
if (max !== undefined && value > max) {
|
||||
return max
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// --
|
||||
// Adapter interface for the layout engine (Yoga)
|
||||
|
||||
export const LayoutEdge = {
|
||||
All: 'all',
|
||||
Horizontal: 'horizontal',
|
||||
Vertical: 'vertical',
|
||||
Left: 'left',
|
||||
Right: 'right',
|
||||
Top: 'top',
|
||||
Bottom: 'bottom',
|
||||
Start: 'start',
|
||||
End: 'end'
|
||||
} as const
|
||||
export type LayoutEdge = (typeof LayoutEdge)[keyof typeof LayoutEdge]
|
||||
|
||||
export const LayoutGutter = {
|
||||
All: 'all',
|
||||
Column: 'column',
|
||||
Row: 'row'
|
||||
} as const
|
||||
export type LayoutGutter = (typeof LayoutGutter)[keyof typeof LayoutGutter]
|
||||
|
||||
export const LayoutDisplay = {
|
||||
Flex: 'flex',
|
||||
None: 'none'
|
||||
} as const
|
||||
export type LayoutDisplay = (typeof LayoutDisplay)[keyof typeof LayoutDisplay]
|
||||
|
||||
export const LayoutFlexDirection = {
|
||||
Row: 'row',
|
||||
RowReverse: 'row-reverse',
|
||||
Column: 'column',
|
||||
ColumnReverse: 'column-reverse'
|
||||
} as const
|
||||
export type LayoutFlexDirection = (typeof LayoutFlexDirection)[keyof typeof LayoutFlexDirection]
|
||||
|
||||
export const LayoutAlign = {
|
||||
Auto: 'auto',
|
||||
Stretch: 'stretch',
|
||||
FlexStart: 'flex-start',
|
||||
Center: 'center',
|
||||
FlexEnd: 'flex-end'
|
||||
} as const
|
||||
export type LayoutAlign = (typeof LayoutAlign)[keyof typeof LayoutAlign]
|
||||
|
||||
export const LayoutJustify = {
|
||||
FlexStart: 'flex-start',
|
||||
Center: 'center',
|
||||
FlexEnd: 'flex-end',
|
||||
SpaceBetween: 'space-between',
|
||||
SpaceAround: 'space-around',
|
||||
SpaceEvenly: 'space-evenly'
|
||||
} as const
|
||||
export type LayoutJustify = (typeof LayoutJustify)[keyof typeof LayoutJustify]
|
||||
|
||||
export const LayoutWrap = {
|
||||
NoWrap: 'nowrap',
|
||||
Wrap: 'wrap',
|
||||
WrapReverse: 'wrap-reverse'
|
||||
} as const
|
||||
export type LayoutWrap = (typeof LayoutWrap)[keyof typeof LayoutWrap]
|
||||
|
||||
export const LayoutPositionType = {
|
||||
Relative: 'relative',
|
||||
Absolute: 'absolute'
|
||||
} as const
|
||||
export type LayoutPositionType = (typeof LayoutPositionType)[keyof typeof LayoutPositionType]
|
||||
|
||||
export const LayoutOverflow = {
|
||||
Visible: 'visible',
|
||||
Hidden: 'hidden',
|
||||
Scroll: 'scroll'
|
||||
} as const
|
||||
export type LayoutOverflow = (typeof LayoutOverflow)[keyof typeof LayoutOverflow]
|
||||
|
||||
export type LayoutMeasureFunc = (width: number, widthMode: LayoutMeasureMode) => { width: number; height: number }
|
||||
|
||||
export const LayoutMeasureMode = {
|
||||
Undefined: 'undefined',
|
||||
Exactly: 'exactly',
|
||||
AtMost: 'at-most'
|
||||
} as const
|
||||
export type LayoutMeasureMode = (typeof LayoutMeasureMode)[keyof typeof LayoutMeasureMode]
|
||||
|
||||
export type LayoutNode = {
|
||||
// Tree
|
||||
insertChild(child: LayoutNode, index: number): void
|
||||
removeChild(child: LayoutNode): void
|
||||
getChildCount(): number
|
||||
getParent(): LayoutNode | null
|
||||
|
||||
// Layout computation
|
||||
calculateLayout(width?: number, height?: number): void
|
||||
setMeasureFunc(fn: LayoutMeasureFunc): void
|
||||
unsetMeasureFunc(): void
|
||||
markDirty(): void
|
||||
|
||||
// Layout reading (post-layout)
|
||||
getComputedLeft(): number
|
||||
getComputedTop(): number
|
||||
getComputedWidth(): number
|
||||
getComputedHeight(): number
|
||||
getComputedBorder(edge: LayoutEdge): number
|
||||
getComputedPadding(edge: LayoutEdge): number
|
||||
|
||||
// Style setters
|
||||
setWidth(value: number): void
|
||||
setWidthPercent(value: number): void
|
||||
setWidthAuto(): void
|
||||
setHeight(value: number): void
|
||||
setHeightPercent(value: number): void
|
||||
setHeightAuto(): void
|
||||
setMinWidth(value: number): void
|
||||
setMinWidthPercent(value: number): void
|
||||
setMinHeight(value: number): void
|
||||
setMinHeightPercent(value: number): void
|
||||
setMaxWidth(value: number): void
|
||||
setMaxWidthPercent(value: number): void
|
||||
setMaxHeight(value: number): void
|
||||
setMaxHeightPercent(value: number): void
|
||||
setFlexDirection(dir: LayoutFlexDirection): void
|
||||
setFlexGrow(value: number): void
|
||||
setFlexShrink(value: number): void
|
||||
setFlexBasis(value: number): void
|
||||
setFlexBasisPercent(value: number): void
|
||||
setFlexWrap(wrap: LayoutWrap): void
|
||||
setAlignItems(align: LayoutAlign): void
|
||||
setAlignSelf(align: LayoutAlign): void
|
||||
setJustifyContent(justify: LayoutJustify): void
|
||||
setDisplay(display: LayoutDisplay): void
|
||||
getDisplay(): LayoutDisplay
|
||||
setPositionType(type: LayoutPositionType): void
|
||||
setPosition(edge: LayoutEdge, value: number): void
|
||||
setPositionPercent(edge: LayoutEdge, value: number): void
|
||||
setOverflow(overflow: LayoutOverflow): void
|
||||
setMargin(edge: LayoutEdge, value: number): void
|
||||
setPadding(edge: LayoutEdge, value: number): void
|
||||
setBorder(edge: LayoutEdge, value: number): void
|
||||
setGap(gutter: LayoutGutter, value: number): void
|
||||
|
||||
// Lifecycle
|
||||
free(): void
|
||||
freeRecursive(): void
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import Yoga, {
|
||||
Align,
|
||||
Direction,
|
||||
Display,
|
||||
Edge,
|
||||
FlexDirection,
|
||||
Gutter,
|
||||
Justify,
|
||||
MeasureMode,
|
||||
Overflow,
|
||||
PositionType,
|
||||
Wrap,
|
||||
type Node as YogaNode
|
||||
} from '../../native-ts/yoga-layout/index.js'
|
||||
|
||||
import {
|
||||
type LayoutAlign,
|
||||
LayoutDisplay,
|
||||
type LayoutEdge,
|
||||
type LayoutFlexDirection,
|
||||
type LayoutGutter,
|
||||
type LayoutJustify,
|
||||
type LayoutMeasureFunc,
|
||||
LayoutMeasureMode,
|
||||
type LayoutNode,
|
||||
type LayoutOverflow,
|
||||
type LayoutPositionType,
|
||||
type LayoutWrap
|
||||
} from './node.js'
|
||||
|
||||
// --
|
||||
// Edge/Gutter mapping
|
||||
|
||||
const EDGE_MAP: Record<LayoutEdge, Edge> = {
|
||||
all: Edge.All,
|
||||
horizontal: Edge.Horizontal,
|
||||
vertical: Edge.Vertical,
|
||||
left: Edge.Left,
|
||||
right: Edge.Right,
|
||||
top: Edge.Top,
|
||||
bottom: Edge.Bottom,
|
||||
start: Edge.Start,
|
||||
end: Edge.End
|
||||
}
|
||||
|
||||
const GUTTER_MAP: Record<LayoutGutter, Gutter> = {
|
||||
all: Gutter.All,
|
||||
column: Gutter.Column,
|
||||
row: Gutter.Row
|
||||
}
|
||||
|
||||
// --
|
||||
// Yoga adapter
|
||||
|
||||
export class YogaLayoutNode implements LayoutNode {
|
||||
readonly yoga: YogaNode
|
||||
|
||||
constructor(yoga: YogaNode) {
|
||||
this.yoga = yoga
|
||||
}
|
||||
|
||||
// Tree
|
||||
|
||||
insertChild(child: LayoutNode, index: number): void {
|
||||
this.yoga.insertChild((child as YogaLayoutNode).yoga, index)
|
||||
}
|
||||
|
||||
removeChild(child: LayoutNode): void {
|
||||
this.yoga.removeChild((child as YogaLayoutNode).yoga)
|
||||
}
|
||||
|
||||
getChildCount(): number {
|
||||
return this.yoga.getChildCount()
|
||||
}
|
||||
|
||||
getParent(): LayoutNode | null {
|
||||
const p = this.yoga.getParent()
|
||||
|
||||
return p ? new YogaLayoutNode(p) : null
|
||||
}
|
||||
|
||||
// Layout
|
||||
|
||||
calculateLayout(width?: number, _height?: number): void {
|
||||
this.yoga.calculateLayout(width, undefined, Direction.LTR)
|
||||
}
|
||||
|
||||
setMeasureFunc(fn: LayoutMeasureFunc): void {
|
||||
this.yoga.setMeasureFunc((w, wMode) => {
|
||||
const mode =
|
||||
wMode === MeasureMode.Exactly
|
||||
? LayoutMeasureMode.Exactly
|
||||
: wMode === MeasureMode.AtMost
|
||||
? LayoutMeasureMode.AtMost
|
||||
: LayoutMeasureMode.Undefined
|
||||
|
||||
return fn(w, mode)
|
||||
})
|
||||
}
|
||||
|
||||
unsetMeasureFunc(): void {
|
||||
this.yoga.unsetMeasureFunc()
|
||||
}
|
||||
|
||||
markDirty(): void {
|
||||
this.yoga.markDirty()
|
||||
}
|
||||
|
||||
// Computed layout
|
||||
|
||||
getComputedLeft(): number {
|
||||
return this.yoga.getComputedLeft()
|
||||
}
|
||||
|
||||
getComputedTop(): number {
|
||||
return this.yoga.getComputedTop()
|
||||
}
|
||||
|
||||
getComputedWidth(): number {
|
||||
return this.yoga.getComputedWidth()
|
||||
}
|
||||
|
||||
getComputedHeight(): number {
|
||||
return this.yoga.getComputedHeight()
|
||||
}
|
||||
|
||||
getComputedBorder(edge: LayoutEdge): number {
|
||||
return this.yoga.getComputedBorder(EDGE_MAP[edge]!)
|
||||
}
|
||||
|
||||
getComputedPadding(edge: LayoutEdge): number {
|
||||
return this.yoga.getComputedPadding(EDGE_MAP[edge]!)
|
||||
}
|
||||
|
||||
// Style setters
|
||||
|
||||
setWidth(value: number): void {
|
||||
this.yoga.setWidth(value)
|
||||
}
|
||||
setWidthPercent(value: number): void {
|
||||
this.yoga.setWidthPercent(value)
|
||||
}
|
||||
setWidthAuto(): void {
|
||||
this.yoga.setWidthAuto()
|
||||
}
|
||||
setHeight(value: number): void {
|
||||
this.yoga.setHeight(value)
|
||||
}
|
||||
setHeightPercent(value: number): void {
|
||||
this.yoga.setHeightPercent(value)
|
||||
}
|
||||
setHeightAuto(): void {
|
||||
this.yoga.setHeightAuto()
|
||||
}
|
||||
setMinWidth(value: number): void {
|
||||
this.yoga.setMinWidth(value)
|
||||
}
|
||||
setMinWidthPercent(value: number): void {
|
||||
this.yoga.setMinWidthPercent(value)
|
||||
}
|
||||
setMinHeight(value: number): void {
|
||||
this.yoga.setMinHeight(value)
|
||||
}
|
||||
setMinHeightPercent(value: number): void {
|
||||
this.yoga.setMinHeightPercent(value)
|
||||
}
|
||||
setMaxWidth(value: number): void {
|
||||
this.yoga.setMaxWidth(value)
|
||||
}
|
||||
setMaxWidthPercent(value: number): void {
|
||||
this.yoga.setMaxWidthPercent(value)
|
||||
}
|
||||
setMaxHeight(value: number): void {
|
||||
this.yoga.setMaxHeight(value)
|
||||
}
|
||||
setMaxHeightPercent(value: number): void {
|
||||
this.yoga.setMaxHeightPercent(value)
|
||||
}
|
||||
|
||||
setFlexDirection(dir: LayoutFlexDirection): void {
|
||||
const map: Record<LayoutFlexDirection, FlexDirection> = {
|
||||
row: FlexDirection.Row,
|
||||
'row-reverse': FlexDirection.RowReverse,
|
||||
column: FlexDirection.Column,
|
||||
'column-reverse': FlexDirection.ColumnReverse
|
||||
}
|
||||
|
||||
this.yoga.setFlexDirection(map[dir]!)
|
||||
}
|
||||
|
||||
setFlexGrow(value: number): void {
|
||||
this.yoga.setFlexGrow(value)
|
||||
}
|
||||
setFlexShrink(value: number): void {
|
||||
this.yoga.setFlexShrink(value)
|
||||
}
|
||||
setFlexBasis(value: number): void {
|
||||
this.yoga.setFlexBasis(value)
|
||||
}
|
||||
setFlexBasisPercent(value: number): void {
|
||||
this.yoga.setFlexBasisPercent(value)
|
||||
}
|
||||
|
||||
setFlexWrap(wrap: LayoutWrap): void {
|
||||
const map: Record<LayoutWrap, Wrap> = {
|
||||
nowrap: Wrap.NoWrap,
|
||||
wrap: Wrap.Wrap,
|
||||
'wrap-reverse': Wrap.WrapReverse
|
||||
}
|
||||
|
||||
this.yoga.setFlexWrap(map[wrap]!)
|
||||
}
|
||||
|
||||
setAlignItems(align: LayoutAlign): void {
|
||||
const map: Record<LayoutAlign, Align> = {
|
||||
auto: Align.Auto,
|
||||
stretch: Align.Stretch,
|
||||
'flex-start': Align.FlexStart,
|
||||
center: Align.Center,
|
||||
'flex-end': Align.FlexEnd
|
||||
}
|
||||
|
||||
this.yoga.setAlignItems(map[align]!)
|
||||
}
|
||||
|
||||
setAlignSelf(align: LayoutAlign): void {
|
||||
const map: Record<LayoutAlign, Align> = {
|
||||
auto: Align.Auto,
|
||||
stretch: Align.Stretch,
|
||||
'flex-start': Align.FlexStart,
|
||||
center: Align.Center,
|
||||
'flex-end': Align.FlexEnd
|
||||
}
|
||||
|
||||
this.yoga.setAlignSelf(map[align]!)
|
||||
}
|
||||
|
||||
setJustifyContent(justify: LayoutJustify): void {
|
||||
const map: Record<LayoutJustify, Justify> = {
|
||||
'flex-start': Justify.FlexStart,
|
||||
center: Justify.Center,
|
||||
'flex-end': Justify.FlexEnd,
|
||||
'space-between': Justify.SpaceBetween,
|
||||
'space-around': Justify.SpaceAround,
|
||||
'space-evenly': Justify.SpaceEvenly
|
||||
}
|
||||
|
||||
this.yoga.setJustifyContent(map[justify]!)
|
||||
}
|
||||
|
||||
setDisplay(display: LayoutDisplay): void {
|
||||
this.yoga.setDisplay(display === 'flex' ? Display.Flex : Display.None)
|
||||
}
|
||||
|
||||
getDisplay(): LayoutDisplay {
|
||||
return this.yoga.getDisplay() === Display.None ? LayoutDisplay.None : LayoutDisplay.Flex
|
||||
}
|
||||
|
||||
setPositionType(type: LayoutPositionType): void {
|
||||
this.yoga.setPositionType(type === 'absolute' ? PositionType.Absolute : PositionType.Relative)
|
||||
}
|
||||
|
||||
setPosition(edge: LayoutEdge, value: number): void {
|
||||
this.yoga.setPosition(EDGE_MAP[edge]!, value)
|
||||
}
|
||||
|
||||
setPositionPercent(edge: LayoutEdge, value: number): void {
|
||||
this.yoga.setPositionPercent(EDGE_MAP[edge]!, value)
|
||||
}
|
||||
|
||||
setOverflow(overflow: LayoutOverflow): void {
|
||||
const map: Record<LayoutOverflow, Overflow> = {
|
||||
visible: Overflow.Visible,
|
||||
hidden: Overflow.Hidden,
|
||||
scroll: Overflow.Scroll
|
||||
}
|
||||
|
||||
this.yoga.setOverflow(map[overflow]!)
|
||||
}
|
||||
|
||||
setMargin(edge: LayoutEdge, value: number): void {
|
||||
this.yoga.setMargin(EDGE_MAP[edge]!, value)
|
||||
}
|
||||
setPadding(edge: LayoutEdge, value: number): void {
|
||||
this.yoga.setPadding(EDGE_MAP[edge]!, value)
|
||||
}
|
||||
setBorder(edge: LayoutEdge, value: number): void {
|
||||
this.yoga.setBorder(EDGE_MAP[edge]!, value)
|
||||
}
|
||||
setGap(gutter: LayoutGutter, value: number): void {
|
||||
this.yoga.setGap(GUTTER_MAP[gutter]!, value)
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
free(): void {
|
||||
this.yoga.free()
|
||||
}
|
||||
freeRecursive(): void {
|
||||
this.yoga.freeRecursive()
|
||||
}
|
||||
}
|
||||
|
||||
// --
|
||||
// Instance management
|
||||
//
|
||||
// The TS yoga-layout port is synchronous — no WASM loading, no linear memory
|
||||
// growth, so no preload/swap/reset machinery is needed. The Yoga instance is
|
||||
// just a plain JS object available at import time.
|
||||
|
||||
export function createYogaLayoutNode(): LayoutNode {
|
||||
return new YogaLayoutNode(Yoga.Node.create())
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { lruEvict } from './lru.js'
|
||||
import { stringWidth } from './stringWidth.js'
|
||||
|
||||
// During streaming, text grows but completed lines are immutable.
|
||||
// Caching stringWidth per-line avoids re-measuring hundreds of
|
||||
// unchanged lines on every token (~50x reduction in stringWidth calls).
|
||||
const cache = new Map<string, number>()
|
||||
|
||||
const MAX_CACHE_SIZE = 4096
|
||||
|
||||
export function lineWidth(line: string): number {
|
||||
const cached = cache.get(line)
|
||||
|
||||
if (cached !== undefined) {
|
||||
cache.delete(line)
|
||||
cache.set(line, cached)
|
||||
|
||||
return cached
|
||||
}
|
||||
|
||||
const width = stringWidth(line)
|
||||
|
||||
if (cache.size >= MAX_CACHE_SIZE) {
|
||||
cache.delete(cache.keys().next().value!)
|
||||
}
|
||||
|
||||
cache.set(line, width)
|
||||
|
||||
return width
|
||||
}
|
||||
|
||||
export function lineWidthCacheSize(): number {
|
||||
return cache.size
|
||||
}
|
||||
|
||||
export function evictLineWidthCache(keepRatio = 0): void {
|
||||
lruEvict(cache, keepRatio)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Frame } from './frame.js'
|
||||
import { LogUpdate } from './log-update.js'
|
||||
import { CellWidth, CharPool, createScreen, HyperlinkPool, type Screen, setCellAt, StylePool } from './screen.js'
|
||||
|
||||
/**
|
||||
* Contract tests for LogUpdate.render() — the diff-to-ANSI path that owns
|
||||
* whether the terminal picks up each React commit correctly.
|
||||
*
|
||||
* These tests pin down a few load-bearing invariants so that any fix for
|
||||
* the "scattered letters after rapid resize" artifact in xterm.js hosts
|
||||
* can be grounded against them.
|
||||
*/
|
||||
|
||||
const stylePool = new StylePool()
|
||||
const charPool = new CharPool()
|
||||
const hyperlinkPool = new HyperlinkPool()
|
||||
|
||||
const mkScreen = (w: number, h: number) => createScreen(w, h, stylePool, charPool, hyperlinkPool)
|
||||
|
||||
const paint = (screen: Screen, y: number, text: string) => {
|
||||
for (let x = 0; x < text.length; x++) {
|
||||
setCellAt(screen, x, y, {
|
||||
char: text[x]!,
|
||||
styleId: stylePool.none,
|
||||
width: CellWidth.Narrow,
|
||||
hyperlink: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const mkFrame = (screen: Screen, viewportW: number, viewportH: number, cursorY = 0): Frame => ({
|
||||
screen,
|
||||
viewport: { width: viewportW, height: viewportH },
|
||||
cursor: { x: 0, y: cursorY, visible: true }
|
||||
})
|
||||
|
||||
const stdoutOnly = (diff: ReturnType<LogUpdate['render']>) =>
|
||||
diff
|
||||
.filter(p => p.type === 'stdout')
|
||||
.map(p => (p as { type: 'stdout'; content: string }).content)
|
||||
.join('')
|
||||
|
||||
const ESC = '\u001b'
|
||||
const hasDecstbm = (text: string) => new RegExp(`${ESC}\\[\\d+;\\d+r`).test(text)
|
||||
|
||||
describe('LogUpdate.render diff contract', () => {
|
||||
it('emits only changed cells when most rows match', () => {
|
||||
const w = 20
|
||||
const h = 4
|
||||
const prev = mkScreen(w, h)
|
||||
paint(prev, 0, 'HELLO')
|
||||
paint(prev, 1, 'WORLD')
|
||||
paint(prev, 2, 'STAYSHERE')
|
||||
|
||||
const next = mkScreen(w, h)
|
||||
paint(next, 0, 'HELLO')
|
||||
paint(next, 1, 'CHANGE')
|
||||
paint(next, 2, 'STAYSHERE')
|
||||
next.damage = { x: 0, y: 0, width: w, height: h }
|
||||
|
||||
const log = new LogUpdate({ isTTY: true, stylePool })
|
||||
const diff = log.render(mkFrame(prev, w, h), mkFrame(next, w, h), true, false)
|
||||
|
||||
const written = stdoutOnly(diff)
|
||||
expect(written).toContain('CHANGE')
|
||||
expect(written).not.toContain('HELLO')
|
||||
expect(written).not.toContain('STAYSHERE')
|
||||
})
|
||||
|
||||
it('width change emits a clearTerminal patch before repainting', () => {
|
||||
const prevW = 20
|
||||
const nextW = 15
|
||||
const h = 3
|
||||
|
||||
const prev = mkScreen(prevW, h)
|
||||
paint(prev, 0, 'thiswaswiderrow')
|
||||
|
||||
const next = mkScreen(nextW, h)
|
||||
paint(next, 0, 'shorterrownow')
|
||||
next.damage = { x: 0, y: 0, width: nextW, height: h }
|
||||
|
||||
const log = new LogUpdate({ isTTY: true, stylePool })
|
||||
const diff = log.render(mkFrame(prev, prevW, h), mkFrame(next, nextW, h), true, false)
|
||||
|
||||
expect(diff.some(p => p.type === 'clearTerminal')).toBe(true)
|
||||
expect(stdoutOnly(diff)).toContain('shorterrownow')
|
||||
})
|
||||
|
||||
it('height growth emits a clearTerminal patch before repainting', () => {
|
||||
const w = 20
|
||||
const prevH = 3
|
||||
const nextH = 6
|
||||
|
||||
const prev = mkScreen(w, prevH)
|
||||
paint(prev, 0, 'old rows')
|
||||
|
||||
const next = mkScreen(w, nextH)
|
||||
paint(next, 0, 'new rows')
|
||||
next.damage = { x: 0, y: 0, width: w, height: nextH }
|
||||
|
||||
const log = new LogUpdate({ isTTY: true, stylePool })
|
||||
const diff = log.render(mkFrame(prev, w, prevH), mkFrame(next, w, nextH), true, false)
|
||||
|
||||
expect(diff.some(p => p.type === 'clearTerminal')).toBe(true)
|
||||
expect(stdoutOnly(diff)).toContain('newrows')
|
||||
})
|
||||
|
||||
it('drift repro: identical prev/next emits no heal, even when the physical terminal is stale', () => {
|
||||
// Load-bearing theory for the rapid-resize scattered-letter bug: if the
|
||||
// physical terminal has stale cells that prev.screen doesn't know about
|
||||
// (e.g. resize-induced reflow wrote past ink's tracked range), the
|
||||
// renderer has no signal to heal them. LogUpdate.render only sees
|
||||
// prev/next — no view of the physical terminal — so when prev==next,
|
||||
// it emits nothing and any orphaned glyphs survive.
|
||||
//
|
||||
// The fix path is upstream of this diff: either (a) defensively
|
||||
// full-repaint on xterm.js frames where prevFrameContaminated is set,
|
||||
// or (b) close the drift window so prev.screen cannot diverge.
|
||||
const w = 20
|
||||
const h = 3
|
||||
|
||||
const prev = mkScreen(w, h)
|
||||
paint(prev, 0, 'same')
|
||||
|
||||
const next = mkScreen(w, h)
|
||||
paint(next, 0, 'same')
|
||||
next.damage = { x: 0, y: 0, width: w, height: h }
|
||||
|
||||
const log = new LogUpdate({ isTTY: true, stylePool })
|
||||
const diff = log.render(mkFrame(prev, w, h), mkFrame(next, w, h), true, false)
|
||||
|
||||
expect(stdoutOnly(diff)).toBe('')
|
||||
expect(diff.some(p => p.type === 'clearTerminal')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores main-screen scrollback-only changes instead of resetting repeatedly', () => {
|
||||
const w = 20
|
||||
const viewportH = 5
|
||||
const h = 8
|
||||
|
||||
const prev = mkScreen(w, h)
|
||||
paint(prev, 0, 'timer 1s')
|
||||
paint(prev, 6, 'visible prompt')
|
||||
|
||||
const next = mkScreen(w, h)
|
||||
paint(next, 0, 'timer 2s')
|
||||
paint(next, 6, 'visible prompt')
|
||||
next.damage = { x: 0, y: 0, width: w, height: h }
|
||||
|
||||
const log = new LogUpdate({ isTTY: true, stylePool })
|
||||
const diff = log.render(mkFrame(prev, w, viewportH, h), mkFrame(next, w, viewportH, h), false, false)
|
||||
|
||||
expect(diff.some(p => p.type === 'clearTerminal')).toBe(false)
|
||||
expect(stdoutOnly(diff)).not.toContain('timer2s')
|
||||
})
|
||||
|
||||
it('keeps alt-screen full reset for unreachable scrollback row changes', () => {
|
||||
const w = 20
|
||||
const viewportH = 5
|
||||
const h = 8
|
||||
|
||||
const prev = mkScreen(w, h)
|
||||
paint(prev, 0, 'timer 1s')
|
||||
paint(prev, 6, 'visible prompt')
|
||||
|
||||
const next = mkScreen(w, h)
|
||||
paint(next, 0, 'timer 2s')
|
||||
paint(next, 6, 'visible prompt')
|
||||
next.damage = { x: 0, y: 0, width: w, height: h }
|
||||
|
||||
const log = new LogUpdate({ isTTY: true, stylePool })
|
||||
const diff = log.render(mkFrame(prev, w, viewportH, h), mkFrame(next, w, viewportH, h), true, false)
|
||||
|
||||
expect(diff.some(p => p.type === 'clearTerminal')).toBe(true)
|
||||
expect(stdoutOnly(diff)).toContain('timer2s')
|
||||
})
|
||||
|
||||
it('keeps DECSTBM fast-path when scroll region stays above bottom row', () => {
|
||||
const w = 12
|
||||
const h = 6
|
||||
const prev = mkScreen(w, h)
|
||||
const next = mkScreen(w, h)
|
||||
|
||||
paint(prev, 1, 'row one')
|
||||
paint(next, 1, 'row one')
|
||||
|
||||
const prevFrame = mkFrame(prev, w, h)
|
||||
|
||||
const nextFrame: Frame = {
|
||||
...mkFrame(next, w, h),
|
||||
scrollHint: { top: 1, bottom: 4, delta: 1 }
|
||||
}
|
||||
|
||||
const log = new LogUpdate({ isTTY: true, stylePool })
|
||||
const diff = log.render(prevFrame, nextFrame, true, true)
|
||||
|
||||
expect(hasDecstbm(stdoutOnly(diff))).toBe(true)
|
||||
})
|
||||
|
||||
it('skips DECSTBM when scroll region touches the bottom row', () => {
|
||||
const w = 12
|
||||
const h = 6
|
||||
const prev = mkScreen(w, h)
|
||||
const next = mkScreen(w, h)
|
||||
|
||||
paint(prev, 1, 'row one')
|
||||
paint(next, 1, 'row one')
|
||||
|
||||
const prevFrame = mkFrame(prev, w, h)
|
||||
|
||||
const nextFrame: Frame = {
|
||||
...mkFrame(next, w, h),
|
||||
scrollHint: { top: 1, bottom: 5, delta: 1 }
|
||||
}
|
||||
|
||||
const log = new LogUpdate({ isTTY: true, stylePool })
|
||||
const diff = log.render(prevFrame, nextFrame, true, true)
|
||||
|
||||
expect(hasDecstbm(stdoutOnly(diff))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,753 @@
|
||||
import { type AnsiCode, ansiCodesToString } from '@alcalzone/ansi-tokenize'
|
||||
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
|
||||
import { transitionAnsiCodes } from './ansi-transition.js'
|
||||
import type { Diff, FlickerReason, Frame } from './frame.js'
|
||||
import type { Point } from './layout/geometry.js'
|
||||
import {
|
||||
type Cell,
|
||||
cellAt,
|
||||
CellWidth,
|
||||
charInCellAt,
|
||||
diffEach,
|
||||
type Hyperlink,
|
||||
isEmptyCellAt,
|
||||
type Screen,
|
||||
shiftRows,
|
||||
type StylePool,
|
||||
visibleCellAtIndex
|
||||
} from './screen.js'
|
||||
import {
|
||||
scrollDown as csiScrollDown,
|
||||
scrollUp as csiScrollUp,
|
||||
CURSOR_HOME,
|
||||
RESET_SCROLL_REGION,
|
||||
setScrollRegion
|
||||
} from './termio/csi.js'
|
||||
import { LINK_END, link as oscLink } from './termio/osc.js'
|
||||
|
||||
type State = {
|
||||
previousOutput: string
|
||||
}
|
||||
|
||||
type Options = {
|
||||
isTTY: boolean
|
||||
stylePool: StylePool
|
||||
}
|
||||
|
||||
const CARRIAGE_RETURN = { type: 'carriageReturn' } as const
|
||||
const NEWLINE = { type: 'stdout', content: '\n' } as const
|
||||
|
||||
export class LogUpdate {
|
||||
private state: State
|
||||
|
||||
constructor(private readonly options: Options) {
|
||||
this.state = {
|
||||
previousOutput: ''
|
||||
}
|
||||
}
|
||||
|
||||
renderPreviousOutput_DEPRECATED(prevFrame: Frame): Diff {
|
||||
if (!this.options.isTTY) {
|
||||
// Non-TTY output is no longer supported (string output was removed)
|
||||
return [NEWLINE]
|
||||
}
|
||||
|
||||
return this.getRenderOpsForDone(prevFrame)
|
||||
}
|
||||
|
||||
// Called when process resumes from suspension (SIGCONT) to prevent clobbering terminal content
|
||||
reset(): void {
|
||||
this.state.previousOutput = ''
|
||||
}
|
||||
|
||||
private renderFullFrame(frame: Frame): Diff {
|
||||
const { screen } = frame
|
||||
const lines: string[] = []
|
||||
let currentStyles: AnsiCode[] = []
|
||||
let currentHyperlink: Hyperlink = undefined
|
||||
|
||||
for (let y = 0; y < screen.height; y++) {
|
||||
let line = ''
|
||||
|
||||
for (let x = 0; x < screen.width; x++) {
|
||||
const cell = cellAt(screen, x, y)
|
||||
|
||||
if (cell && cell.width !== CellWidth.SpacerTail) {
|
||||
// Handle hyperlink transitions
|
||||
if (cell.hyperlink !== currentHyperlink) {
|
||||
if (currentHyperlink !== undefined) {
|
||||
line += LINK_END
|
||||
}
|
||||
|
||||
if (cell.hyperlink !== undefined) {
|
||||
line += oscLink(cell.hyperlink)
|
||||
}
|
||||
|
||||
currentHyperlink = cell.hyperlink
|
||||
}
|
||||
|
||||
const cellStyles = this.options.stylePool.get(cell.styleId)
|
||||
const styleDiff = transitionAnsiCodes(currentStyles, cellStyles)
|
||||
|
||||
if (styleDiff.length > 0) {
|
||||
line += ansiCodesToString(styleDiff)
|
||||
currentStyles = cellStyles
|
||||
}
|
||||
|
||||
line += cell.char
|
||||
}
|
||||
}
|
||||
|
||||
// Close any open hyperlink before resetting styles
|
||||
if (currentHyperlink !== undefined) {
|
||||
line += LINK_END
|
||||
currentHyperlink = undefined
|
||||
}
|
||||
|
||||
// Reset styles at end of line so trimEnd doesn't leave dangling codes
|
||||
const resetCodes = transitionAnsiCodes(currentStyles, [])
|
||||
|
||||
if (resetCodes.length > 0) {
|
||||
line += ansiCodesToString(resetCodes)
|
||||
currentStyles = []
|
||||
}
|
||||
|
||||
lines.push(line.trimEnd())
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{ type: 'stdout', content: lines.join('\n') }]
|
||||
}
|
||||
|
||||
private getRenderOpsForDone(prev: Frame): Diff {
|
||||
this.state.previousOutput = ''
|
||||
|
||||
if (!prev.cursor.visible) {
|
||||
return [{ type: 'cursorShow' }]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
render(prev: Frame, next: Frame, altScreen = false, decstbmSafe = true): Diff {
|
||||
if (!this.options.isTTY) {
|
||||
return this.renderFullFrame(next)
|
||||
}
|
||||
|
||||
const startTime = performance.now()
|
||||
const stylePool = this.options.stylePool
|
||||
|
||||
// Terminal hosts can reflow/preserve old cells on any resize, including
|
||||
// height-only growth. A partial diff can then leave stale transcript rows
|
||||
// or cut off bordered content even when our virtual scrollTop is correct.
|
||||
// Resizing is rare enough that a full repaint is the safer tradeoff.
|
||||
if (
|
||||
next.viewport.height !== prev.viewport.height ||
|
||||
(prev.viewport.width !== 0 && next.viewport.width !== prev.viewport.width)
|
||||
) {
|
||||
return fullResetSequence_CAUSES_FLICKER(next, 'resize', stylePool)
|
||||
}
|
||||
|
||||
// DECSTBM scroll optimization: when a ScrollBox's scrollTop changed,
|
||||
// shift content with a hardware scroll (CSI top;bot r + CSI n S/T)
|
||||
// instead of rewriting the whole scroll region. The shiftRows on
|
||||
// prev.screen simulates the shift so the diff loop below naturally
|
||||
// finds only the rows that scrolled IN as diffs. prev.screen is
|
||||
// about to become backFrame (reused next render) so mutation is safe.
|
||||
// CURSOR_HOME after RESET_SCROLL_REGION is defensive — DECSTBM reset
|
||||
// homes cursor per spec but terminal implementations vary.
|
||||
//
|
||||
// decstbmSafe: caller passes false when the DECSTBM→diff sequence
|
||||
// can't be made atomic (no DEC 2026 / BSU/ESU). Without atomicity the
|
||||
// outer terminal renders the intermediate state — region scrolled,
|
||||
// edge rows not yet painted — a visible vertical jump on every frame
|
||||
// where scrollTop moves. Falling through to the diff loop writes all
|
||||
// shifted rows: more bytes, no intermediate state. next.screen from
|
||||
// render-node-to-output's blit+shift is correct either way.
|
||||
let scrollPatch: Diff = []
|
||||
|
||||
if (altScreen && next.scrollHint && decstbmSafe) {
|
||||
const { top, bottom, delta } = next.scrollHint
|
||||
|
||||
// Keep DECSTBM away from the terminal's last visible row. In alt-screen
|
||||
// layouts we reserve that lane for status/cursor parking, and scrolling
|
||||
// it can leave transient ghosting/bleed artifacts until a later repaint.
|
||||
if (top >= 0 && bottom < prev.screen.height - 1 && bottom < next.screen.height - 1) {
|
||||
shiftRows(prev.screen, top, bottom, delta)
|
||||
scrollPatch = [
|
||||
{
|
||||
type: 'stdout',
|
||||
content:
|
||||
setScrollRegion(top + 1, bottom + 1) +
|
||||
(delta > 0 ? csiScrollUp(delta) : csiScrollDown(-delta)) +
|
||||
RESET_SCROLL_REGION +
|
||||
CURSOR_HOME
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// We have to use purely relative operations to manipulate the cursor since
|
||||
// we don't know its starting point.
|
||||
//
|
||||
// When content height >= viewport height AND cursor is at the bottom,
|
||||
// the cursor restore at the end of the previous frame caused terminal scroll.
|
||||
// viewportY tells us how many rows are in scrollback from content overflow.
|
||||
// Additionally, the cursor-restore scroll pushes 1 more row into scrollback.
|
||||
// We need fullReset if any changes are to rows that are now in scrollback.
|
||||
//
|
||||
// This early full-reset check only applies in "steady state" (not growing).
|
||||
// For growing, the viewportY calculation below (with cursorRestoreScroll)
|
||||
// catches unreachable scrollback rows in the diff loop instead.
|
||||
const cursorAtBottom = prev.cursor.y >= prev.screen.height
|
||||
const isGrowing = next.screen.height > prev.screen.height
|
||||
|
||||
// When content fills the viewport exactly (height == viewport) and the
|
||||
// cursor is at the bottom, the cursor-restore LF at the end of the
|
||||
// previous frame scrolled 1 row into scrollback. Use >= to catch this.
|
||||
const prevHadScrollback = cursorAtBottom && prev.screen.height >= prev.viewport.height
|
||||
|
||||
const isShrinking = next.screen.height < prev.screen.height
|
||||
const nextFitsViewport = next.screen.height <= prev.viewport.height
|
||||
|
||||
// When shrinking from above-viewport to at-or-below-viewport, content that
|
||||
// was in scrollback should now be visible. Terminal clear operations can't
|
||||
// bring scrollback content into view, so we need a full reset.
|
||||
// Use <= (not <) because even when next height equals viewport height, the
|
||||
// scrollback depth from the previous render differs from a fresh render.
|
||||
if (prevHadScrollback && nextFitsViewport && isShrinking) {
|
||||
logForDebugging(
|
||||
`Full reset (shrink->below): prevHeight=${prev.screen.height}, nextHeight=${next.screen.height}, viewport=${prev.viewport.height}`
|
||||
)
|
||||
|
||||
return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool)
|
||||
}
|
||||
|
||||
if (
|
||||
altScreen &&
|
||||
prev.screen.height >= prev.viewport.height &&
|
||||
prev.screen.height > 0 &&
|
||||
cursorAtBottom &&
|
||||
!isGrowing
|
||||
) {
|
||||
// viewportY = rows in scrollback from content overflow
|
||||
// +1 for the row pushed by cursor-restore scroll
|
||||
const viewportY = prev.screen.height - prev.viewport.height
|
||||
const scrollbackRows = viewportY + 1
|
||||
|
||||
let scrollbackChangeY = -1
|
||||
diffEach(prev.screen, next.screen, (_x, y) => {
|
||||
if (y < scrollbackRows) {
|
||||
scrollbackChangeY = y
|
||||
|
||||
return true // early exit
|
||||
}
|
||||
})
|
||||
|
||||
if (scrollbackChangeY >= 0) {
|
||||
const prevLine = readLine(prev.screen, scrollbackChangeY)
|
||||
const nextLine = readLine(next.screen, scrollbackChangeY)
|
||||
|
||||
return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, {
|
||||
triggerY: scrollbackChangeY,
|
||||
prevLine,
|
||||
nextLine
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const screen = new VirtualScreen(prev.cursor, next.viewport.width)
|
||||
|
||||
// Treat empty screen as height 1 to avoid spurious adjustments on first render
|
||||
const heightDelta = Math.max(next.screen.height, 1) - Math.max(prev.screen.height, 1)
|
||||
|
||||
const shrinking = heightDelta < 0
|
||||
const growing = heightDelta > 0
|
||||
|
||||
// Handle shrinking: clear lines from the bottom
|
||||
if (shrinking) {
|
||||
const linesToClear = prev.screen.height - next.screen.height
|
||||
|
||||
// eraseLines only works within the viewport - it can't clear scrollback.
|
||||
// If we need to clear more lines than fit in the viewport, some are in
|
||||
// scrollback, so we need a full reset.
|
||||
if (linesToClear > prev.viewport.height) {
|
||||
return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', this.options.stylePool)
|
||||
}
|
||||
|
||||
// clear(N) moves cursor UP by N-1 lines and to column 0
|
||||
// This puts us at line prev.screen.height - N = next.screen.height
|
||||
// But we want to be at next.screen.height - 1 (bottom of new screen)
|
||||
screen.txn(prev => [
|
||||
[
|
||||
{ type: 'clear', count: linesToClear },
|
||||
{ type: 'cursorMove', x: 0, y: -1 }
|
||||
],
|
||||
{ dx: -prev.x, dy: -linesToClear }
|
||||
])
|
||||
}
|
||||
|
||||
// viewportY = number of rows in scrollback (not visible on terminal).
|
||||
// For shrinking: use max(prev, next) because terminal clears don't scroll.
|
||||
// For growing: use prev state because new rows haven't scrolled old ones yet.
|
||||
// When prevHadScrollback, add 1 for the cursor-restore LF that scrolled
|
||||
// an additional row out of view at the end of the previous frame. Without
|
||||
// this, the diff loop treats that row as reachable — but the cursor clamps
|
||||
// at viewport top, causing writes to land 1 row off and garbling the output.
|
||||
const cursorRestoreScroll = prevHadScrollback ? 1 : 0
|
||||
|
||||
const viewportY = growing
|
||||
? Math.max(0, prev.screen.height - prev.viewport.height + cursorRestoreScroll)
|
||||
: Math.max(prev.screen.height, next.screen.height) - next.viewport.height + cursorRestoreScroll
|
||||
|
||||
let currentStyleId = stylePool.none
|
||||
let currentHyperlink: Hyperlink = undefined
|
||||
|
||||
// First pass: render changes to existing rows (rows < prev.screen.height)
|
||||
let needsFullReset = false
|
||||
let resetTriggerY = -1
|
||||
diffEach(prev.screen, next.screen, (x, y, removed, added) => {
|
||||
// Skip new rows - we'll render them directly after
|
||||
if (growing && y >= prev.screen.height) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip spacers during rendering because the terminal will automatically
|
||||
// advance 2 columns when we write the wide character itself.
|
||||
// SpacerTail: Second cell of a wide character
|
||||
// SpacerHead: Marks line-end position where wide char wraps to next line
|
||||
if (added && (added.width === CellWidth.SpacerTail || added.width === CellWidth.SpacerHead)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (removed && (removed.width === CellWidth.SpacerTail || removed.width === CellWidth.SpacerHead) && !added) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip empty cells that don't need to overwrite existing content.
|
||||
// This prevents writing trailing spaces that would cause unnecessary
|
||||
// line wrapping at the edge of the screen.
|
||||
// Uses isEmptyCellAt to check if both packed words are zero (empty cell).
|
||||
if (added && isEmptyCellAt(next.screen, x, y) && !removed) {
|
||||
return
|
||||
}
|
||||
|
||||
// If the cell outside the viewport range has changed, we need to reset
|
||||
// because we can't move the cursor there to draw. In main-screen mode,
|
||||
// those rows are already in terminal scrollback and invisible; resetting
|
||||
// on every scrollback-only update can loop when a resize changes the
|
||||
// physical buffer. Shrink-to-visible cases are handled above.
|
||||
if (y < viewportY) {
|
||||
if (!altScreen) {
|
||||
return
|
||||
}
|
||||
|
||||
needsFullReset = true
|
||||
resetTriggerY = y
|
||||
|
||||
return true // early exit
|
||||
}
|
||||
|
||||
moveCursorTo(screen, x, y)
|
||||
|
||||
if (added) {
|
||||
const targetHyperlink = added.hyperlink
|
||||
currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, targetHyperlink)
|
||||
const styleStr = stylePool.transition(currentStyleId, added.styleId)
|
||||
|
||||
if (writeCellWithStyleStr(screen, added, styleStr)) {
|
||||
currentStyleId = added.styleId
|
||||
}
|
||||
} else if (removed) {
|
||||
// Cell was removed - clear it with a space
|
||||
// (This handles shrinking content)
|
||||
// Reset any active styles/hyperlinks first to avoid leaking into cleared cells
|
||||
const styleIdToReset = currentStyleId
|
||||
const hyperlinkToReset = currentHyperlink
|
||||
currentStyleId = stylePool.none
|
||||
currentHyperlink = undefined
|
||||
|
||||
screen.txn(() => {
|
||||
const patches: Diff = []
|
||||
transitionStyle(patches, stylePool, styleIdToReset, stylePool.none)
|
||||
transitionHyperlink(patches, hyperlinkToReset, undefined)
|
||||
patches.push({ type: 'stdout', content: ' ' })
|
||||
|
||||
return [patches, { dx: 1, dy: 0 }]
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (needsFullReset) {
|
||||
return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, {
|
||||
triggerY: resetTriggerY,
|
||||
prevLine: readLine(prev.screen, resetTriggerY),
|
||||
nextLine: readLine(next.screen, resetTriggerY)
|
||||
})
|
||||
}
|
||||
|
||||
// Reset styles before rendering new rows (they'll set their own styles)
|
||||
currentStyleId = transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none)
|
||||
currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, undefined)
|
||||
|
||||
// Handle growth: render new rows directly (they naturally scroll the terminal)
|
||||
if (growing) {
|
||||
renderFrameSlice(screen, next, prev.screen.height, next.screen.height, stylePool)
|
||||
}
|
||||
|
||||
// Restore cursor. Skipped in alt-screen: the cursor is hidden, its
|
||||
// position only matters as the starting point for the NEXT frame's
|
||||
// relative moves, and in alt-screen the next frame always begins with
|
||||
// CSI H (see ink.tsx onRender) which resets to (0,0) regardless. This
|
||||
// saves a CR + cursorMove round-trip (~6-10 bytes) every frame.
|
||||
//
|
||||
// Main screen: if cursor needs to be past the last line of content
|
||||
// (typical: cursor.y = screen.height), emit \n to create that line
|
||||
// since cursor movement can't create new lines.
|
||||
if (altScreen) {
|
||||
// no-op; next frame's CSI H anchors cursor
|
||||
} else if (next.cursor.y >= next.screen.height) {
|
||||
// Move to column 0 of current line, then emit newlines to reach target row
|
||||
screen.txn(prev => {
|
||||
const rowsToCreate = next.cursor.y - prev.y
|
||||
|
||||
if (rowsToCreate > 0) {
|
||||
// Use CR to resolve pending wrap (if any) without advancing
|
||||
// to the next line, then LF to create each new row.
|
||||
const patches: Diff = new Array<Diff[number]>(1 + rowsToCreate)
|
||||
patches[0] = CARRIAGE_RETURN
|
||||
|
||||
for (let i = 0; i < rowsToCreate; i++) {
|
||||
patches[1 + i] = NEWLINE
|
||||
}
|
||||
|
||||
return [patches, { dx: -prev.x, dy: rowsToCreate }]
|
||||
}
|
||||
|
||||
// At or past target row - need to move cursor to correct position
|
||||
const dy = next.cursor.y - prev.y
|
||||
|
||||
if (dy !== 0 || prev.x !== next.cursor.x) {
|
||||
// Use CR to clear pending wrap (if any), then cursor move
|
||||
const patches: Diff = [CARRIAGE_RETURN]
|
||||
patches.push({ type: 'cursorMove', x: next.cursor.x, y: dy })
|
||||
|
||||
return [patches, { dx: next.cursor.x - prev.x, dy }]
|
||||
}
|
||||
|
||||
return [[], { dx: 0, dy: 0 }]
|
||||
})
|
||||
} else {
|
||||
moveCursorTo(screen, next.cursor.x, next.cursor.y)
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - startTime
|
||||
|
||||
if (elapsed > 50) {
|
||||
const damage = next.screen.damage
|
||||
|
||||
const damageInfo = damage ? `${damage.width}x${damage.height} at (${damage.x},${damage.y})` : 'none'
|
||||
|
||||
logForDebugging(
|
||||
`Slow render: ${elapsed.toFixed(1)}ms, screen: ${next.screen.height}x${next.screen.width}, damage: ${damageInfo}, changes: ${screen.diff.length}`
|
||||
)
|
||||
}
|
||||
|
||||
return scrollPatch.length > 0 ? [...scrollPatch, ...screen.diff] : screen.diff
|
||||
}
|
||||
}
|
||||
|
||||
function transitionHyperlink(diff: Diff, current: Hyperlink, target: Hyperlink): Hyperlink {
|
||||
if (current !== target) {
|
||||
diff.push({ type: 'hyperlink', uri: target ?? '' })
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
function transitionStyle(diff: Diff, stylePool: StylePool, currentId: number, targetId: number): number {
|
||||
const str = stylePool.transition(currentId, targetId)
|
||||
|
||||
if (str.length > 0) {
|
||||
diff.push({ type: 'styleStr', str })
|
||||
}
|
||||
|
||||
return targetId
|
||||
}
|
||||
|
||||
function readLine(screen: Screen, y: number): string {
|
||||
let line = ''
|
||||
|
||||
for (let x = 0; x < screen.width; x++) {
|
||||
line += charInCellAt(screen, x, y) ?? ' '
|
||||
}
|
||||
|
||||
return line.trimEnd()
|
||||
}
|
||||
|
||||
function fullResetSequence_CAUSES_FLICKER(
|
||||
frame: Frame,
|
||||
reason: FlickerReason,
|
||||
stylePool: StylePool,
|
||||
debug?: { triggerY: number; prevLine: string; nextLine: string }
|
||||
): Diff {
|
||||
// After clearTerminal, cursor is at (0, 0)
|
||||
const screen = new VirtualScreen({ x: 0, y: 0 }, frame.viewport.width)
|
||||
renderFrame(screen, frame, stylePool)
|
||||
|
||||
return [{ type: 'clearTerminal', reason, debug }, ...screen.diff]
|
||||
}
|
||||
|
||||
function renderFrame(screen: VirtualScreen, frame: Frame, stylePool: StylePool): void {
|
||||
renderFrameSlice(screen, frame, 0, frame.screen.height, stylePool)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a slice of rows from the frame's screen.
|
||||
* Each row is rendered followed by a newline. Cursor ends at (0, endY).
|
||||
*/
|
||||
function renderFrameSlice(
|
||||
screen: VirtualScreen,
|
||||
frame: Frame,
|
||||
startY: number,
|
||||
endY: number,
|
||||
stylePool: StylePool
|
||||
): VirtualScreen {
|
||||
let currentStyleId = stylePool.none
|
||||
let currentHyperlink: Hyperlink = undefined
|
||||
// Track the styleId of the last rendered cell on this line (-1 if none).
|
||||
// Passed to visibleCellAtIndex to enable fg-only space optimization.
|
||||
let lastRenderedStyleId = -1
|
||||
|
||||
const { width: screenWidth, cells, charPool, hyperlinkPool } = frame.screen
|
||||
|
||||
let index = startY * screenWidth
|
||||
|
||||
for (let y = startY; y < endY; y += 1) {
|
||||
// Advance cursor to this row using LF (not CSI CUD / cursor-down).
|
||||
// CSI CUD stops at the viewport bottom margin and cannot scroll,
|
||||
// but LF scrolls the viewport to create new lines. Without this,
|
||||
// when the cursor is at the viewport bottom, moveCursorTo's
|
||||
// cursor-down silently fails, creating a permanent off-by-one
|
||||
// between the virtual cursor and the real terminal cursor.
|
||||
if (screen.cursor.y < y) {
|
||||
const rowsToAdvance = y - screen.cursor.y
|
||||
screen.txn(prev => {
|
||||
const patches: Diff = new Array<Diff[number]>(1 + rowsToAdvance)
|
||||
patches[0] = CARRIAGE_RETURN
|
||||
|
||||
for (let i = 0; i < rowsToAdvance; i++) {
|
||||
patches[1 + i] = NEWLINE
|
||||
}
|
||||
|
||||
return [patches, { dx: -prev.x, dy: rowsToAdvance }]
|
||||
})
|
||||
}
|
||||
|
||||
// Reset at start of each line — no cell rendered yet
|
||||
lastRenderedStyleId = -1
|
||||
|
||||
for (let x = 0; x < screenWidth; x += 1, index += 1) {
|
||||
// Skip spacers, unstyled empty cells, and fg-only styled spaces that
|
||||
// match the last rendered style (since cursor-forward produces identical
|
||||
// visual result). visibleCellAtIndex handles the optimization internally
|
||||
// to avoid allocating Cell objects for skipped cells.
|
||||
const cell = visibleCellAtIndex(cells, charPool, hyperlinkPool, index, lastRenderedStyleId)
|
||||
|
||||
if (!cell) {
|
||||
continue
|
||||
}
|
||||
|
||||
moveCursorTo(screen, x, y)
|
||||
|
||||
// Handle hyperlink
|
||||
const targetHyperlink = cell.hyperlink
|
||||
currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, targetHyperlink)
|
||||
|
||||
// Style transition — cached string, zero allocations after warmup
|
||||
const styleStr = stylePool.transition(currentStyleId, cell.styleId)
|
||||
|
||||
if (writeCellWithStyleStr(screen, cell, styleStr)) {
|
||||
currentStyleId = cell.styleId
|
||||
lastRenderedStyleId = cell.styleId
|
||||
}
|
||||
}
|
||||
|
||||
// Reset styles/hyperlinks before newline so background color doesn't
|
||||
// bleed into the next line when the terminal scrolls. The old code
|
||||
// reset implicitly by writing trailing unstyled spaces; now that we
|
||||
// skip empty cells, we must reset explicitly.
|
||||
currentStyleId = transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none)
|
||||
currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, undefined)
|
||||
// CR+LF at end of row — \r resets to column 0, \n moves to next line.
|
||||
// Without \r, the terminal cursor stays at whatever column content ended
|
||||
// (since we skip trailing spaces, this can be mid-row).
|
||||
screen.txn(prev => [[CARRIAGE_RETURN, NEWLINE], { dx: -prev.x, dy: 1 }])
|
||||
}
|
||||
|
||||
// Reset any open style/hyperlink at end of slice
|
||||
transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none)
|
||||
transitionHyperlink(screen.diff, currentHyperlink, undefined)
|
||||
|
||||
return screen
|
||||
}
|
||||
|
||||
type Delta = { dx: number; dy: number }
|
||||
|
||||
/**
|
||||
* Write a cell with a pre-serialized style transition string (from
|
||||
* StylePool.transition). Inlines the txn logic to avoid closure/tuple/delta
|
||||
* allocations on every cell.
|
||||
*
|
||||
* Returns true if the cell was written, false if skipped (wide char at
|
||||
* viewport edge). Callers MUST gate currentStyleId updates on this — when
|
||||
* skipped, styleStr is never pushed and the terminal's style state is
|
||||
* unchanged. Updating the virtual tracker anyway desyncs it from the
|
||||
* terminal, and the next transition is computed from phantom state.
|
||||
*/
|
||||
function writeCellWithStyleStr(screen: VirtualScreen, cell: Cell, styleStr: string): boolean {
|
||||
const cellWidth = cell.width === CellWidth.Wide ? 2 : 1
|
||||
const px = screen.cursor.x
|
||||
const vw = screen.viewportWidth
|
||||
|
||||
// Don't write wide chars that would cross the viewport edge.
|
||||
// Single-codepoint chars (CJK) at vw-2 are safe; multi-codepoint
|
||||
// graphemes (flags, ZWJ emoji) need stricter threshold.
|
||||
if (cellWidth === 2 && px < vw) {
|
||||
const threshold = cell.char.length > 2 ? vw : vw + 1
|
||||
|
||||
if (px + 2 >= threshold) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const diff = screen.diff
|
||||
|
||||
if (styleStr.length > 0) {
|
||||
diff.push({ type: 'styleStr', str: styleStr })
|
||||
}
|
||||
|
||||
const needsCompensation = cellWidth === 2 && needsWidthCompensation(cell.char)
|
||||
|
||||
// On terminals with old wcwidth tables, a compensated emoji only advances
|
||||
// the cursor 1 column, so the CHA below skips column x+1 without painting
|
||||
// it. Write a styled space there first — on correct terminals the emoji
|
||||
// glyph (width 2) overwrites it harmlessly; on old terminals it fills the
|
||||
// gap with the emoji's background. Also clears any stale content at x+1.
|
||||
// CHA is 1-based, so column px+1 (0-based) is CHA target px+2.
|
||||
if (needsCompensation && px + 1 < vw) {
|
||||
diff.push({ type: 'cursorTo', col: px + 2 })
|
||||
diff.push({ type: 'stdout', content: ' ' })
|
||||
diff.push({ type: 'cursorTo', col: px + 1 })
|
||||
}
|
||||
|
||||
diff.push({ type: 'stdout', content: cell.char })
|
||||
|
||||
// Force terminal cursor to correct column after the emoji.
|
||||
if (needsCompensation) {
|
||||
diff.push({ type: 'cursorTo', col: px + cellWidth + 1 })
|
||||
}
|
||||
|
||||
// Update cursor — mutate in place to avoid Point allocation
|
||||
if (px >= vw) {
|
||||
screen.cursor.x = cellWidth
|
||||
screen.cursor.y++
|
||||
} else {
|
||||
screen.cursor.x = px + cellWidth
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function moveCursorTo(screen: VirtualScreen, targetX: number, targetY: number) {
|
||||
screen.txn(prev => {
|
||||
const dx = targetX - prev.x
|
||||
const dy = targetY - prev.y
|
||||
const inPendingWrap = prev.x >= screen.viewportWidth
|
||||
|
||||
// If we're in pending wrap state (cursor.x >= width), use CR
|
||||
// to reset to column 0 on the current line without advancing
|
||||
// to the next line, then issue the cursor movement.
|
||||
if (inPendingWrap) {
|
||||
return [[CARRIAGE_RETURN, { type: 'cursorMove', x: targetX, y: dy }], { dx, dy }]
|
||||
}
|
||||
|
||||
// When moving to a different line, use carriage return (\r) to reset to
|
||||
// column 0 first, then cursor move.
|
||||
if (dy !== 0) {
|
||||
return [[CARRIAGE_RETURN, { type: 'cursorMove', x: targetX, y: dy }], { dx, dy }]
|
||||
}
|
||||
|
||||
// Standard same-line cursor move
|
||||
return [[{ type: 'cursorMove', x: dx, y: dy }], { dx, dy }]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify emoji where the terminal's wcwidth may disagree with Unicode.
|
||||
* On terminals with correct tables, the CHA we emit is a harmless no-op.
|
||||
*
|
||||
* Two categories:
|
||||
* 1. Newer emoji (Unicode 12.0+) missing from terminal wcwidth tables.
|
||||
* 2. Text-by-default emoji + VS16 (U+FE0F): the base codepoint is width 1
|
||||
* in wcwidth, but VS16 triggers emoji presentation making it width 2.
|
||||
* Examples: ⚔️ (U+2694), ☠️ (U+2620), ❤️ (U+2764).
|
||||
*/
|
||||
function needsWidthCompensation(char: string): boolean {
|
||||
const cp = char.codePointAt(0)
|
||||
|
||||
if (cp === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
// U+1FA70-U+1FAFF: Symbols and Pictographs Extended-A (Unicode 12.0-15.0)
|
||||
// U+1FB00-U+1FBFF: Symbols for Legacy Computing (Unicode 13.0)
|
||||
if ((cp >= 0x1fa70 && cp <= 0x1faff) || (cp >= 0x1fb00 && cp <= 0x1fbff)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Text-by-default emoji with VS16: scan for U+FE0F in multi-codepoint
|
||||
// graphemes. Single BMP chars (length 1) and surrogate pairs without VS16
|
||||
// skip this check. VS16 (0xFE0F) can't collide with surrogates (0xD800-0xDFFF).
|
||||
if (char.length >= 2) {
|
||||
for (let i = 0; i < char.length; i++) {
|
||||
if (char.charCodeAt(i) === 0xfe0f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
class VirtualScreen {
|
||||
// Public for direct mutation by writeCellWithStyleStr (avoids txn overhead).
|
||||
// File-private class — not exposed outside log-update.ts.
|
||||
cursor: Point
|
||||
diff: Diff = []
|
||||
|
||||
constructor(
|
||||
origin: Point,
|
||||
readonly viewportWidth: number
|
||||
) {
|
||||
this.cursor = { ...origin }
|
||||
}
|
||||
|
||||
txn(fn: (prev: Point) => [patches: Diff, next: Delta]): void {
|
||||
const [patches, next] = fn(this.cursor)
|
||||
|
||||
for (const patch of patches) {
|
||||
this.diff.push(patch)
|
||||
}
|
||||
|
||||
this.cursor.x += next.dx
|
||||
this.cursor.y += next.dy
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Shared eviction for the hot Ink LRU caches (widthCache, wrapCache,
|
||||
// sliceCache, lineWidthCache). Hot-path touch-on-read stays inlined per
|
||||
// cache — only the bulk eviction is factored here.
|
||||
export function lruEvict<K, V>(cache: Map<K, V>, keepRatio: number): void {
|
||||
if (keepRatio <= 0) {
|
||||
return cache.clear()
|
||||
}
|
||||
|
||||
const target = Math.floor(cache.size * keepRatio)
|
||||
|
||||
while (cache.size > target) {
|
||||
cache.delete(cache.keys().next().value!)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { DOMElement } from './dom.js'
|
||||
|
||||
type Output = {
|
||||
/**
|
||||
* Element width.
|
||||
*/
|
||||
width: number
|
||||
|
||||
/**
|
||||
* Element height.
|
||||
*/
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the dimensions of a particular `<Box>` element.
|
||||
*/
|
||||
const measureElement = (node: DOMElement): Output => ({
|
||||
width: node.yogaNode?.getComputedWidth() ?? 0,
|
||||
height: node.yogaNode?.getComputedHeight() ?? 0
|
||||
})
|
||||
|
||||
export default measureElement
|
||||
@@ -0,0 +1,50 @@
|
||||
import { lineWidth } from './line-width-cache.js'
|
||||
|
||||
type Output = {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
// Single-pass measurement: computes both width and height in one
|
||||
// iteration instead of two (widestLine + countVisualLines).
|
||||
// Uses indexOf to avoid array allocation from split('\n').
|
||||
function measureText(text: string, maxWidth: number): Output {
|
||||
if (text.length === 0) {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Infinite or non-positive width means no wrapping — each line is one visual line.
|
||||
// Must check before the loop since Math.ceil(w / Infinity) = 0.
|
||||
const noWrap = maxWidth <= 0 || !Number.isFinite(maxWidth)
|
||||
|
||||
let height = 0
|
||||
let width = 0
|
||||
let start = 0
|
||||
|
||||
while (start <= text.length) {
|
||||
const end = text.indexOf('\n', start)
|
||||
const line = end === -1 ? text.substring(start) : text.substring(start, end)
|
||||
|
||||
const w = lineWidth(line)
|
||||
width = Math.max(width, w)
|
||||
|
||||
if (noWrap) {
|
||||
height++
|
||||
} else {
|
||||
height += w === 0 ? 1 : Math.ceil(w / maxWidth)
|
||||
}
|
||||
|
||||
if (end === -1) {
|
||||
break
|
||||
}
|
||||
|
||||
start = end + 1
|
||||
}
|
||||
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
export default measureText
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { DOMElement } from './dom.js'
|
||||
import type { Rectangle } from './layout/geometry.js'
|
||||
|
||||
/**
|
||||
* Cached layout bounds for each rendered node (used for blit + clearing).
|
||||
* `top` is the yoga-local getComputedTop() — stored so ScrollBox viewport
|
||||
* culling can skip yoga reads for clean children whose position hasn't
|
||||
* shifted (O(dirty) instead of O(mounted) first-pass).
|
||||
*/
|
||||
export type CachedLayout = {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
top?: number
|
||||
}
|
||||
|
||||
export const nodeCache = new WeakMap<DOMElement, CachedLayout>()
|
||||
|
||||
/** Rects of removed children that need clearing on next render */
|
||||
export const pendingClears = new WeakMap<DOMElement, Rectangle[]>()
|
||||
|
||||
/**
|
||||
* Set when a pendingClear is added for an absolute-positioned node.
|
||||
* Signals renderer to disable blit for the next frame: the removed node
|
||||
* may have painted over non-siblings (e.g. an overlay over a ScrollBox
|
||||
* earlier in tree order), so their blits from prevScreen would restore
|
||||
* the overlay's pixels. Normal-flow removals are already handled by
|
||||
* hasRemovedChild at the parent level; only absolute positioning paints
|
||||
* cross-subtree. Reset at the start of each render.
|
||||
*/
|
||||
let absoluteNodeRemoved = false
|
||||
|
||||
export function addPendingClear(parent: DOMElement, rect: Rectangle, isAbsolute: boolean): void {
|
||||
const existing = pendingClears.get(parent)
|
||||
|
||||
if (existing) {
|
||||
existing.push(rect)
|
||||
} else {
|
||||
pendingClears.set(parent, [rect])
|
||||
}
|
||||
|
||||
if (isAbsolute) {
|
||||
absoluteNodeRemoved = true
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeAbsoluteRemovedFlag(): boolean {
|
||||
const had = absoluteNodeRemoved
|
||||
absoluteNodeRemoved = false
|
||||
|
||||
return had
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Diff } from './frame.js'
|
||||
|
||||
/**
|
||||
* Optimize a diff by applying all optimization rules in a single pass.
|
||||
* This reduces the number of patches that need to be written to the terminal.
|
||||
*
|
||||
* Rules applied:
|
||||
* - Remove empty stdout patches
|
||||
* - Merge consecutive cursorMove patches
|
||||
* - Remove no-op cursorMove (0,0) patches
|
||||
* - Concat adjacent style patches (transition diffs — can't drop either)
|
||||
* - Dedupe consecutive hyperlinks with same URI
|
||||
* - Cancel cursor hide/show pairs
|
||||
* - Remove clear patches with count 0
|
||||
*/
|
||||
export function optimize(diff: Diff): Diff {
|
||||
if (diff.length <= 1) {
|
||||
return diff
|
||||
}
|
||||
|
||||
const result: Diff = []
|
||||
let len = 0
|
||||
|
||||
for (const patch of diff) {
|
||||
const type = patch.type
|
||||
|
||||
// Skip no-ops
|
||||
if (type === 'stdout') {
|
||||
if (patch.content === '') {
|
||||
continue
|
||||
}
|
||||
} else if (type === 'cursorMove') {
|
||||
if (patch.x === 0 && patch.y === 0) {
|
||||
continue
|
||||
}
|
||||
} else if (type === 'clear') {
|
||||
if (patch.count === 0) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Try to merge with previous patch
|
||||
if (len > 0) {
|
||||
const lastIdx = len - 1
|
||||
const last = result[lastIdx]!
|
||||
const lastType = last.type
|
||||
|
||||
// Merge consecutive cursorMove
|
||||
if (type === 'cursorMove' && lastType === 'cursorMove') {
|
||||
result[lastIdx] = {
|
||||
type: 'cursorMove',
|
||||
x: last.x + patch.x,
|
||||
y: last.y + patch.y
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Collapse consecutive cursorTo (only the last one matters)
|
||||
if (type === 'cursorTo' && lastType === 'cursorTo') {
|
||||
result[lastIdx] = patch
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Concat adjacent style patches. styleStr is a transition diff
|
||||
// (computed by diffAnsiCodes(from, to)), not a setter — dropping
|
||||
// the first is only sound if its undo-codes are a subset of the
|
||||
// second's, which is NOT guaranteed. e.g. [\e[49m, \e[2m]: dropping
|
||||
// the bg reset leaks it into the next \e[2J/\e[2K via BCE.
|
||||
if (type === 'styleStr' && lastType === 'styleStr') {
|
||||
result[lastIdx] = { type: 'styleStr', str: last.str + patch.str }
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Dedupe hyperlinks
|
||||
if (type === 'hyperlink' && lastType === 'hyperlink' && patch.uri === last.uri) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Cancel cursor hide/show pairs
|
||||
if (
|
||||
(type === 'cursorShow' && lastType === 'cursorHide') ||
|
||||
(type === 'cursorHide' && lastType === 'cursorShow')
|
||||
) {
|
||||
result.pop()
|
||||
len--
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
result.push(patch)
|
||||
len++
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { INITIAL_STATE, parseMultipleKeypresses } from './parse-keypress.js'
|
||||
import { oscColor, TerminalQuerier } from './terminal-querier.js'
|
||||
import { parseOscColor } from './terminal.js'
|
||||
|
||||
// End-to-end (minus the PTY): a terminal's OSC 11 reply arriving on stdin
|
||||
// must tokenize as a response, match the pending oscColor(11) query, and
|
||||
// parse to a hex background. This is the exact chain App.tsx relies on for
|
||||
// light/dark detection — if any segment drops the reply, detection dies
|
||||
// silently because the querier never times out.
|
||||
|
||||
const parseWire = (raw: string) => {
|
||||
const [keys] = parseMultipleKeypresses({ ...INITIAL_STATE }, raw)
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
const drainResponses = (raw: string) => parseWire(raw).filter(k => k.kind === 'response')
|
||||
|
||||
describe('OSC 11 reply chain', () => {
|
||||
it.each([
|
||||
['BEL-terminated', '\x1b]11;rgb:ffff/ffff/ffff\x07', '#ffffff'],
|
||||
['ST-terminated', '\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\', '#1e1e2e']
|
||||
])('resolves a pending query from a %s reply', async (_label, wire, expectedHex) => {
|
||||
const writes: string[] = []
|
||||
const stdout = { write: (s: string) => (writes.push(s), true) } as unknown as NodeJS.WriteStream
|
||||
const querier = new TerminalQuerier(stdout)
|
||||
|
||||
const pending = querier.send(oscColor(11))
|
||||
|
||||
expect(writes.join('')).toContain('\x1b]11;?')
|
||||
|
||||
const responses = drainResponses(wire)
|
||||
|
||||
expect(responses).toHaveLength(1)
|
||||
|
||||
for (const r of responses) {
|
||||
if (r.kind === 'response') {
|
||||
querier.onResponse(r.response)
|
||||
}
|
||||
}
|
||||
|
||||
const reply = await pending
|
||||
|
||||
expect(reply?.type).toBe('osc')
|
||||
|
||||
if (reply?.type === 'osc') {
|
||||
expect(reply.code).toBe(11)
|
||||
expect(parseOscColor(reply.data)).toBe(expectedHex)
|
||||
}
|
||||
})
|
||||
|
||||
it('reply interleaved with keystrokes still resolves', async () => {
|
||||
const stdout = { write: () => true } as unknown as NodeJS.WriteStream
|
||||
const querier = new TerminalQuerier(stdout)
|
||||
const pending = querier.send(oscColor(11))
|
||||
|
||||
const keys = parseWire('a\x1b]11;rgb:0000/0000/0000\x07b')
|
||||
|
||||
for (const k of keys) {
|
||||
if (k.kind === 'response') {
|
||||
querier.onResponse(k.response)
|
||||
}
|
||||
}
|
||||
|
||||
const reply = await pending
|
||||
|
||||
expect(reply?.type).toBe('osc')
|
||||
|
||||
// The surrounding keystrokes survive as normal input.
|
||||
const chars = keys.filter(k => k.kind !== 'response')
|
||||
|
||||
expect(chars.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
Loaded 100 of 11340 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user