更新大量 tsx 原始文件; 已经迁移 login panel; 部分 (#121)

* style(B1-1): 格式化 ink/buddy/cli/context/screens/tasks/services/keybindings/state (43 files)

纯格式化:移除分号、React Compiler import、import 多行展开。
修复了 Box.tsx 和 ScrollBox.tsx 中无效的 global.d.ts import。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-2): 格式化 commands (79 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-3): 格式化 components/messages,permissions,mcp,sandbox,shell (104 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-4): 格式化 components/PromptInput,FeedbackSurvey,tasks,agents,skills,design-system,wizard (73 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-5): 格式化 components其余 + hooks + tools (232 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-6): 格式化 main/entrypoints/utils/moreright (21 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: 更新 README,新增 Run.ps1/TODO.md,删除 V6.md

- README.md: 大幅重写,更详细版本历史和配置示例
- Run.ps1: 新增 Windows 启动脚本
- TODO.md: 新增包完成清单
- V6.md: 删除(架构重构规划已不适用)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复以前的问题

* fix: 修复 login 面板的问题

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-04 23:24:27 +08:00
committed by GitHub
parent 02694918b5
commit 5b1a52b8e0
559 changed files with 103807 additions and 101817 deletions

View File

@@ -1,25 +1,31 @@
import { c as _c } from "react/compiler-runtime";
import React from 'react';
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';
import React from 'react'
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: string;
children: string
/** When true, force all text to be rendered with dim styling */
dimColor?: boolean;
};
dimColor?: boolean
}
type SpanProps = {
color?: Color;
backgroundColor?: Color;
dim?: boolean;
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
inverse?: boolean;
hyperlink?: string;
};
color?: Color
backgroundColor?: Color
dim?: boolean
bold?: boolean
italic?: boolean
underline?: boolean
strikethrough?: boolean
inverse?: boolean
hyperlink?: string
}
/**
* Component that parses ANSI escape codes and renders them using Text components.
@@ -29,145 +35,156 @@ type SpanProps = {
*
* Memoized to prevent re-renders when parent changes but children string is the same.
*/
export const Ansi = React.memo(function Ansi(t0: { children: React.ReactNode; dimColor?: boolean }) {
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];
export const Ansi = React.memo(function Ansi({
children,
dimColor,
}: Props): React.ReactNode {
if (typeof children !== 'string') {
return dimColor ? (
<Text dim>{String(children)}</Text>
) : (
<Text>{String(children)}</Text>
)
}
if (children === '') {
return null
}
const spans = parseToSpans(children)
if (spans.length === 0) {
return null
}
if (spans.length === 1 && !hasAnyProps(spans[0]!.props)) {
return dimColor ? (
<Text dim>{spans[0]!.text}</Text>
) : (
<Text>{spans[0]!.text}</Text>
)
}
const content = spans.map((span, i) => {
const hyperlink = span.props.hyperlink
// When dimColor is forced, override the span's dim prop
if (dimColor) {
span.props.dim = true
}
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, i) => {
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 color={span.props.color} backgroundColor={span.props.backgroundColor} dim={span.props.dim} bold={span.props.bold} italic={span.props.italic} underline={span.props.underline} strikethrough={span.props.strikethrough} inverse={span.props.inverse}>{span.text}</StyledText></Link> : <Link key={i} url={hyperlink}>{span.text}</Link>;
}
return hasTextProps ? <StyledText key={i} color={span.props.color} backgroundColor={span.props.backgroundColor} dim={span.props.dim} bold={span.props.bold} italic={span.props.italic} underline={span.props.underline} strikethrough={span.props.strikethrough} inverse={span.props.inverse}>{span.text}</StyledText> : span.text;
};
$[7] = dimColor;
$[8] = t3;
} else {
t3 = $[8];
}
t1 = spans.map(t3);
const hasTextProps = hasAnyTextProps(span.props)
if (hyperlink) {
return hasTextProps ? (
<Link key={i} url={hyperlink}>
<StyledText
color={span.props.color}
backgroundColor={span.props.backgroundColor}
dim={span.props.dim}
bold={span.props.bold}
italic={span.props.italic}
underline={span.props.underline}
strikethrough={span.props.strikethrough}
inverse={span.props.inverse}
>
{span.text}
</StyledText>
</Link>
) : (
<Link key={i} url={hyperlink}>
{span.text}
</Link>
)
}
$[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;
});
return hasTextProps ? (
<StyledText
key={i}
color={span.props.color}
backgroundColor={span.props.backgroundColor}
dim={span.props.dim}
bold={span.props.bold}
italic={span.props.italic}
underline={span.props.underline}
strikethrough={span.props.strikethrough}
inverse={span.props.inverse}
>
{span.text}
</StyledText>
) : (
span.text
)
})
return dimColor ? <Text dim>{content}</Text> : <Text>{content}</Text>
})
type Span = {
text: string;
props: SpanProps;
};
text: string
props: SpanProps
}
/**
* 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;
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;
currentHyperlink = action.action.url
} else {
currentHyperlink = undefined;
currentHyperlink = undefined
}
continue;
continue
}
if (action.type === 'text') {
const text = action.graphemes.map(g => g.value).join('');
if (!text) continue;
const props = textStyleToSpanProps(action.style);
const text = action.graphemes.map(g => g.value).join('')
if (!text) continue
const props = textStyleToSpanProps(action.style)
if (currentHyperlink) {
props.hyperlink = currentHyperlink;
props.hyperlink = currentHyperlink
}
// Try to merge with previous span if props match
const lastSpan = spans[spans.length - 1];
const lastSpan = spans[spans.length - 1]
if (lastSpan && propsEqual(lastSpan.props, props)) {
lastSpan.text += text;
lastSpan.text += text
} else {
spans.push({
text,
props
});
spans.push({ text, props })
}
}
}
return spans;
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;
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
@@ -187,8 +204,8 @@ const NAMED_COLOR_MAP: Record<NamedColor, string> = {
brightBlue: 'ansi:blueBright',
brightMagenta: 'ansi:magentaBright',
brightCyan: 'ansi:cyanBright',
brightWhite: 'ansi:whiteBright'
};
brightWhite: 'ansi:whiteBright',
}
/**
* Convert termio's Color to the string format used by Ink.
@@ -196,13 +213,13 @@ const NAMED_COLOR_MAP: Record<NamedColor, string> = {
function colorToString(color: TermioColor): Color | undefined {
switch (color.type) {
case 'named':
return NAMED_COLOR_MAP[color.name] as Color;
return NAMED_COLOR_MAP[color.name] as Color
case 'indexed':
return `ansi256(${color.index})` as Color;
return `ansi256(${color.index})` as Color
case 'rgb':
return `rgb(${color.r},${color.g},${color.b})` as Color;
return `rgb(${color.r},${color.g},${color.b})` as Color
case 'default':
return undefined;
return undefined
}
}
@@ -210,82 +227,81 @@ function colorToString(color: TermioColor): Color | 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;
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;
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;
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;
};
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) {
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];
}
function StyledText({
bold,
dim,
children,
...rest
}: BaseTextStyleProps & {
bold?: boolean
dim?: boolean
children: string
}): React.ReactNode {
// dim takes precedence over bold when both are set (terminals treat them as mutually exclusive)
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;
return (
<Text {...rest} dim>
{children}
</Text>
)
}
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;
return (
<Text {...rest} bold>
{children}
</Text>
)
}
let t1;
if ($[11] !== children || $[12] !== rest) {
t1 = <Text {...rest}>{children}</Text>;
$[11] = children;
$[12] = rest;
$[13] = t1;
} else {
t1 = $[13];
}
return t1;
return <Text {...rest}>{children}</Text>
}

View File

@@ -1,14 +1,23 @@
import { c as _c } from "react/compiler-runtime";
import React, { type PropsWithChildren, useContext, useInsertionEffect } from 'react';
import instances from '../instances.js';
import { DISABLE_MOUSE_TRACKING, ENABLE_MOUSE_TRACKING, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN } from '../termio/dec.js';
import { TerminalWriteContext } from '../useTerminalNotification.js';
import Box from './Box.js';
import { TerminalSizeContext } from './TerminalSizeContext.js';
import React, {
type PropsWithChildren,
useContext,
useInsertionEffect,
} from 'react'
import instances from '../instances.js'
import {
DISABLE_MOUSE_TRACKING,
ENABLE_MOUSE_TRACKING,
ENTER_ALT_SCREEN,
EXIT_ALT_SCREEN,
} from '../termio/dec.js'
import { TerminalWriteContext } from '../useTerminalNotification.js'
import Box from './Box.js'
import { TerminalSizeContext } from './TerminalSizeContext.js'
type Props = PropsWithChildren<{
/** Enable SGR mouse tracking (wheel + click/drag). Default true. */
mouseTracking?: boolean;
}>;
mouseTracking?: boolean
}>
/**
* Run children in the terminal's alternate screen buffer, constrained to
@@ -30,50 +39,49 @@ type Props = PropsWithChildren<{
* 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) {
const $ = _c(7);
const {
children,
mouseTracking: t1
} = t0;
const mouseTracking = t1 === undefined ? true : 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;
}
writeRaw(ENTER_ALT_SCREEN + "\x1B[2J\x1B[H" + (mouseTracking ? ENABLE_MOUSE_TRACKING : ""));
ink?.setAltScreenActive(true, mouseTracking);
return () => {
ink?.setAltScreenActive(false);
ink?.clearTextSelection();
writeRaw((mouseTracking ? 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" height={t4} width="100%" flexShrink={0}>{children}</Box>;
$[4] = children;
$[5] = t4;
$[6] = t5;
} else {
t5 = $[6];
}
return t5;
export function AlternateScreen({
children,
mouseTracking = true,
}: Props): React.ReactNode {
const size = useContext(TerminalSizeContext)
const writeRaw = useContext(TerminalWriteContext)
// useInsertionEffect (not useLayoutEffect): react-reconciler calls
// resetAfterCommit between the mutation and layout commit phases, and
// Ink's resetAfterCommit triggers onRender. With useLayoutEffect, that
// first onRender fires BEFORE this effect — writing a full frame to the
// main screen with altScreen=false. That frame is preserved when we
// enter alt screen and revealed on exit as a broken view. Insertion
// effects fire during the mutation phase, before resetAfterCommit, so
// ENTER_ALT_SCREEN reaches the terminal before the first frame does.
// Cleanup timing is unchanged: both insertion and layout effect cleanup
// run in the mutation phase on unmount, before resetAfterCommit.
useInsertionEffect(() => {
const ink = instances.get(process.stdout)
if (!writeRaw) return
writeRaw(
ENTER_ALT_SCREEN +
'\x1b[2J\x1b[H' +
(mouseTracking ? ENABLE_MOUSE_TRACKING : ''),
)
ink?.setAltScreenActive(true, mouseTracking)
return () => {
ink?.setAltScreenActive(false)
ink?.clearTextSelection()
writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN)
}
}, [writeRaw, mouseTracking])
return (
<Box
flexDirection="column"
height={size?.rows ?? 24}
width="100%"
flexShrink={0}
>
{children}
</Box>
)
}

View File

@@ -1,223 +1,290 @@
import React, { PureComponent, type ReactNode } from 'react';
import { updateLastInteractionTime } from '../../bootstrap/state.js';
import { logForDebugging } from '../../utils/debug.js';
import { stopCapturingEarlyInput } from '../../utils/earlyInput.js';
import { isEnvTruthy } from '../../utils/envUtils.js';
import { isMouseClicksDisabled } from '../../utils/fullscreen.js';
import { logError } from '../../utils/log.js';
import { EventEmitter } from '../events/emitter.js';
import { InputEvent } from '../events/input-event.js';
import { TerminalFocusEvent } from '../events/terminal-focus-event.js';
import { INITIAL_STATE, type ParsedInput, type ParsedKey, type ParsedMouse, parseMultipleKeypresses } from '../parse-keypress.js';
import reconciler from '../reconciler.js';
import { finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js';
import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal.js';
import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js';
import { TerminalQuerier, xtversion } from '../terminal-querier.js';
import { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS, ENABLE_KITTY_KEYBOARD, ENABLE_MODIFY_OTHER_KEYS, FOCUS_IN, FOCUS_OUT } from '../termio/csi.js';
import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, HIDE_CURSOR, SHOW_CURSOR } from '../termio/dec.js';
import AppContext from './AppContext.js';
import { ClockProvider } from './ClockContext.js';
import CursorDeclarationContext, { type CursorDeclarationSetter } from './CursorDeclarationContext.js';
import ErrorOverview from './ErrorOverview.js';
import StdinContext from './StdinContext.js';
import { TerminalFocusProvider } from './TerminalFocusContext.js';
import { TerminalSizeContext } from './TerminalSizeContext.js';
import React, { PureComponent, type ReactNode } from 'react'
import { updateLastInteractionTime } from '../../bootstrap/state.js'
import { logForDebugging } from '../../utils/debug.js'
import { stopCapturingEarlyInput } from '../../utils/earlyInput.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { isMouseClicksDisabled } from '../../utils/fullscreen.js'
import { logError } from '../../utils/log.js'
import { EventEmitter } from '../events/emitter.js'
import { InputEvent } from '../events/input-event.js'
import { TerminalFocusEvent } from '../events/terminal-focus-event.js'
import {
INITIAL_STATE,
type ParsedInput,
type ParsedKey,
type ParsedMouse,
parseMultipleKeypresses,
} from '../parse-keypress.js'
import reconciler from '../reconciler.js'
import {
finishSelection,
hasSelection,
type SelectionState,
startSelection,
} from '../selection.js'
import {
isXtermJs,
setXtversionName,
supportsExtendedKeys,
} from '../terminal.js'
import {
getTerminalFocused,
setTerminalFocused,
} from '../terminal-focus-state.js'
import { TerminalQuerier, xtversion } from '../terminal-querier.js'
import {
DISABLE_KITTY_KEYBOARD,
DISABLE_MODIFY_OTHER_KEYS,
ENABLE_KITTY_KEYBOARD,
ENABLE_MODIFY_OTHER_KEYS,
FOCUS_IN,
FOCUS_OUT,
} from '../termio/csi.js'
import {
DBP,
DFE,
DISABLE_MOUSE_TRACKING,
EBP,
EFE,
HIDE_CURSOR,
SHOW_CURSOR,
} from '../termio/dec.js'
import AppContext from './AppContext.js'
import { ClockProvider } from './ClockContext.js'
import CursorDeclarationContext, {
type CursorDeclarationSetter,
} from './CursorDeclarationContext.js'
import ErrorOverview from './ErrorOverview.js'
import StdinContext from './StdinContext.js'
import { TerminalFocusProvider } from './TerminalFocusContext.js'
import { TerminalSizeContext } from './TerminalSizeContext.js'
// Platforms that support Unix-style process suspension (SIGSTOP/SIGCONT)
const SUPPORTS_SUSPEND = process.platform !== 'win32';
const SUPPORTS_SUSPEND = process.platform !== 'win32'
// After this many milliseconds of stdin silence, the next chunk triggers
// a terminal mode re-assert (mouse tracking). Catches tmux detach→attach,
// ssh reconnect, and laptop wake — the terminal resets DEC private modes
// but no signal reaches us. 5s is well above normal inter-keystroke gaps
// but short enough that the first scroll after reattach works.
const STDIN_RESUME_GAP_MS = 5000;
const STDIN_RESUME_GAP_MS = 5000
type Props = {
readonly children: ReactNode;
readonly stdin: NodeJS.ReadStream;
readonly stdout: NodeJS.WriteStream;
readonly stderr: NodeJS.WriteStream;
readonly exitOnCtrlC: boolean;
readonly onExit: (error?: Error) => void;
readonly terminalColumns: number;
readonly terminalRows: number;
readonly children: ReactNode
readonly stdin: NodeJS.ReadStream
readonly stdout: NodeJS.WriteStream
readonly stderr: NodeJS.WriteStream
readonly exitOnCtrlC: boolean
readonly onExit: (error?: Error) => void
readonly terminalColumns: number
readonly terminalRows: number
// Text selection state. App mutates this directly from mouse events
// and calls onSelectionChange to trigger a repaint. Mouse events only
// arrive when <AlternateScreen> (or similar) enables mouse tracking,
// so the handler is always wired but dormant until tracking is on.
readonly selection: SelectionState;
readonly onSelectionChange: () => void;
readonly selection: SelectionState
readonly onSelectionChange: () => void
// Dispatch a click at (col, row) — hit-tests the DOM tree and bubbles
// onClick handlers. Returns true if a DOM handler consumed the click.
// No-op (returns false) outside fullscreen mode (Ink.dispatchClick
// gates on altScreenActive).
readonly onClickAt: (col: number, row: number) => boolean;
readonly onClickAt: (col: number, row: number) => boolean
// Dispatch hover (onMouseEnter/onMouseLeave) as the pointer moves over
// DOM elements. Called for mode-1003 motion events with no button held.
// No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive).
readonly onHoverAt: (col: number, row: number) => void;
readonly onHoverAt: (col: number, row: number) => void
// Look up the OSC 8 hyperlink at (col, row) synchronously at click
// time. Returns the URL or undefined. The browser-open is deferred by
// MULTI_CLICK_TIMEOUT_MS so double-click can cancel it.
readonly getHyperlinkAt: (col: number, row: number) => string | undefined;
readonly getHyperlinkAt: (col: number, row: number) => string | undefined
// Open a hyperlink URL in the browser. Called after the timer fires.
readonly onOpenHyperlink: (url: string) => void;
readonly onOpenHyperlink: (url: string) => void
// Called on double/triple-click PRESS at (col, row). count=2 selects
// the word under the cursor; count=3 selects the line. Ink reads the
// screen buffer to find word/line boundaries and mutates selection,
// setting isDragging=true so a subsequent drag extends by word/line.
readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void;
readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void
// Called on drag-motion. Mode-aware: char mode updates focus to the
// exact cell; word/line mode snaps to word/line boundaries. Needs
// screen-buffer access (word boundaries) so lives on Ink, not here.
readonly onSelectionDrag: (col: number, row: number) => void;
readonly onSelectionDrag: (col: number, row: number) => void
// Called when stdin data arrives after a >STDIN_RESUME_GAP_MS gap.
// Ink re-asserts terminal modes: extended key reporting, and (when in
// fullscreen) re-enters alt-screen + mouse tracking. Idempotent on the
// terminal side. Optional so testing.tsx doesn't need to stub it.
readonly onStdinResume?: () => void;
readonly onStdinResume?: () => void
// Receives the declared native-cursor position from useDeclaredCursor
// so ink.tsx can park the terminal cursor there after each frame.
// Enables IME composition at the input caret and lets screen readers /
// magnifiers track the input. Optional so testing.tsx doesn't stub it.
readonly onCursorDeclaration?: CursorDeclarationSetter;
readonly onCursorDeclaration?: CursorDeclarationSetter
// Dispatch a keyboard event through the DOM tree. Called for each
// parsed key alongside the legacy EventEmitter path.
readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => void;
};
readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => void
}
// Multi-click detection thresholds. 500ms is the macOS default; a small
// position tolerance allows for trackpad jitter between clicks.
const MULTI_CLICK_TIMEOUT_MS = 500;
const MULTI_CLICK_DISTANCE = 1;
const MULTI_CLICK_TIMEOUT_MS = 500
const MULTI_CLICK_DISTANCE = 1
type State = {
readonly error?: Error;
};
readonly error?: Error
}
// Root component for all Ink apps
// It renders stdin and stdout contexts, so that children can access them if needed
// It also handles Ctrl+C exiting and cursor visibility
export default class App extends PureComponent<Props, State> {
static displayName = 'InternalApp';
static displayName = 'InternalApp'
static getDerivedStateFromError(error: Error) {
return {
error
};
return { error }
}
override state = {
error: undefined
};
error: undefined,
}
// Count how many components enabled raw mode to avoid disabling
// raw mode until all components don't need it anymore
rawModeEnabledCount = 0;
internal_eventEmitter = new EventEmitter();
keyParseState = INITIAL_STATE;
rawModeEnabledCount = 0
internal_eventEmitter = new EventEmitter()
keyParseState = INITIAL_STATE
// Timer for flushing incomplete escape sequences
incompleteEscapeTimer: NodeJS.Timeout | null = null;
incompleteEscapeTimer: NodeJS.Timeout | null = null
// Timeout durations for incomplete sequences (ms)
readonly NORMAL_TIMEOUT = 50; // Short timeout for regular esc sequences
readonly PASTE_TIMEOUT = 500; // Longer timeout for paste operations
readonly NORMAL_TIMEOUT = 50 // Short timeout for regular esc sequences
readonly PASTE_TIMEOUT = 500 // Longer timeout for paste operations
// Terminal query/response dispatch. Responses arrive on stdin (parsed
// out by parse-keypress) and are routed to pending promise resolvers.
querier = new TerminalQuerier(this.props.stdout);
querier = new TerminalQuerier(this.props.stdout)
// Multi-click tracking for double/triple-click text selection. A click
// within MULTI_CLICK_TIMEOUT_MS and MULTI_CLICK_DISTANCE of the previous
// click increments clickCount; otherwise it resets to 1.
lastClickTime = 0;
lastClickCol = -1;
lastClickRow = -1;
clickCount = 0;
lastClickTime = 0
lastClickCol = -1
lastClickRow = -1
clickCount = 0
// Deferred hyperlink-open timer — cancelled if a second click arrives
// within MULTI_CLICK_TIMEOUT_MS (so double-clicking a hyperlink selects
// the word without also opening the browser). DOM onClick dispatch is
// NOT deferred — it returns true from onClickAt and skips this timer.
pendingHyperlinkTimer: ReturnType<typeof setTimeout> | null = null;
pendingHyperlinkTimer: ReturnType<typeof setTimeout> | null = null
// Last mode-1003 motion position. Terminals already dedupe to cell
// granularity but this also lets us skip dispatchHover entirely on
// repeat events (drag-then-release at same cell, etc.).
lastHoverCol = -1;
lastHoverRow = -1;
lastHoverCol = -1
lastHoverRow = -1
// Timestamp of last stdin chunk. Used to detect long gaps (tmux attach,
// ssh reconnect, laptop wake) and trigger terminal mode re-assert.
// Initialized to now so startup doesn't false-trigger.
lastStdinTime = Date.now();
lastStdinTime = Date.now()
// Determines if TTY is supported on the provided stdin
isRawModeSupported(): boolean {
return this.props.stdin.isTTY;
return this.props.stdin.isTTY
}
override render() {
return <TerminalSizeContext.Provider value={{
columns: this.props.terminalColumns,
rows: this.props.terminalRows
}}>
<AppContext.Provider value={{
exit: this.handleExit
}}>
<StdinContext.Provider value={{
stdin: this.props.stdin,
setRawMode: this.handleSetRawMode,
isRawModeSupported: this.isRawModeSupported(),
internal_exitOnCtrlC: this.props.exitOnCtrlC,
internal_eventEmitter: this.internal_eventEmitter,
internal_querier: this.querier
}}>
return (
<TerminalSizeContext.Provider
value={{
columns: this.props.terminalColumns,
rows: this.props.terminalRows,
}}
>
<AppContext.Provider
value={{
exit: this.handleExit,
}}
>
<StdinContext.Provider
value={{
stdin: this.props.stdin,
setRawMode: this.handleSetRawMode,
isRawModeSupported: this.isRawModeSupported(),
internal_exitOnCtrlC: this.props.exitOnCtrlC,
internal_eventEmitter: this.internal_eventEmitter,
internal_querier: this.querier,
}}
>
<TerminalFocusProvider>
<ClockProvider>
<CursorDeclarationContext.Provider value={this.props.onCursorDeclaration ?? (() => {})}>
{this.state.error ? <ErrorOverview error={this.state.error as Error} /> : this.props.children}
<CursorDeclarationContext.Provider
value={this.props.onCursorDeclaration ?? (() => {})}
>
{this.state.error ? (
<ErrorOverview error={this.state.error as Error} />
) : (
this.props.children
)}
</CursorDeclarationContext.Provider>
</ClockProvider>
</TerminalFocusProvider>
</StdinContext.Provider>
</AppContext.Provider>
</TerminalSizeContext.Provider>;
</TerminalSizeContext.Provider>
)
}
override componentDidMount() {
// In accessibility mode, keep the native cursor visible for screen magnifiers and other tools
if (this.props.stdout.isTTY && !isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) {
this.props.stdout.write(HIDE_CURSOR);
if (
this.props.stdout.isTTY &&
!isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)
) {
this.props.stdout.write(HIDE_CURSOR)
}
}
override componentWillUnmount() {
if (this.props.stdout.isTTY) {
this.props.stdout.write(SHOW_CURSOR);
this.props.stdout.write(SHOW_CURSOR)
}
// Clear any pending timers
if (this.incompleteEscapeTimer) {
clearTimeout(this.incompleteEscapeTimer);
this.incompleteEscapeTimer = null;
clearTimeout(this.incompleteEscapeTimer)
this.incompleteEscapeTimer = null
}
if (this.pendingHyperlinkTimer) {
clearTimeout(this.pendingHyperlinkTimer);
this.pendingHyperlinkTimer = null;
clearTimeout(this.pendingHyperlinkTimer)
this.pendingHyperlinkTimer = null
}
// ignore calling setRawMode on an handle stdin it cannot be called
if (this.isRawModeSupported()) {
this.handleSetRawMode(false);
this.handleSetRawMode(false)
}
}
override componentDidCatch(error: Error) {
this.handleExit(error);
this.handleExit(error)
}
handleSetRawMode = (isEnabled: boolean): void => {
const {
stdin
} = this.props;
const { stdin } = this.props
if (!this.isRawModeSupported()) {
if (stdin === process.stdin) {
throw new Error('Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported');
throw new Error(
'Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported',
)
} else {
throw new Error('Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported');
throw new Error(
'Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported',
)
}
}
stdin.setEncoding('utf8');
stdin.setEncoding('utf8')
if (isEnabled) {
// Ensure raw mode is enabled only once
if (this.rawModeEnabledCount === 0) {
@@ -225,22 +292,22 @@ export default class App extends PureComponent<Props, State> {
// Both use the same stdin 'readable' + read() pattern, so they can't
// coexist -- our handler would drain stdin before Ink's can see it.
// The buffered text is preserved for REPL.tsx via consumeEarlyInput().
stopCapturingEarlyInput();
stdin.ref();
stdin.setRawMode(true);
stdin.addListener('readable', this.handleReadable);
stopCapturingEarlyInput()
stdin.ref()
stdin.setRawMode(true)
stdin.addListener('readable', this.handleReadable)
// Enable bracketed paste mode
this.props.stdout.write(EBP);
this.props.stdout.write(EBP)
// Enable terminal focus reporting (DECSET 1004)
this.props.stdout.write(EFE);
this.props.stdout.write(EFE)
// Enable extended key reporting so ctrl+shift+<letter> is
// distinguishable from ctrl+<letter>. We write both the kitty stack
// push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) —
// terminals honor whichever they implement (tmux only accepts the
// latter).
if (supportsExtendedKeys()) {
this.props.stdout.write(ENABLE_KITTY_KEYBOARD);
this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS);
this.props.stdout.write(ENABLE_KITTY_KEYBOARD)
this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS)
}
// Probe terminal identity. XTVERSION survives SSH (query/reply goes
// through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base
@@ -251,41 +318,45 @@ export default class App extends PureComponent<Props, State> {
// init sequence completes — avoids interleaving with alt-screen/mouse
// tracking enable writes that may happen in the same render cycle.
setImmediate(() => {
void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => {
void Promise.all([
this.querier.send(xtversion()),
this.querier.flush(),
]).then(([r]) => {
if (r) {
setXtversionName(r.name);
logForDebugging(`XTVERSION: terminal identified as "${r.name}"`);
setXtversionName(r.name)
logForDebugging(`XTVERSION: terminal identified as "${r.name}"`)
} else {
logForDebugging('XTVERSION: no reply (terminal ignored query)');
logForDebugging('XTVERSION: no reply (terminal ignored query)')
}
});
});
})
})
}
this.rawModeEnabledCount++;
return;
this.rawModeEnabledCount++
return
}
// Disable raw mode only when no components left that are using it
if (--this.rawModeEnabledCount === 0) {
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS);
this.props.stdout.write(DISABLE_KITTY_KEYBOARD);
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS)
this.props.stdout.write(DISABLE_KITTY_KEYBOARD)
// Disable terminal focus reporting (DECSET 1004)
this.props.stdout.write(DFE);
this.props.stdout.write(DFE)
// Disable bracketed paste mode
this.props.stdout.write(DBP);
stdin.setRawMode(false);
stdin.removeListener('readable', this.handleReadable);
stdin.unref();
this.props.stdout.write(DBP)
stdin.setRawMode(false)
stdin.removeListener('readable', this.handleReadable)
stdin.unref()
}
};
}
// Helper to flush incomplete escape sequences
flushIncomplete = (): void => {
// Clear the timer reference
this.incompleteEscapeTimer = null;
this.incompleteEscapeTimer = null
// Only proceed if we have incomplete sequences
if (!this.keyParseState.incomplete) return;
if (!this.keyParseState.incomplete) return
// Fullscreen: if stdin has data waiting, it's almost certainly the
// continuation of the buffered sequence (e.g. `[<64;74;16M` after a
@@ -296,20 +367,23 @@ export default class App extends PureComponent<Props, State> {
// drain stdin next and clear this timer. Prevents both the spurious
// Escape key and the lost scroll event.
if (this.props.stdin.readableLength > 0) {
this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.NORMAL_TIMEOUT);
return;
this.incompleteEscapeTimer = setTimeout(
this.flushIncomplete,
this.NORMAL_TIMEOUT,
)
return
}
// Process incomplete as a flush operation (input=null)
// This reuses all existing parsing logic
this.processInput(null);
};
this.processInput(null)
}
// Process input through the parser and handle the results
processInput = (input: string | Buffer | null): void => {
// Parse input using our state machine
const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input);
this.keyParseState = newState;
const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input)
this.keyParseState = newState
// Process ALL keys in a SINGLE discreteUpdates call to prevent
// "Maximum update depth exceeded" error when many keys arrive at once
@@ -317,87 +391,106 @@ export default class App extends PureComponent<Props, State> {
// This batches all state updates from handleInput and all useInput
// listeners together within one high-priority update context.
if (keys.length > 0) {
reconciler.discreteUpdates(processKeysInBatch, this, keys, undefined, undefined);
reconciler.discreteUpdates(
processKeysInBatch,
this,
keys,
undefined,
undefined,
)
}
// If we have incomplete escape sequences, set a timer to flush them
if (this.keyParseState.incomplete) {
// Cancel any existing timer first
if (this.incompleteEscapeTimer) {
clearTimeout(this.incompleteEscapeTimer);
clearTimeout(this.incompleteEscapeTimer)
}
this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.keyParseState.mode === 'IN_PASTE' ? this.PASTE_TIMEOUT : this.NORMAL_TIMEOUT);
this.incompleteEscapeTimer = setTimeout(
this.flushIncomplete,
this.keyParseState.mode === 'IN_PASTE'
? this.PASTE_TIMEOUT
: this.NORMAL_TIMEOUT,
)
}
};
}
handleReadable = (): void => {
// Detect long stdin gaps (tmux attach, ssh reconnect, laptop wake).
// The terminal may have reset DEC private modes; re-assert mouse
// tracking. Checked before the read loop so one Date.now() covers
// all chunks in this readable event.
const now = Date.now();
const now = Date.now()
if (now - this.lastStdinTime > STDIN_RESUME_GAP_MS) {
this.props.onStdinResume?.();
this.props.onStdinResume?.()
}
this.lastStdinTime = now;
this.lastStdinTime = now
try {
let chunk;
let chunk
while ((chunk = this.props.stdin.read() as string | null) !== null) {
// Process the input chunk
this.processInput(chunk);
this.processInput(chunk)
}
} catch (error) {
// In Bun, an uncaught throw inside a stream 'readable' handler can
// permanently wedge the stream: data stays buffered and 'readable'
// never re-emits. Catching here ensures the stream stays healthy so
// subsequent keystrokes are still delivered.
logError(error);
logError(error)
// Re-attach the listener in case the exception detached it.
// Bun may remove the listener after an error; without this,
// the session freezes permanently (stdin reader dead, event loop alive).
const {
stdin
} = this.props;
if (this.rawModeEnabledCount > 0 && !stdin.listeners('readable').includes(this.handleReadable)) {
logForDebugging('handleReadable: re-attaching stdin readable listener after error recovery', {
level: 'warn'
});
stdin.addListener('readable', this.handleReadable);
const { stdin } = this.props
if (
this.rawModeEnabledCount > 0 &&
!stdin.listeners('readable').includes(this.handleReadable)
) {
logForDebugging(
'handleReadable: re-attaching stdin readable listener after error recovery',
{ level: 'warn' },
)
stdin.addListener('readable', this.handleReadable)
}
}
};
}
handleInput = (input: string | undefined): void => {
// Exit on Ctrl+C
if (input === '\x03' && this.props.exitOnCtrlC) {
this.handleExit();
this.handleExit()
}
// Note: Ctrl+Z (suspend) is now handled in processKeysInBatch using the
// parsed key to support both raw (\x1a) and CSI u format from Kitty
// keyboard protocol terminals (Ghostty, iTerm2, kitty, WezTerm)
};
}
handleExit = (error?: Error): void => {
if (this.isRawModeSupported()) {
this.handleSetRawMode(false);
this.handleSetRawMode(false)
}
this.props.onExit(error);
};
this.props.onExit(error)
}
handleTerminalFocus = (isFocused: boolean): void => {
// setTerminalFocused notifies subscribers: TerminalFocusProvider (context)
// and Clock (interval speed) — no App setState needed.
setTerminalFocused(isFocused);
};
setTerminalFocused(isFocused)
}
handleSuspend = (): void => {
if (!this.isRawModeSupported()) {
return;
return
}
// Store the exact raw mode count to restore it properly
const rawModeCountBeforeSuspend = this.rawModeEnabledCount;
const rawModeCountBeforeSuspend = this.rawModeEnabledCount
// Completely disable raw mode before suspending
while (this.rawModeEnabledCount > 0) {
this.handleSetRawMode(false);
this.handleSetRawMode(false)
}
// Show cursor, disable focus reporting, and disable mouse tracking
@@ -406,108 +499,125 @@ export default class App extends PureComponent<Props, State> {
// it, SGR mouse sequences would appear as garbled text at the
// shell prompt while suspended.
if (this.props.stdout.isTTY) {
this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING);
this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING)
}
// Emit suspend event for Claude Code to handle. Mostly just has a notification
this.internal_eventEmitter.emit('suspend');
this.internal_eventEmitter.emit('suspend')
// Set up resume handler
const resumeHandler = () => {
// Restore raw mode to exact previous state
for (let i = 0; i < rawModeCountBeforeSuspend; i++) {
if (this.isRawModeSupported()) {
this.handleSetRawMode(true);
this.handleSetRawMode(true)
}
}
// Hide cursor (unless in accessibility mode) and re-enable focus reporting after resuming
if (this.props.stdout.isTTY) {
if (!isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) {
this.props.stdout.write(HIDE_CURSOR);
this.props.stdout.write(HIDE_CURSOR)
}
// Re-enable focus reporting to restore terminal state
this.props.stdout.write(EFE);
this.props.stdout.write(EFE)
}
// Emit resume event for Claude Code to handle
this.internal_eventEmitter.emit('resume');
process.removeListener('SIGCONT', resumeHandler);
};
process.on('SIGCONT', resumeHandler);
process.kill(process.pid, 'SIGSTOP');
};
this.internal_eventEmitter.emit('resume')
process.removeListener('SIGCONT', resumeHandler)
}
process.on('SIGCONT', resumeHandler)
process.kill(process.pid, 'SIGSTOP')
}
}
// Helper to process all keys within a single discrete update context.
// discreteUpdates expects (fn, a, b, c, d) -> fn(a, b, c, d)
function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined, _unused2: undefined): void {
function processKeysInBatch(
app: App,
items: ParsedInput[],
_unused1: undefined,
_unused2: undefined,
): void {
// Update interaction time for notification timeout tracking.
// This is called from the central input handler to avoid having multiple
// stdin listeners that can cause race conditions and dropped input.
// Terminal responses (kind: 'response') are automated, not user input.
// Mode-1003 no-button motion is also excluded — passive cursor drift is
// not engagement (would suppress idle notifications + defer housekeeping).
if (items.some(i => i.kind === 'key' || i.kind === 'mouse' && !((i.button & 0x20) !== 0 && (i.button & 0x03) === 3))) {
updateLastInteractionTime();
if (
items.some(
i =>
i.kind === 'key' ||
(i.kind === 'mouse' &&
!((i.button & 0x20) !== 0 && (i.button & 0x03) === 3)),
)
) {
updateLastInteractionTime()
}
for (const item of items) {
// Terminal responses (DECRPM, DA1, OSC replies, etc.) are not user
// input — route them to the querier to resolve pending promises.
if (item.kind === 'response') {
app.querier.onResponse(item.response);
continue;
app.querier.onResponse(item.response)
continue
}
// Mouse click/drag events update selection state (fullscreen only).
// Terminal sends 1-indexed col/row; convert to 0-indexed for the
// screen buffer. Button bit 0x20 = drag (motion while button held).
if (item.kind === 'mouse') {
handleMouseEvent(app, item);
continue;
handleMouseEvent(app, item)
continue
}
const sequence = item.sequence;
const sequence = item.sequence
// Handle terminal focus events (DECSET 1004)
if (sequence === FOCUS_IN) {
app.handleTerminalFocus(true);
const event = new TerminalFocusEvent('terminalfocus');
app.internal_eventEmitter.emit('terminalfocus', event);
continue;
app.handleTerminalFocus(true)
const event = new TerminalFocusEvent('terminalfocus')
app.internal_eventEmitter.emit('terminalfocus', event)
continue
}
if (sequence === FOCUS_OUT) {
app.handleTerminalFocus(false);
app.handleTerminalFocus(false)
// Defensive: if we lost the release event (mouse released outside
// terminal window — some emulators drop it rather than capturing the
// pointer), focus-out is the next observable signal that the drag is
// over. Without this, drag-to-scroll's timer runs until the scroll
// boundary is hit.
if (app.props.selection.isDragging) {
finishSelection(app.props.selection);
app.props.onSelectionChange();
finishSelection(app.props.selection)
app.props.onSelectionChange()
}
const event = new TerminalFocusEvent('terminalblur');
app.internal_eventEmitter.emit('terminalblur', event);
continue;
const event = new TerminalFocusEvent('terminalblur')
app.internal_eventEmitter.emit('terminalblur', event)
continue
}
// Failsafe: if we receive input, the terminal must be focused
if (!getTerminalFocused()) {
setTerminalFocused(true);
setTerminalFocused(true)
}
// Handle Ctrl+Z (suspend) using parsed key to support both raw (\x1a) and
// CSI u format (\x1b[122;5u) from Kitty keyboard protocol terminals
if (item.name === 'z' && item.ctrl && SUPPORTS_SUSPEND) {
app.handleSuspend();
continue;
app.handleSuspend()
continue
}
app.handleInput(sequence);
const event = new InputEvent(item);
app.internal_eventEmitter.emit('input', event);
app.handleInput(sequence)
const event = new InputEvent(item)
app.internal_eventEmitter.emit('input', event)
// Also dispatch through the DOM tree so onKeyDown handlers fire.
app.props.dispatchKeyboardEvent(item);
app.props.dispatchKeyboardEvent(item)
}
}
@@ -515,12 +625,14 @@ function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined,
export function handleMouseEvent(app: App, m: ParsedMouse): void {
// Allow disabling click handling while keeping wheel scroll (which goes
// through the keybinding system as 'wheelup'/'wheeldown', not here).
if (isMouseClicksDisabled()) return;
const sel = app.props.selection;
if (isMouseClicksDisabled()) return
const sel = app.props.selection
// Terminal coords are 1-indexed; screen buffer is 0-indexed
const col = m.col - 1;
const row = m.row - 1;
const baseButton = m.button & 0x03;
const col = m.col - 1
const row = m.row - 1
const baseButton = m.button & 0x03
if (m.action === 'press') {
if ((m.button & 0x20) !== 0 && baseButton === 3) {
// Mode-1003 motion with no button held. Dispatch hover; skip the
@@ -533,25 +645,25 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
// past the edge, came back" — and tmux drops focus events unless
// `focus-events on` is set, so this is the more reliable signal.
if (sel.isDragging) {
finishSelection(sel);
app.props.onSelectionChange();
finishSelection(sel)
app.props.onSelectionChange()
}
if (col === app.lastHoverCol && row === app.lastHoverRow) return;
app.lastHoverCol = col;
app.lastHoverRow = row;
app.props.onHoverAt(col, row);
return;
if (col === app.lastHoverCol && row === app.lastHoverRow) return
app.lastHoverCol = col
app.lastHoverRow = row
app.props.onHoverAt(col, row)
return
}
if (baseButton !== 0) {
// Non-left press breaks the multi-click chain.
app.clickCount = 0;
return;
app.clickCount = 0
return
}
if ((m.button & 0x20) !== 0) {
// Drag motion: mode-aware extension (char/word/line). onSelectionDrag
// calls notifySelectionChange internally — no extra onSelectionChange.
app.props.onSelectionDrag(col, row);
return;
app.props.onSelectionDrag(col, row)
return
}
// Lost-release fallback for mode-1002-only terminals: a fresh press
// while isDragging=true means the previous release was dropped (cursor
@@ -559,40 +671,43 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
// before startSelection/onMultiClick clobbers it. Mode-1003 terminals
// hit the no-button-motion recovery above instead, so this is rare.
if (sel.isDragging) {
finishSelection(sel);
app.props.onSelectionChange();
finishSelection(sel)
app.props.onSelectionChange()
}
// Fresh left press. Detect multi-click HERE (not on release) so the
// word/line highlight appears immediately and a subsequent drag can
// extend by word/line like native macOS. Previously detected on
// release, which meant (a) visible latency before the word highlights
// and (b) double-click+drag fell through to char-mode selection.
const now = Date.now();
const nearLast = now - app.lastClickTime < MULTI_CLICK_TIMEOUT_MS && Math.abs(col - app.lastClickCol) <= MULTI_CLICK_DISTANCE && Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE;
app.clickCount = nearLast ? app.clickCount + 1 : 1;
app.lastClickTime = now;
app.lastClickCol = col;
app.lastClickRow = row;
const now = Date.now()
const nearLast =
now - app.lastClickTime < MULTI_CLICK_TIMEOUT_MS &&
Math.abs(col - app.lastClickCol) <= MULTI_CLICK_DISTANCE &&
Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE
app.clickCount = nearLast ? app.clickCount + 1 : 1
app.lastClickTime = now
app.lastClickCol = col
app.lastClickRow = row
if (app.clickCount >= 2) {
// Cancel any pending hyperlink-open from the first click — this is
// a double-click, not a single-click on a link.
if (app.pendingHyperlinkTimer) {
clearTimeout(app.pendingHyperlinkTimer);
app.pendingHyperlinkTimer = null;
clearTimeout(app.pendingHyperlinkTimer)
app.pendingHyperlinkTimer = null
}
// Cap at 3 (line select) for quadruple+ clicks.
const count = app.clickCount === 2 ? 2 : 3;
app.props.onMultiClick(col, row, count);
return;
const count = app.clickCount === 2 ? 2 : 3
app.props.onMultiClick(col, row, count)
return
}
startSelection(sel, col, row);
startSelection(sel, col, row)
// SGR bit 0x08 = alt (xterm.js wires altKey here, not metaKey — see
// comment at the hyperlink-open guard below). On macOS xterm.js,
// receiving alt means macOptionClickForcesSelection is OFF (otherwise
// xterm.js would have consumed the event for native selection).
sel.lastPressHadAlt = (m.button & 0x08) !== 0;
app.props.onSelectionChange();
return;
sel.lastPressHadAlt = (m.button & 0x08) !== 0
app.props.onSelectionChange()
return
}
// Release: end the drag even for non-zero button codes. Some terminals
@@ -602,12 +717,12 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
// scroll boundary. Only act on non-left releases when we ARE dragging
// (so an unrelated middle/right click-release doesn't touch selection).
if (baseButton !== 0) {
if (!sel.isDragging) return;
finishSelection(sel);
app.props.onSelectionChange();
return;
if (!sel.isDragging) return
finishSelection(sel)
app.props.onSelectionChange()
return
}
finishSelection(sel);
finishSelection(sel)
// NOTE: unlike the old release-based detection we do NOT reset clickCount
// on release-after-drag. This aligns with NSEvent.clickCount semantics:
// an intervening drag doesn't break the click chain. Practical upside:
@@ -628,7 +743,7 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
// Resolve the hyperlink URL synchronously while the screen buffer
// still reflects what the user clicked — deferring only the
// browser-open so double-click can cancel it.
const url = app.props.getHyperlinkAt(col, row);
const url = app.props.getHyperlinkAt(col, row)
// xterm.js (VS Code, Cursor, Windsurf, etc.) has its own OSC 8 link
// handler that fires on Cmd+click *without consuming the mouse event*
// (Linkifier._handleMouseUp calls link.activate() but never
@@ -644,14 +759,19 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
// Clear any prior pending timer — clicking a second link
// supersedes the first (only the latest click opens).
if (app.pendingHyperlinkTimer) {
clearTimeout(app.pendingHyperlinkTimer);
clearTimeout(app.pendingHyperlinkTimer)
}
app.pendingHyperlinkTimer = setTimeout((app, url) => {
app.pendingHyperlinkTimer = null;
app.props.onOpenHyperlink(url);
}, MULTI_CLICK_TIMEOUT_MS, app, url);
app.pendingHyperlinkTimer = setTimeout(
(app, url) => {
app.pendingHyperlinkTimer = null
app.props.onOpenHyperlink(url)
},
MULTI_CLICK_TIMEOUT_MS,
app,
url,
)
}
}
}
app.props.onSelectionChange();
app.props.onSelectionChange()
}

View File

@@ -1,212 +1,118 @@
import { c as _c } from "react/compiler-runtime";
import React, { type PropsWithChildren, type Ref } from 'react';
import type { Except } from 'type-fest';
import type { DOMElement } from '../dom.js';
import type { ClickEvent } from '../events/click-event.js';
import type { FocusEvent } from '../events/focus-event.js';
import type { KeyboardEvent } from '../events/keyboard-event.js';
import type { Styles } from '../styles.js';
import * as warn from '../warn.js';
import React, { type PropsWithChildren, type Ref } from 'react'
import type { Except } from 'type-fest'
import type { DOMElement } from '../dom.js'
import type { ClickEvent } from '../events/click-event.js'
import type { FocusEvent } from '../events/focus-event.js'
import type { KeyboardEvent } from '../events/keyboard-event.js'
import type { Styles } from '../styles.js'
import * as warn from '../warn.js'
export type Props = Except<Styles, 'textWrap'> & {
ref?: Ref<DOMElement>;
ref?: Ref<DOMElement>
/**
* Tab order index. Nodes with `tabIndex >= 0` participate in
* Tab/Shift+Tab cycling; `-1` means programmatically focusable only.
*/
tabIndex?: number;
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;
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;
onFocus?: (event: FocusEvent) => void;
onFocusCapture?: (event: FocusEvent) => void;
onBlur?: (event: FocusEvent) => void;
onBlurCapture?: (event: FocusEvent) => void;
onKeyDown?: (event: KeyboardEvent) => void;
onKeyDownCapture?: (event: KeyboardEvent) => void;
onClick?: (event: ClickEvent) => 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;
onMouseEnter?: () => void
/** Fired when the mouse moves out of this Box's rendered rect. */
onMouseLeave?: () => void;
};
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) {
const $ = _c(42);
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 onMouseEnter;
let onMouseLeave;
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,
onMouseEnter: t14,
onMouseLeave: t15,
onKeyDown: t16,
onKeyDownCapture: t17,
...t18
} = t0;
children = t1;
ref = t6;
tabIndex = t7;
autoFocus = t8;
onClick = t9;
onFocus = t10;
onFocusCapture = t11;
onBlur = t12;
onBlurCapture = t13;
onMouseEnter = t14;
onMouseLeave = t15;
onKeyDown = t16;
onKeyDownCapture = t17;
style = t18;
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] = onMouseEnter;
$[15] = onMouseLeave;
$[16] = ref;
$[17] = style;
$[18] = 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];
onMouseEnter = $[14];
onMouseLeave = $[15];
ref = $[16];
style = $[17];
tabIndex = $[18];
}
const t1 = style.overflowX ?? style.overflow ?? "visible";
const t2 = style.overflowY ?? style.overflow ?? "visible";
let t3;
if ($[19] !== flexDirection || $[20] !== flexGrow || $[21] !== flexShrink || $[22] !== flexWrap || $[23] !== style || $[24] !== t1 || $[25] !== t2) {
t3 = {
flexWrap,
flexDirection,
flexGrow,
flexShrink,
...style,
overflowX: t1,
overflowY: t2
};
$[19] = flexDirection;
$[20] = flexGrow;
$[21] = flexShrink;
$[22] = flexWrap;
$[23] = style;
$[24] = t1;
$[25] = t2;
$[26] = t3;
} else {
t3 = $[26];
}
let t4;
if ($[27] !== autoFocus || $[28] !== children || $[29] !== onBlur || $[30] !== onBlurCapture || $[31] !== onClick || $[32] !== onFocus || $[33] !== onFocusCapture || $[34] !== onKeyDown || $[35] !== onKeyDownCapture || $[36] !== onMouseEnter || $[37] !== onMouseLeave || $[38] !== ref || $[39] !== t3 || $[40] !== tabIndex) {
t4 = <ink-box ref={ref} tabIndex={tabIndex} autoFocus={autoFocus} onClick={onClick} onFocus={onFocus} onFocusCapture={onFocusCapture} onBlur={onBlur} onBlurCapture={onBlurCapture} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onKeyDown={onKeyDown} onKeyDownCapture={onKeyDownCapture} style={t3}>{children}</ink-box>;
$[27] = autoFocus;
$[28] = children;
$[29] = onBlur;
$[30] = onBlurCapture;
$[31] = onClick;
$[32] = onFocus;
$[33] = onFocusCapture;
$[34] = onKeyDown;
$[35] = onKeyDownCapture;
$[36] = onMouseEnter;
$[37] = onMouseLeave;
$[38] = ref;
$[39] = t3;
$[40] = tabIndex;
$[41] = t4;
} else {
t4 = $[41];
}
return t4;
function Box({
children,
flexWrap = 'nowrap',
flexDirection = 'row',
flexGrow = 0,
flexShrink = 1,
ref,
tabIndex,
autoFocus,
onClick,
onFocus,
onFocusCapture,
onBlur,
onBlurCapture,
onMouseEnter,
onMouseLeave,
onKeyDown,
onKeyDownCapture,
...style
}: PropsWithChildren<Props>): React.ReactNode {
// Warn if spacing values are not integers to prevent fractional layout dimensions
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')
return (
<ink-box
ref={ref}
tabIndex={tabIndex}
autoFocus={autoFocus}
onClick={onClick}
onFocus={onFocus}
onFocusCapture={onFocusCapture}
onBlur={onBlur}
onBlurCapture={onBlurCapture}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onKeyDown={onKeyDown}
onKeyDownCapture={onKeyDownCapture}
style={{
flexWrap,
flexDirection,
flexGrow,
flexShrink,
...style,
overflowX: style.overflowX ?? style.overflow ?? 'visible',
overflowY: style.overflowY ?? style.overflow ?? 'visible',
}}
>
{children}
</ink-box>
)
}
export default Box;
export default Box

View File

@@ -1,32 +1,39 @@
import { c as _c } from "react/compiler-runtime";
import React, { type Ref, useCallback, useEffect, useRef, useState } from 'react';
import type { Except } from 'type-fest';
import type { DOMElement } from '../dom.js';
import type { ClickEvent } from '../events/click-event.js';
import type { FocusEvent } from '../events/focus-event.js';
import type { KeyboardEvent } from '../events/keyboard-event.js';
import type { Styles } from '../styles.js';
import Box from './Box.js';
import React, {
type Ref,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import type { Except } from 'type-fest'
import type { DOMElement } from '../dom.js'
import type { ClickEvent } from '../events/click-event.js'
import type { FocusEvent } from '../events/focus-event.js'
import type { KeyboardEvent } from '../events/keyboard-event.js'
import type { Styles } from '../styles.js'
import Box from './Box.js'
type ButtonState = {
focused: boolean;
hovered: boolean;
active: boolean;
};
focused: boolean
hovered: boolean
active: boolean
}
export type Props = Except<Styles, 'textWrap'> & {
ref?: Ref<DOMElement>;
ref?: Ref<DOMElement>
/**
* Called when the button is activated via Enter, Space, or click.
*/
onAction: () => void;
onAction: () => void
/**
* Tab order index. Defaults to 0 (in tab order).
* Set to -1 for programmatically focusable only.
*/
tabIndex?: number;
tabIndex?: number
/**
* Focus this button when it mounts.
*/
autoFocus?: boolean;
autoFocus?: boolean
/**
* Render prop receiving the interactive state. Use this to
* style children based on focus/hover/active — Button itself
@@ -34,158 +41,82 @@ export type Props = Except<Styles, 'textWrap'> & {
*
* 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 ref={ref} tabIndex={tabIndex} autoFocus={autoFocus} onKeyDown={handleKeyDown} onClick={handleClick} onFocus={handleFocus} onBlur={handleBlur} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...style}>{content}</Box>;
$[22] = autoFocus;
$[23] = content;
$[24] = handleClick;
$[25] = handleKeyDown;
$[26] = ref;
$[27] = style;
$[28] = tabIndex;
$[29] = t11;
} else {
t11 = $[29];
}
return t11;
children: ((state: ButtonState) => React.ReactNode) | React.ReactNode
}
function _temp(setter) {
return setter(false);
function Button({
onAction,
tabIndex = 0,
autoFocus,
children,
ref,
...style
}: Props): React.ReactNode {
const [isFocused, setIsFocused] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [isActive, setIsActive] = useState(false)
const activeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
return () => {
if (activeTimer.current) clearTimeout(activeTimer.current)
}
}, [])
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'return' || e.key === ' ') {
e.preventDefault()
setIsActive(true)
onAction()
if (activeTimer.current) clearTimeout(activeTimer.current)
activeTimer.current = setTimeout(
setter => setter(false),
100,
setIsActive,
)
}
},
[onAction],
)
const handleClick = useCallback(
(_e: ClickEvent) => {
onAction()
},
[onAction],
)
const handleFocus = useCallback((_e: FocusEvent) => setIsFocused(true), [])
const handleBlur = useCallback((_e: FocusEvent) => setIsFocused(false), [])
const handleMouseEnter = useCallback(() => setIsHovered(true), [])
const handleMouseLeave = useCallback(() => setIsHovered(false), [])
const state: ButtonState = {
focused: isFocused,
hovered: isHovered,
active: isActive,
}
const content = typeof children === 'function' ? children(state) : children
return (
<Box
ref={ref}
tabIndex={tabIndex}
autoFocus={autoFocus}
onKeyDown={handleKeyDown}
onClick={handleClick}
onFocus={handleFocus}
onBlur={handleBlur}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...style}
>
{content}
</Box>
)
}
export default Button;
export type { ButtonState };
export default Button
export type { ButtonState }

View File

@@ -1,111 +1,99 @@
import { c as _c } from "react/compiler-runtime";
import React, { createContext, useEffect, useState } from 'react';
import { FRAME_INTERVAL_MS } from '../constants.js';
import { useTerminalFocus } from '../hooks/use-terminal-focus.js';
import React, { createContext, useEffect, useState } from 'react'
import { 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;
};
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;
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;
let tickTime = 0
function tick(): void {
tickTime = Date.now() - startTime;
tickTime = Date.now() - startTime
for (const onChange of subscribers.keys()) {
onChange();
onChange()
}
}
function updateInterval(): void {
const anyKeepAlive = [...subscribers.values()].some(Boolean);
const anyKeepAlive = [...subscribers.values()].some(Boolean)
if (anyKeepAlive) {
if (interval) {
clearInterval(interval);
interval = null;
clearInterval(interval)
interval = null
}
if (startTime === 0) {
startTime = Date.now();
startTime = Date.now()
}
interval = setInterval(tick, currentTickIntervalMs);
interval = setInterval(tick, currentTickIntervalMs)
} else if (interval) {
clearInterval(interval);
interval = null;
clearInterval(interval)
interval = null
}
}
return {
subscribe(onChange, keepAlive) {
subscribers.set(onChange, keepAlive);
updateInterval();
subscribers.set(onChange, keepAlive)
updateInterval()
return () => {
subscribers.delete(onChange);
updateInterval();
};
subscribers.delete(onChange)
updateInterval()
}
},
now() {
if (startTime === 0) {
startTime = Date.now();
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 tickTime
}
return Date.now() - startTime;
return Date.now() - startTime
},
setTickInterval(ms) {
if (ms === currentTickIntervalMs) return;
currentTickIntervalMs = ms;
updateInterval();
}
};
if (ms === currentTickIntervalMs) return
currentTickIntervalMs = ms
updateInterval()
},
}
}
export const ClockContext = createContext<Clock | null>(null);
const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2;
export const ClockContext = createContext<Clock | null>(null)
const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2
// 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) {
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_TICK_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);
export function ClockProvider({
children,
}: {
children: React.ReactNode
}): React.ReactNode {
const [clock] = useState(() => createClock(FRAME_INTERVAL_MS))
const focused = useTerminalFocus()
useEffect(() => {
clock.setTickInterval(
focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS,
)
}, [clock, focused])
return <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>
}

View File

@@ -1,55 +1,57 @@
import codeExcerpt, { type CodeExcerpt } from 'code-excerpt';
import { readFileSync } from 'fs';
import React from 'react';
import StackUtils from 'stack-utils';
import Box from './Box.js';
import Text from './Text.js';
import codeExcerpt, { type CodeExcerpt } from 'code-excerpt'
import { readFileSync } from 'fs'
import React from 'react'
import StackUtils from 'stack-utils'
import Box from './Box.js'
import Text from './Text.js'
/* eslint-disable custom-rules/no-process-cwd -- stack trace file:// paths are relative to the real OS cwd, not the virtual cwd */
// 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;
return path?.replace(`file://${process.cwd()}/`, '')
}
let stackUtils: StackUtils | undefined
function getStackUtils(): StackUtils {
return stackUtils ??= new StackUtils({
return (stackUtils ??= new StackUtils({
cwd: process.cwd(),
internals: StackUtils.nodeInternals()
});
internals: StackUtils.nodeInternals(),
}))
}
/* eslint-enable custom-rules/no-process-cwd */
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;
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 {
// eslint-disable-next-line custom-rules/no-sync-fs -- sync render path; error overlay can't go async without suspense restructuring
const sourceCode = readFileSync(filePath, 'utf8');
excerpt = codeExcerpt(sourceCode, origin.line);
const sourceCode = readFileSync(filePath, 'utf8')
excerpt = codeExcerpt(sourceCode, origin.line)
if (excerpt) {
for (const {
line
} of excerpt) {
lineWidth = Math.max(lineWidth, String(line).length);
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}>
return (
<Box flexDirection="column" padding={1}>
<Box>
<Text backgroundColor="ansi:red" color="ansi:white">
{' '}
@@ -59,41 +61,62 @@ export default function ErrorOverview({
<Text> {error.message}</Text>
</Box>
{origin && filePath && <Box marginTop={1}>
{origin && filePath && (
<Box marginTop={1}>
<Text dim>
{filePath}:{origin.line}:{origin.column}
</Text>
</Box>}
</Box>
)}
{origin && excerpt && <Box marginTop={1} flexDirection="column">
{excerpt.map(({
line: line_0,
value
}) => <Box key={line_0}>
{origin && excerpt && (
<Box marginTop={1} flexDirection="column">
{excerpt.map(({ line, value }) => (
<Box key={line}>
<Box width={lineWidth + 1}>
<Text dim={line_0 !== origin.line} backgroundColor={line_0 === origin.line ? 'ansi:red' : undefined} color={line_0 === origin.line ? 'ansi:white' : undefined}>
{String(line_0).padStart(lineWidth, ' ')}:
<Text
dim={line !== origin.line}
backgroundColor={
line === origin.line ? 'ansi:red' : undefined
}
color={line === origin.line ? 'ansi:white' : undefined}
>
{String(line).padStart(lineWidth, ' ')}:
</Text>
</Box>
<Text key={line_0} backgroundColor={line_0 === origin.line ? 'ansi:red' : undefined} color={line_0 === origin.line ? 'ansi:white' : undefined}>
<Text
key={line}
backgroundColor={line === origin.line ? 'ansi:red' : undefined}
color={line === origin.line ? 'ansi:white' : undefined}
>
{' ' + value}
</Text>
</Box>)}
</Box>}
</Box>
))}
</Box>
)}
{error.stack && <Box marginTop={1} flexDirection="column">
{error.stack.split('\n').slice(1).map(line_1 => {
const parsedLine = getStackUtils().parseLine(line_1);
{error.stack && (
<Box marginTop={1} flexDirection="column">
{error.stack
.split('\n')
.slice(1)
.map(line => {
const parsedLine = getStackUtils().parseLine(line)
// If the line from the stack cannot be parsed, we print out the unparsed line.
if (!parsedLine) {
return <Box key={line_1}>
// If the line from the stack cannot be parsed, we print out the unparsed line.
if (!parsedLine) {
return (
<Box key={line}>
<Text dim>- </Text>
<Text bold>{line_1}</Text>
</Box>;
}
return <Box key={line_1}>
<Text bold>{line}</Text>
</Box>
)
}
return (
<Box key={line}>
<Text dim>- </Text>
<Text bold>{parsedLine.function}</Text>
<Text dim>
@@ -101,8 +124,11 @@ export default function ErrorOverview({
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:
{parsedLine.column})
</Text>
</Box>;
})}
</Box>}
</Box>;
</Box>
)
})}
</Box>
)}
</Box>
)
}

View File

@@ -1,41 +1,31 @@
import { c as _c } from "react/compiler-runtime";
import type { ReactNode } from 'react';
import React from 'react';
import { supportsHyperlinks } from '../supports-hyperlinks.js';
import Text from './Text.js';
import type { ReactNode } from 'react'
import React from 'react'
import { supportsHyperlinks } from '../supports-hyperlinks.js'
import Text from './Text.js'
export type Props = {
readonly children?: ReactNode;
readonly url: string;
readonly fallback?: ReactNode;
};
export default function Link(t0) {
const $ = _c(5);
const {
children,
url,
fallback
} = t0;
const content = children ?? url;
if (supportsHyperlinks()) {
let t1;
if ($[0] !== content || $[1] !== url) {
t1 = <Text><ink-link href={url}>{content}</ink-link></Text>;
$[0] = content;
$[1] = url;
$[2] = t1;
} else {
t1 = $[2];
}
return t1;
}
const t1 = fallback ?? content;
let t2;
if ($[3] !== t1) {
t2 = <Text>{t1}</Text>;
$[3] = t1;
$[4] = t2;
} else {
t2 = $[4];
}
return t2;
readonly children?: ReactNode
readonly url: string
readonly fallback?: ReactNode
}
export default function Link({
children,
url,
fallback,
}: Props): React.ReactNode {
// Use children if provided, otherwise display the URL
const content = children ?? url
if (supportsHyperlinks()) {
// Wrap in Text to ensure we're in a text context
// (ink-link is a text element like ink-text)
return (
<Text>
<ink-link href={url}>{content}</ink-link>
</Text>
)
}
return <Text>{fallback ?? content}</Text>
}

View File

@@ -1,38 +1,17 @@
import { c as _c } from "react/compiler-runtime";
import React from 'react';
import React from 'react'
export type Props = {
/**
* Number of newlines to insert.
*
* @default 1
*/
readonly count?: number;
};
readonly count?: number
}
/**
* Adds one or more newline (\n) characters. Must be used within <Text> components.
*/
export default function Newline(t0) {
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;
export default function Newline({ count = 1 }: Props) {
return <ink-text>{'\n'.repeat(count)}</ink-text>
}

View File

@@ -1,6 +1,6 @@
import { c as _c } from "react/compiler-runtime";
import React, { type PropsWithChildren } from 'react';
import Box, { type Props as BoxProps } from './Box.js';
import React, { type PropsWithChildren } from 'react'
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,
@@ -11,8 +11,8 @@ type Props = Omit<BoxProps, 'noSelect'> & {
*
* @default false
*/
fromLeftEdge?: boolean;
};
fromLeftEdge?: boolean
}
/**
* Marks its contents as non-selectable in fullscreen text selection.
@@ -32,36 +32,14 @@ type Props = Omit<BoxProps, 'noSelect'> & {
* tracking). No-op in the main-screen scrollback render where the
* terminal's native selection is used instead.
*/
export function NoSelect(t0) {
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;
export function NoSelect({
children,
fromLeftEdge,
...boxProps
}: PropsWithChildren<Props>): React.ReactNode {
return (
<Box {...boxProps} noSelect={fromLeftEdge ? 'from-left-edge' : true}>
{children}
</Box>
)
}

View File

@@ -1,14 +1,14 @@
import { c as _c } from "react/compiler-runtime";
import React from 'react';
import React from 'react'
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[];
lines: string[]
/** Column width the producer wrapped to. Sent to Yoga as the fixed leaf width. */
width: number;
};
width: number
}
/**
* Bypass the <Ansi> → React tree → Yoga → squash → re-serialize roundtrip for
@@ -25,32 +25,15 @@ type Props = {
* (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) {
const $ = _c(6);
const {
lines,
width
} = t0;
export function RawAnsi({ lines, width }: Props): React.ReactNode {
if (lines.length === 0) {
return null;
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 rawText={t1} rawWidth={width} rawHeight={lines.length} />;
$[2] = lines.length;
$[3] = t1;
$[4] = width;
$[5] = t2;
} else {
t2 = $[5];
}
return t2;
return (
<ink-raw-ansi
rawText={lines.join('\n')}
rawWidth={width}
rawHeight={lines.length}
/>
)
}

View File

@@ -1,14 +1,21 @@
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';
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'
export type ScrollBoxHandle = {
scrollTo: (y: number) => void;
scrollBy: (dy: number) => void;
scrollTo: (y: number) => void
scrollBy: (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
@@ -16,24 +23,24 @@ export type ScrollBoxHandle = {
* 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;
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;
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;
getViewportTop: () => number
/**
* True when scroll is pinned to the bottom. Set by scrollToBottom, the
* initial stickyScroll attribute, and by the renderer when positional
@@ -41,14 +48,14 @@ export type ScrollBoxHandle = {
* scrollTo/scrollBy. Stable signal for "at bottom" that doesn't depend on
* layout values (unlike scrollTop+viewportH >= scrollHeight).
*/
isSticky: () => boolean;
isSticky: () => boolean
/**
* Subscribe to imperative scroll changes (scrollTo/scrollBy/scrollToBottom).
* Does NOT fire for stickyScroll updates done by the Ink renderer — those
* happen during Ink's render phase after React has committed. Callers that
* care about the sticky case should treat "at bottom" as a fallback.
*/
subscribe: (listener: () => void) => () => void;
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;
@@ -57,16 +64,20 @@ export type ScrollBoxHandle = {
* 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>;
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;
};
stickyScroll?: boolean
}
/**
* A Box with `overflow: scroll` and an imperative scroll API.
@@ -84,7 +95,7 @@ function ScrollBox({
stickyScroll,
...style
}: PropsWithChildren<ScrollBoxProps>): React.ReactNode {
const domRef = useRef<DOMElement>(null);
const domRef = useRef<DOMElement>(null)
// scrollTo/scrollBy 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,
@@ -93,114 +104,121 @@ function ScrollBox({
// 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 renderQueuedRef = useRef(false);
const [, forceRender] = useState(0)
const listenersRef = useRef(new Set<() => void>())
const renderQueuedRef = useRef(false)
const notify = () => {
for (const l of listenersRef.current) l();
};
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;
markScrollActivity()
markDirty(el)
markCommitStart()
notify()
if (renderQueuedRef.current) return
renderQueuedRef.current = true
queueMicrotask(() => {
renderQueuedRef.current = false;
scheduleRenderFrom(el);
});
renderQueuedRef.current = false
scheduleRenderFrom(el)
})
}
useImperativeHandle(ref, (): ScrollBoxHandle => ({
scrollTo(y: number) {
const el = domRef.current;
if (!el) return;
// Explicit false overrides the DOM attribute so manual scroll
// breaks stickiness. Render code checks ?? precedence.
el.stickyScroll = false;
el.pendingScrollDelta = 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) return;
box.stickyScroll = false;
box.pendingScrollDelta = undefined;
box.scrollAnchor = {
el,
offset
};
scrollMutated(box);
},
scrollBy(dy: number) {
const el = domRef.current;
if (!el) return;
el.stickyScroll = false;
// Wheel input cancels any in-flight anchor seek — user override.
el.scrollAnchor = undefined;
// Accumulate in pendingScrollDelta; renderer drains it at a capped
// rate so fast flicks show intermediate frames. Pure accumulator:
// scroll-up followed by scroll-down naturally cancels.
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy);
scrollMutated(el);
},
scrollToBottom() {
const el = domRef.current;
if (!el) return;
el.pendingScrollDelta = undefined;
el.stickyScroll = true;
markDirty(el);
notify();
forceRender(n => n + 1);
},
getScrollTop() {
return domRef.current?.scrollTop ?? 0;
},
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 domRef.current?.pendingScrollDelta ?? 0;
},
getScrollHeight() {
return domRef.current?.scrollHeight ?? 0;
},
getFreshScrollHeight() {
const content = domRef.current?.childNodes[0] as DOMElement | undefined;
return content?.yogaNode?.getComputedHeight() ?? domRef.current?.scrollHeight ?? 0;
},
getViewportHeight() {
return domRef.current?.scrollViewportHeight ?? 0;
},
getViewportTop() {
return domRef.current?.scrollViewportTop ?? 0;
},
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;
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).
// eslint-disable-next-line react-hooks/exhaustive-deps
[]);
useImperativeHandle(
ref,
(): ScrollBoxHandle => ({
scrollTo(y: number) {
const el = domRef.current
if (!el) return
// Explicit false overrides the DOM attribute so manual scroll
// breaks stickiness. Render code checks ?? precedence.
el.stickyScroll = false
el.pendingScrollDelta = 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) return
box.stickyScroll = false
box.pendingScrollDelta = undefined
box.scrollAnchor = { el, offset }
scrollMutated(box)
},
scrollBy(dy: number) {
const el = domRef.current
if (!el) return
el.stickyScroll = false
// Wheel input cancels any in-flight anchor seek — user override.
el.scrollAnchor = undefined
// Accumulate in pendingScrollDelta; renderer drains it at a capped
// rate so fast flicks show intermediate frames. Pure accumulator:
// scroll-up followed by scroll-down naturally cancels.
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy)
scrollMutated(el)
},
scrollToBottom() {
const el = domRef.current
if (!el) return
el.pendingScrollDelta = undefined
el.stickyScroll = true
markDirty(el)
notify()
forceRender(n => n + 1)
},
getScrollTop() {
return domRef.current?.scrollTop ?? 0
},
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 domRef.current?.pendingScrollDelta ?? 0
},
getScrollHeight() {
return domRef.current?.scrollHeight ?? 0
},
getFreshScrollHeight() {
const content = domRef.current?.childNodes[0] as DOMElement | undefined
return (
content?.yogaNode?.getComputedHeight() ??
domRef.current?.scrollHeight ??
0
)
},
getViewportHeight() {
return domRef.current?.scrollViewportHeight ?? 0
},
getViewportTop() {
return domRef.current?.scrollViewportTop ?? 0
},
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
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).
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
)
// Structure: outer viewport (overflow:scroll, constrained height) >
// inner content (flexGrow:1, flexShrink:0 — fills at least the viewport
@@ -213,23 +231,28 @@ function ScrollBox({
// 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 => {
domRef.current = el;
if (el) el.scrollTop ??= 0;
}} style={{
flexWrap: 'nowrap',
flexDirection: style.flexDirection ?? 'row',
flexGrow: style.flexGrow ?? 0,
flexShrink: style.flexShrink ?? 1,
...style,
overflowX: 'scroll',
overflowY: 'scroll'
}} {...stickyScroll ? {
stickyScroll: true
} : {}}>
return (
<ink-box
ref={el => {
domRef.current = el
if (el) el.scrollTop ??= 0
}}
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>;
</ink-box>
)
}
export default ScrollBox;
export default ScrollBox

View File

@@ -1,19 +1,10 @@
import { c as _c } from "react/compiler-runtime";
import React from 'react';
import Box from './Box.js';
import React from 'react'
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;
return <Box flexGrow={1} />
}

View File

@@ -1,51 +1,53 @@
import { c as _c } from "react/compiler-runtime";
import React, { createContext, useMemo, useSyncExternalStore } from 'react';
import { getTerminalFocused, getTerminalFocusState, subscribeTerminalFocus, type TerminalFocusState } from '../terminal-focus-state.js';
export type { TerminalFocusState };
import React, { createContext, useMemo, useSyncExternalStore } from 'react'
import {
getTerminalFocused,
getTerminalFocusState,
subscribeTerminalFocus,
type TerminalFocusState,
} from '../terminal-focus-state.js'
export type { TerminalFocusState }
export type TerminalFocusContextProps = {
readonly isTerminalFocused: boolean;
readonly terminalFocusState: TerminalFocusState;
};
readonly isTerminalFocused: boolean
readonly terminalFocusState: TerminalFocusState
}
const TerminalFocusContext = createContext<TerminalFocusContextProps>({
isTerminalFocused: true,
terminalFocusState: 'unknown'
});
terminalFocusState: 'unknown',
})
// eslint-disable-next-line custom-rules/no-top-level-side-effects
TerminalFocusContext.displayName = 'TerminalFocusContext';
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) {
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 function TerminalFocusProvider({
children,
}: {
children: React.ReactNode
}): React.ReactNode {
const isTerminalFocused = useSyncExternalStore(
subscribeTerminalFocus,
getTerminalFocused,
)
const terminalFocusState = useSyncExternalStore(
subscribeTerminalFocus,
getTerminalFocusState,
)
const value = useMemo(
() => ({ isTerminalFocused, terminalFocusState }),
[isTerminalFocused, terminalFocusState],
)
return (
<TerminalFocusContext.Provider value={value}>
{children}
</TerminalFocusContext.Provider>
)
}
export default TerminalFocusContext;
export default TerminalFocusContext

View File

@@ -1,6 +1,8 @@
import { createContext } from 'react';
import { createContext } from 'react'
export type TerminalSize = {
columns: number;
rows: number;
};
export const TerminalSizeContext = createContext<TerminalSize | null>(null);
columns: number
rows: number
}
export const TerminalSizeContext = createContext<TerminalSize | null>(null)

View File

@@ -1,253 +1,144 @@
import { c as _c } from "react/compiler-runtime";
import type { ReactNode } from 'react';
import React from 'react';
import type { Color, Styles, TextStyles } from '../styles.js';
import type { ReactNode } from 'react'
import React from 'react'
import type { Color, Styles, TextStyles } from '../styles.js'
type BaseProps = {
/**
* Change text color. Accepts a raw color value (rgb, hex, ansi).
*/
readonly color?: Color;
readonly color?: Color
/**
* Same as `color`, but for background.
*/
readonly backgroundColor?: Color;
readonly backgroundColor?: Color
/**
* Make the text italic.
*/
readonly italic?: boolean;
readonly italic?: boolean
/**
* Make the text underlined.
*/
readonly underline?: boolean;
readonly underline?: boolean
/**
* Make the text crossed with a line.
*/
readonly strikethrough?: boolean;
readonly strikethrough?: boolean
/**
* Inverse background and foreground colors.
*/
readonly inverse?: boolean;
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;
};
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;
type WeightProps =
| { bold?: never; dim?: never }
| { bold: boolean; dim?: never }
| { dim: boolean; bold?: never }
export type Props = BaseProps & WeightProps
const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
wrap: {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'wrap'
textWrap: 'wrap',
},
'wrap-trim': {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'wrap-trim'
textWrap: 'wrap-trim',
},
end: {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'end'
textWrap: 'end',
},
middle: {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'middle'
textWrap: 'middle',
},
'truncate-end': {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'truncate-end'
textWrap: 'truncate-end',
},
truncate: {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'truncate'
textWrap: 'truncate',
},
'truncate-middle': {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'truncate-middle'
textWrap: 'truncate-middle',
},
'truncate-start': {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'truncate-start'
}
} as const;
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) {
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;
export default function Text({
color,
backgroundColor,
bold,
dim,
italic = false,
underline = false,
strikethrough = false,
inverse = false,
wrap = 'wrap',
children,
}: Props): React.ReactNode {
if (children === undefined || children === null) {
return null;
return null
}
let t6;
if ($[0] !== color) {
t6 = color && {
color
};
$[0] = color;
$[1] = t6;
} else {
t6 = $[1];
// Build textStyles object with only the properties that are set
const textStyles: TextStyles = {
...(color && { color }),
...(backgroundColor && { backgroundColor }),
...(dim && { dim }),
...(bold && { bold }),
...(italic && { italic }),
...(underline && { underline }),
...(strikethrough && { strikethrough }),
...(inverse && { inverse }),
}
let t7;
if ($[2] !== backgroundColor) {
t7 = backgroundColor && {
backgroundColor
};
$[2] = backgroundColor;
$[3] = t7;
} else {
t7 = $[3];
}
let t8;
if ($[4] !== dim) {
t8 = dim && {
dim
};
$[4] = dim;
$[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;
return (
<ink-text style={memoizedStylesForWrap[wrap]} textStyles={textStyles}>
{children}
</ink-text>
)
}

File diff suppressed because it is too large Load Diff