mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
更新大量 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:
412
src/ink/Ansi.tsx
412
src/ink/Ansi.tsx
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
1662
src/ink/ink.tsx
1662
src/ink/ink.tsx
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user