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

This commit is contained in:
claude-code-best
2026-04-06 23:56:45 +08:00
committed by GitHub
parent 3ea64eeb0f
commit c445f43f8d
645 changed files with 7255 additions and 1214 deletions

View File

@@ -0,0 +1,87 @@
import React, {
type PropsWithChildren,
useContext,
useInsertionEffect,
} from 'react'
import instances from '../core/instances.js'
import {
DISABLE_MOUSE_TRACKING,
ENABLE_MOUSE_TRACKING,
ENTER_ALT_SCREEN,
EXIT_ALT_SCREEN,
} from '../core/termio/dec.js'
import { TerminalWriteContext } from '../hooks/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
}>
/**
* Run children in the terminal's alternate screen buffer, constrained to
* the viewport height. While mounted:
*
* - Enters the alt screen (DEC 1049), clears it, homes the cursor
* - Constrains its own height to the terminal row count, so overflow must
* be handled via `overflow: scroll` / flexbox (no native scrollback)
* - Optionally enables SGR mouse tracking (wheel + click/drag) — events
* surface as `ParsedKey` (wheel) and update the Ink instance's
* selection state (click/drag)
*
* On unmount, disables mouse tracking and exits the alt screen, restoring
* the main screen's content. Safe for use in ctrl-o transcript overlays
* and similar temporary fullscreen views — the main screen is preserved.
*
* Notifies the Ink instance via `setAltScreenActive()` so the renderer
* keeps the cursor inside the viewport (preventing the cursor-restore LF
* from scrolling content) and so signal-exit cleanup can exit the alt
* screen if the component's own unmount doesn't run.
*/
export function AlternateScreen({
children,
mouseTracking = true,
}: Props): React.ReactNode {
const size = useContext(TerminalSizeContext)
const writeRaw = useContext(TerminalWriteContext)
// useInsertionEffect (not useLayoutEffect): react-reconciler calls
// resetAfterCommit between the mutation and layout commit phases, and
// Ink's resetAfterCommit triggers onRender. With useLayoutEffect, that
// first onRender fires BEFORE this effect — writing a full frame to the
// main screen with altScreen=false. That frame is preserved when we
// enter alt screen and revealed on exit as a broken view. Insertion
// effects fire during the mutation phase, before resetAfterCommit, so
// ENTER_ALT_SCREEN reaches the terminal before the first frame does.
// Cleanup timing is unchanged: both insertion and layout effect cleanup
// run in the mutation phase on unmount, before resetAfterCommit.
useInsertionEffect(() => {
const ink = instances.get(process.stdout)
if (!writeRaw) return
writeRaw(
ENTER_ALT_SCREEN +
'\x1b[2J\x1b[H' +
(mouseTracking ? ENABLE_MOUSE_TRACKING : ''),
)
ink?.setAltScreenActive(true, mouseTracking)
return () => {
ink?.setAltScreenActive(false)
ink?.clearTextSelection()
writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN)
}
}, [writeRaw, mouseTracking])
return (
<Box
flexDirection="column"
height={size?.rows ?? 24}
width="100%"
flexShrink={0}
>
{children}
</Box>
)
}

View File

@@ -0,0 +1,802 @@
import React, { PureComponent, type ReactNode } from 'react'
// Business-layer callbacks — replaced with inline defaults so this package
// has zero dependencies on business code. The business layer can inject
// implementations via AppCallbacks when needed.
type AppCallbacks = {
updateLastInteractionTime?: () => void
stopCapturingEarlyInput?: () => void
isMouseClicksDisabled?: () => boolean
logError?: (error: unknown) => void
logForDebugging?: (message: string, opts?: { level?: string }) => void
}
/** Default no-op / safe-default implementations */
const defaultCallbacks: Required<AppCallbacks> = {
updateLastInteractionTime: () => {},
stopCapturingEarlyInput: () => {},
isMouseClicksDisabled: () => false,
logError: (error: unknown) => console.error(error),
logForDebugging: (_message: string, _opts?: { level?: string }) => {},
}
/**
* Override the default no-op callbacks. Call this from the business layer
* (e.g. src/ink.tsx) before mounting <App>.
*/
export function setAppCallbacks(cb: AppCallbacks): void {
Object.assign(defaultCallbacks, cb)
}
function isEnvTruthy(value: string | undefined): boolean {
return value === '1' || value === 'true'
}
import { EventEmitter } from '../core/events/emitter.js'
import { InputEvent } from '../core/events/input-event.js'
import { TerminalFocusEvent } from '../core/events/terminal-focus-event.js'
import {
INITIAL_STATE,
type ParsedInput,
type ParsedKey,
type ParsedMouse,
parseMultipleKeypresses,
} from '../core/parse-keypress.js'
import reconciler from '../core/reconciler.js'
import {
finishSelection,
hasSelection,
type SelectionState,
startSelection,
} from '../core/selection.js'
import {
isXtermJs,
setXtversionName,
supportsExtendedKeys,
} from '../core/terminal.js'
import {
getTerminalFocused,
setTerminalFocused,
} from '../core/terminal-focus-state.js'
import { TerminalQuerier, xtversion } from '../core/terminal-querier.js'
import {
DISABLE_KITTY_KEYBOARD,
DISABLE_MODIFY_OTHER_KEYS,
ENABLE_KITTY_KEYBOARD,
ENABLE_MODIFY_OTHER_KEYS,
FOCUS_IN,
FOCUS_OUT,
} from '../core/termio/csi.js'
import {
DBP,
DFE,
DISABLE_MOUSE_TRACKING,
EBP,
EFE,
HIDE_CURSOR,
SHOW_CURSOR,
} from '../core/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'
// 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
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
// 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
// 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
// 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
// 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
// Open a hyperlink URL in the browser. Called after the timer fires.
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
// 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
// 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
// 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
// Dispatch a keyboard event through the DOM tree. Called for each
// parsed key alongside the legacy EventEmitter path.
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
type State = {
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 getDerivedStateFromError(error: Error) {
return { error }
}
override state = {
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
// Timer for flushing incomplete escape sequences
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
// 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)
// 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
// 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
// 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
// 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()
// Determines if TTY is supported on the provided stdin
isRawModeSupported(): boolean {
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,
}}
>
<TerminalFocusProvider>
<ClockProvider>
<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>
)
}
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)
}
}
override componentWillUnmount() {
if (this.props.stdout.isTTY) {
this.props.stdout.write(SHOW_CURSOR)
}
// Clear any pending timers
if (this.incompleteEscapeTimer) {
clearTimeout(this.incompleteEscapeTimer)
this.incompleteEscapeTimer = null
}
if (this.pendingHyperlinkTimer) {
clearTimeout(this.pendingHyperlinkTimer)
this.pendingHyperlinkTimer = null
}
// ignore calling setRawMode on an handle stdin it cannot be called
if (this.isRawModeSupported()) {
this.handleSetRawMode(false)
}
}
override componentDidCatch(error: Error) {
this.handleExit(error)
}
handleSetRawMode = (isEnabled: boolean): void => {
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',
)
} 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',
)
}
}
stdin.setEncoding('utf8')
if (isEnabled) {
// Ensure raw mode is enabled only once
if (this.rawModeEnabledCount === 0) {
// Stop early input capture right before we add our own readable handler.
// Both use the same stdin 'readable' + read() pattern, so they can't
// coexist -- our handler would drain stdin before Ink's can see it.
// The buffered text is preserved for REPL.tsx via consumeEarlyInput().
defaultCallbacks.stopCapturingEarlyInput()
stdin.ref()
stdin.setRawMode(true)
stdin.addListener('readable', this.handleReadable)
// Enable bracketed paste mode
this.props.stdout.write(EBP)
// Enable terminal focus reporting (DECSET 1004)
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)
}
// Probe terminal identity. XTVERSION survives SSH (query/reply goes
// through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base
// detection when env vars are absent. Fire-and-forget: the DA1
// sentinel bounds the round-trip, and if the terminal ignores the
// query, flush() still resolves and name stays undefined.
// Deferred to next tick so it fires AFTER the current synchronous
// 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]) => {
if (r) {
setXtversionName(r.name)
defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`)
} else {
defaultCallbacks.logForDebugging('XTVERSION: no reply (terminal ignored query)')
}
})
})
}
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)
// Disable terminal focus reporting (DECSET 1004)
this.props.stdout.write(DFE)
// Disable bracketed paste mode
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
// Only proceed if we have incomplete sequences
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
// lone ESC). Node's event loop runs the timers phase before the poll
// phase, so when a heavy render blocks the loop past 50ms, this timer
// fires before the queued readable event even though the bytes are
// already buffered. Re-arm instead of flushing: handleReadable will
// 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
}
// Process incomplete as a flush operation (input=null)
// This reuses all existing parsing logic
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
// Process ALL keys in a SINGLE discreteUpdates call to prevent
// "Maximum update depth exceeded" error when many keys arrive at once
// (e.g., from paste operations or holding keys rapidly).
// 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,
)
}
// 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)
}
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()
if (now - this.lastStdinTime > STDIN_RESUME_GAP_MS) {
this.props.onStdinResume?.()
}
this.lastStdinTime = now
try {
let chunk
while ((chunk = this.props.stdin.read() as string | null) !== null) {
// Process the input 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.
defaultCallbacks.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)
) {
defaultCallbacks.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()
}
// 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.props.onExit(error)
}
handleTerminalFocus = (isFocused: boolean): void => {
// setTerminalFocused notifies subscribers: TerminalFocusProvider (context)
// and Clock (interval speed) — no App setState needed.
setTerminalFocused(isFocused)
}
handleSuspend = (): void => {
if (!this.isRawModeSupported()) {
return
}
// Store the exact raw mode count to restore it properly
const rawModeCountBeforeSuspend = this.rawModeEnabledCount
// Completely disable raw mode before suspending
while (this.rawModeEnabledCount > 0) {
this.handleSetRawMode(false)
}
// Show cursor, disable focus reporting, and disable mouse tracking
// before suspending. DISABLE_MOUSE_TRACKING is a no-op if tracking
// wasn't enabled, so it's safe to emit unconditionally — without
// 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)
}
// Emit suspend event for Claude Code to handle. Mostly just has a notification
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)
}
}
// 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)
}
// Re-enable focus reporting to restore terminal state
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')
}
}
// 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 {
// 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)),
)
) {
defaultCallbacks.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
}
// 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
}
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
}
if (sequence === FOCUS_OUT) {
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()
}
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)
}
// 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.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)
}
}
/** Exported for testing. Mutates app.props.selection and click/hover state. */
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 (defaultCallbacks.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
if (m.action === 'press') {
if ((m.button & 0x20) !== 0 && baseButton === 3) {
// Mode-1003 motion with no button held. Dispatch hover; skip the
// rest of this handler (no selection, no click-count side effects).
// Lost-release recovery: no-button motion while isDragging=true means
// the release happened outside the terminal window (iTerm2 doesn't
// capture the pointer past window bounds, so the SGR 'm' never
// arrives). Finish the selection here so copy-on-select fires. The
// FOCUS_OUT handler covers the "switched apps" case but not "released
// 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()
}
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
}
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
}
// Lost-release fallback for mode-1002-only terminals: a fresh press
// while isDragging=true means the previous release was dropped (cursor
// left the window). Finish that selection so copy-on-select fires
// 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()
}
// 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
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
}
// Cap at 3 (line select) for quadruple+ clicks.
const count = app.clickCount === 2 ? 2 : 3
app.props.onMultiClick(col, row, count)
return
}
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
}
// Release: end the drag even for non-zero button codes. Some terminals
// encode release with the motion bit or button=3 "no button" (carried
// over from pre-SGR X10 encoding) — filtering those would orphan
// isDragging=true and leave drag-to-scroll's timer running until the
// 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
}
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:
// trackpad jitter during an intended double-click (press→wobble→release
// →press) now correctly resolves to word-select instead of breaking to a
// fresh single click. The nearLast window (500ms, 1 cell) bounds the
// effect — a deliberate drag past that just starts a fresh chain.
// A press+release with no drag in char mode is a click: anchor set,
// focus null → hasSelection false. In word/line mode the press already
// set anchor+focus (hasSelection true), so release just keeps the
// highlight. The anchor check guards against an orphaned release (no
// prior press — e.g. button was held when mouse tracking was enabled).
if (!hasSelection(sel) && sel.anchor) {
// Single click: dispatch DOM click immediately (cursor repositioning
// etc. are latency-sensitive). If no DOM handler consumed it, defer
// the hyperlink check so a second click can cancel it.
if (!app.props.onClickAt(col, row)) {
// 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)
// 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
// preventDefault/stopPropagation). The click is also forwarded to the
// pty as SGR, so both VS Code's terminalLinkManager AND our handler
// here would open the URL — twice. We can't filter on Cmd: xterm.js
// drops metaKey before SGR encoding (ICoreMouseEvent has no meta
// field; the SGR bit we call 'meta' is wired to alt). Let xterm.js
// own link-opening; Cmd+click is the native UX there anyway.
// TERM_PROGRAM is the sync fast-path; isXtermJs() is the XTVERSION
// probe result (catches SSH + non-VS Code embedders like Hyper).
if (url && process.env.TERM_PROGRAM !== 'vscode' && !isXtermJs()) {
// Clear any prior pending timer — clicking a second link
// supersedes the first (only the latest click opens).
if (app.pendingHyperlinkTimer) {
clearTimeout(app.pendingHyperlinkTimer)
}
app.pendingHyperlinkTimer = setTimeout(
(app, url) => {
app.pendingHyperlinkTimer = null
app.props.onOpenHyperlink(url)
},
MULTI_CLICK_TIMEOUT_MS,
app,
url,
)
}
}
}
app.props.onSelectionChange()
}

View File

@@ -0,0 +1,21 @@
import { createContext } from 'react'
export type Props = {
/**
* Exit (unmount) the whole Ink app.
*/
readonly exit: (error?: Error) => void
}
/**
* `AppContext` is a React context, which exposes a method to manually exit the app (unmount).
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
const AppContext = createContext<Props>({
exit() {},
})
// eslint-disable-next-line custom-rules/no-top-level-side-effects
AppContext.displayName = 'InternalAppContext'
export default AppContext

View File

@@ -0,0 +1,118 @@
import React, { type PropsWithChildren, type Ref } from 'react'
import type { Except } from 'type-fest'
import type { DOMElement } from '../core/dom.js'
import type { ClickEvent } from '../core/events/click-event.js'
import type { FocusEvent } from '../core/events/focus-event.js'
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
import type { Styles } from '../core/styles.js'
import * as warn from '../core/warn.js'
export type Props = Except<Styles, 'textWrap'> & {
ref?: Ref<DOMElement>
/**
* Tab order index. Nodes with `tabIndex >= 0` participate in
* Tab/Shift+Tab cycling; `-1` means programmatically focusable only.
*/
tabIndex?: number
/**
* Focus this element when it mounts. Like the HTML `autofocus`
* attribute — the FocusManager calls `focus(node)` during the
* reconciler's `commitMount` phase.
*/
autoFocus?: boolean
/**
* Fired on left-button click (press + release without drag). Only works
* inside `<AlternateScreen>` where mouse tracking is enabled — no-op
* otherwise. The event bubbles from the deepest hit Box up through
* ancestors; call `event.stopImmediatePropagation()` to stop bubbling.
*/
onClick?: (event: ClickEvent) => void
onFocus?: (event: FocusEvent) => void
onFocusCapture?: (event: FocusEvent) => void
onBlur?: (event: FocusEvent) => void
onBlurCapture?: (event: FocusEvent) => void
onKeyDown?: (event: KeyboardEvent) => void
onKeyDownCapture?: (event: KeyboardEvent) => void
/**
* Fired when the mouse moves into this Box's rendered rect. Like DOM
* `mouseenter`, does NOT bubble — moving between children does not
* re-fire on the parent. Only works inside `<AlternateScreen>` where
* mode-1003 mouse tracking is enabled.
*/
onMouseEnter?: () => void
/** Fired when the mouse moves out of this Box's rendered rect. */
onMouseLeave?: () => void
}
/**
* `<Box>` is an essential Ink component to build your layout. It's like `<div style="display: flex">` in the browser.
*/
function Box({
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

View File

@@ -0,0 +1,122 @@
import React, {
type Ref,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import type { Except } from 'type-fest'
import type { DOMElement } from '../core/dom.js'
import type { ClickEvent } from '../core/events/click-event.js'
import type { FocusEvent } from '../core/events/focus-event.js'
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
import type { Styles } from '../core/styles.js'
import Box from './Box.js'
type ButtonState = {
focused: boolean
hovered: boolean
active: boolean
}
export type Props = Except<Styles, 'textWrap'> & {
ref?: Ref<DOMElement>
/**
* Called when the button is activated via Enter, Space, or click.
*/
onAction: () => void
/**
* Tab order index. Defaults to 0 (in tab order).
* Set to -1 for programmatically focusable only.
*/
tabIndex?: number
/**
* Focus this button when it mounts.
*/
autoFocus?: boolean
/**
* Render prop receiving the interactive state. Use this to
* style children based on focus/hover/active — Button itself
* is intentionally unstyled.
*
* If not provided, children render as-is (no state-dependent styling).
*/
children: ((state: ButtonState) => React.ReactNode) | React.ReactNode
}
function Button({
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 }

View File

@@ -0,0 +1,99 @@
import React, { createContext, useEffect, useState } from 'react'
import { FRAME_INTERVAL_MS } from '../core/constants.js'
import { useTerminalFocus } from '../hooks/use-terminal-focus.js'
export type Clock = {
subscribe: (onChange: () => void, keepAlive: boolean) => () => void
now: () => number
setTickInterval: (ms: number) => void
}
export function createClock(tickIntervalMs: number): Clock {
const subscribers = new Map<() => void, boolean>()
let interval: ReturnType<typeof setInterval> | null = null
let currentTickIntervalMs = tickIntervalMs
let startTime = 0
// Snapshot of the current tick's time, ensuring all subscribers in the same
// tick see the same value (keeps animations synchronized)
let tickTime = 0
function tick(): void {
tickTime = Date.now() - startTime
for (const onChange of subscribers.keys()) {
onChange()
}
}
function updateInterval(): void {
const anyKeepAlive = [...subscribers.values()].some(Boolean)
if (anyKeepAlive) {
if (interval) {
clearInterval(interval)
interval = null
}
if (startTime === 0) {
startTime = Date.now()
}
interval = setInterval(tick, currentTickIntervalMs)
} else if (interval) {
clearInterval(interval)
interval = null
}
}
return {
subscribe(onChange, keepAlive) {
subscribers.set(onChange, keepAlive)
updateInterval()
return () => {
subscribers.delete(onChange)
updateInterval()
}
},
now() {
if (startTime === 0) {
startTime = Date.now()
}
// When the clock interval is running, return the synchronized tickTime
// so all subscribers in the same tick see the same value.
// When paused (no keepAlive subscribers), return real-time to avoid
// returning a stale tickTime from the last tick before the pause.
if (interval && tickTime) {
return tickTime
}
return Date.now() - startTime
},
setTickInterval(ms) {
if (ms === currentTickIntervalMs) return
currentTickIntervalMs = ms
updateInterval()
},
}
}
export const ClockContext = createContext<Clock | null>(null)
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({
children,
}: {
children: React.ReactNode
}): React.ReactNode {
const [clock] = useState(() => createClock(FRAME_INTERVAL_MS))
const focused = useTerminalFocus()
useEffect(() => {
clock.setTickInterval(
focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS,
)
}, [clock, focused])
return <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>
}

View File

@@ -0,0 +1,32 @@
import { createContext } from 'react'
import type { DOMElement } from '../core/dom.js'
export type CursorDeclaration = {
/** Display column (terminal cell width) within the declared node */
readonly relativeX: number
/** Line number within the declared node */
readonly relativeY: number
/** The ink-box DOMElement whose yoga layout provides the absolute origin */
readonly node: DOMElement
}
/**
* Setter for the declared cursor position.
*
* The optional second argument makes `null` a conditional clear: the
* declaration is only cleared if the currently-declared node matches
* `clearIfNode`. This makes the hook safe for sibling components
* (e.g. list items) that transfer focus among themselves — without the
* node check, a newly-unfocused item's clear could clobber a
* newly-focused sibling's set depending on layout-effect order.
*/
export type CursorDeclarationSetter = (
declaration: CursorDeclaration | null,
clearIfNode?: DOMElement | null,
) => void
const CursorDeclarationContext = createContext<CursorDeclarationSetter>(
() => {},
)
export default CursorDeclarationContext

View File

@@ -0,0 +1,134 @@
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
function getStackUtils(): StackUtils {
return (stackUtils ??= new StackUtils({
cwd: process.cwd(),
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
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)
if (excerpt) {
for (const { line } of excerpt) {
lineWidth = Math.max(lineWidth, String(line).length)
}
}
} catch {
// file not readable — skip source context
}
}
return (
<Box flexDirection="column" padding={1}>
<Box>
<Text backgroundColor="ansi:red" color="ansi:white">
{' '}
ERROR{' '}
</Text>
<Text> {error.message}</Text>
</Box>
{origin && filePath && (
<Box marginTop={1}>
<Text dim>
{filePath}:{origin.line}:{origin.column}
</Text>
</Box>
)}
{origin && excerpt && (
<Box marginTop={1} flexDirection="column">
{excerpt.map(({ line, value }) => (
<Box key={line}>
<Box width={lineWidth + 1}>
<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}
backgroundColor={line === origin.line ? 'ansi:red' : undefined}
color={line === origin.line ? 'ansi:white' : undefined}
>
{' ' + value}
</Text>
</Box>
))}
</Box>
)}
{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}>
<Text dim>- </Text>
<Text bold>{line}</Text>
</Box>
)
}
return (
<Box key={line}>
<Text dim>- </Text>
<Text bold>{parsedLine.function}</Text>
<Text dim>
{' '}
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:
{parsedLine.column})
</Text>
</Box>
)
})}
</Box>
)}
</Box>
)
}

View File

@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
import React from 'react'
import { supportsHyperlinks } from '../core/supports-hyperlinks.js'
import Text from './Text.js'
export type Props = {
readonly children?: ReactNode
readonly url: string
readonly fallback?: ReactNode
}
export default function Link({
children,
url,
fallback,
}: Props): React.ReactNode {
// Use children if provided, otherwise display the URL
const content = children ?? url
if (supportsHyperlinks()) {
// Wrap in Text to ensure we're in a text context
// (ink-link is a text element like ink-text)
return (
<Text>
<ink-link href={url}>{content}</ink-link>
</Text>
)
}
return <Text>{fallback ?? content}</Text>
}

View File

@@ -0,0 +1,17 @@
import React from 'react'
export type Props = {
/**
* Number of newlines to insert.
*
* @default 1
*/
readonly count?: number
}
/**
* Adds one or more newline (\n) characters. Must be used within <Text> components.
*/
export default function Newline({ count = 1 }: Props) {
return <ink-text>{'\n'.repeat(count)}</ink-text>
}

View File

@@ -0,0 +1,45 @@
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,
* for every row this box occupies. Use for gutters rendered inside a
* wider indented container (e.g. a diff inside a tool message row):
* without this, a multi-row drag picks up the container's leading
* indent on rows below the prefix.
*
* @default false
*/
fromLeftEdge?: boolean
}
/**
* Marks its contents as non-selectable in fullscreen text selection.
* Cells inside this box are skipped by both the selection highlight and
* the copied text — the gutter stays visually unchanged while the user
* drags, making it clear what will be copied.
*
* Use to fence off gutters (line numbers, diff +/- sigils, list bullets)
* so click-drag over rendered code yields clean pasteable content:
*
* <Box flexDirection="row">
* <NoSelect fromLeftEdge><Text dimColor> 42 +</Text></NoSelect>
* <Text>const x = 1</Text>
* </Box>
*
* Only affects alt-screen text selection (<AlternateScreen> with mouse
* tracking). No-op in the main-screen scrollback render where the
* terminal's native selection is used instead.
*/
export function NoSelect({
children,
fromLeftEdge,
...boxProps
}: PropsWithChildren<Props>): React.ReactNode {
return (
<Box {...boxProps} noSelect={fromLeftEdge ? 'from-left-edge' : true}>
{children}
</Box>
)
}

View File

@@ -0,0 +1,39 @@
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[]
/** Column width the producer wrapped to. Sent to Yoga as the fixed leaf width. */
width: number
}
/**
* Bypass the <Ansi> → React tree → Yoga → squash → re-serialize roundtrip for
* content that is already terminal-ready.
*
* Use this when an external renderer (e.g. the ColorDiff NAPI module) has
* already produced ANSI-escaped, width-wrapped output. A normal <Ansi> mount
* reparses that output into one React <Text> per style span, lays out each
* span as a Yoga flex child, then walks the tree to re-emit the same escape
* codes it was given. For a long transcript full of syntax-highlighted diffs
* that roundtrip is the dominant cost of the render.
*
* This component emits a single Yoga leaf with a constant-time measure func
* (width × lines.length) and hands the joined string straight to output.write(),
* which already splits on '\n' and parses ANSI into the screen buffer.
*/
export function RawAnsi({ lines, width }: Props): React.ReactNode {
if (lines.length === 0) {
return null
}
return (
<ink-raw-ansi
rawText={lines.join('\n')}
rawWidth={width}
rawHeight={lines.length}
/>
)
}

View File

@@ -0,0 +1,257 @@
import React, {
type PropsWithChildren,
type Ref,
useImperativeHandle,
useRef,
useState,
} from 'react'
import type { Except } from 'type-fest'
import type { DOMElement } from '../core/dom.js'
import { markDirty, scheduleRenderFrom } from '../core/dom.js'
import { markCommitStart } from '../core/reconciler.js'
import type { Styles } from '../core/styles.js'
import Box from './Box.js'
export type ScrollBoxHandle = {
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
* render fires, this defers the position read to render time —
* render-node-to-output reads `el.yogaNode.getComputedTop()` in the
* SAME Yoga pass that computes scrollHeight. Deterministic. One-shot.
*/
scrollToElement: (el: DOMElement, offset?: number) => void
scrollToBottom: () => void
getScrollTop: () => number
getPendingDelta: () => number
getScrollHeight: () => number
/**
* Like getScrollHeight, but reads Yoga directly instead of the cached
* value written by render-node-to-output (throttled, up to 16ms stale).
* Use when you need a fresh value in useLayoutEffect after a React commit
* that grew content. Slightly more expensive (native Yoga call).
*/
getFreshScrollHeight: () => number
getViewportHeight: () => number
/**
* Absolute screen-buffer row of the first visible content line (inside
* padding). Used for drag-to-scroll edge detection.
*/
getViewportTop: () => number
/**
* True when scroll is pinned to the bottom. Set by scrollToBottom, the
* initial stickyScroll attribute, and by the renderer when positional
* follow fires (scrollTop at prevMax, content grows). Cleared by
* scrollTo/scrollBy. Stable signal for "at bottom" that doesn't depend on
* layout values (unlike scrollTop+viewportH >= scrollHeight).
*/
isSticky: () => boolean
/**
* Subscribe to 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
/**
* Set the render-time scrollTop clamp to the currently-mounted children's
* coverage span. Called by useVirtualScroll after computing its range;
* render-node-to-output clamps scrollTop to [min, max] so burst scrollTo
* calls that race past React's async re-render show the edge of mounted
* content instead of blank spacer. Pass undefined to disable (sticky,
* cold start).
*/
setClampBounds: (min: number | undefined, max: number | undefined) => void
}
export type ScrollBoxProps = Except<
Styles,
'textWrap' | 'overflow' | 'overflowX' | 'overflowY'
> & {
ref?: Ref<ScrollBoxHandle>
/**
* When true, automatically pins scroll position to the bottom when content
* grows. Unset manually via scrollTo/scrollBy to break the stickiness.
*/
stickyScroll?: boolean
}
/**
* A Box with `overflow: scroll` and an imperative scroll API.
*
* Children are laid out at their full Yoga-computed height inside a
* constrained container. At render time, only children intersecting the
* visible window (scrollTop..scrollTop+height) are rendered (viewport
* culling). Content is translated by -scrollTop and clipped to the box bounds.
*
* Works best inside a fullscreen (constrained-height root) Ink tree.
*/
function ScrollBox({
children,
ref,
stickyScroll,
...style
}: PropsWithChildren<ScrollBoxProps>): React.ReactNode {
const domRef = useRef<DOMElement>(null)
// 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,
// no reconciler overhead per wheel event. The microtask defer coalesces
// multiple scrollBy calls in one input batch (discreteUpdates) into one
// render — otherwise scheduleRender's leading edge fires on the FIRST
// event before subsequent events mutate scrollTop. scrollToBottom still
// forces a React render: sticky is attribute-observed, no DOM-only path.
const [, forceRender] = useState(0)
const listenersRef = useRef(new Set<() => void>())
const renderQueuedRef = useRef(false)
const notify = () => {
for (const l of listenersRef.current) l()
}
function scrollMutated(el: DOMElement): void {
// Signal background intervals (IDE poll, LSP poll, GCS fetch, orphan
// check) to skip their next tick — they compete for the event loop and
// contributed to 1402ms max frame gaps during scroll drain.
// noop — injected by business layer via onScrollActivity callback
markDirty(el)
markCommitStart()
notify()
if (renderQueuedRef.current) return
renderQueuedRef.current = true
queueMicrotask(() => {
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
[],
)
// Structure: outer viewport (overflow:scroll, constrained height) >
// inner content (flexGrow:1, flexShrink:0 — fills at least the viewport
// but grows beyond it for tall content). flexGrow:1 lets children use
// spacers to pin elements to the bottom of the scroll area. Yoga's
// Overflow.Scroll prevents the viewport from growing to fit the content.
// The renderer computes scrollHeight from the content box and culls
// content's children based on scrollTop.
//
// stickyScroll is passed as a DOM attribute (via ink-box directly) so it's
// available on the first render — ref callbacks fire after the initial
// commit, which is too late for the first frame.
return (
<ink-box
ref={el => {
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>
)
}
export default ScrollBox

View File

@@ -0,0 +1,10 @@
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() {
return <Box flexGrow={1} />
}

View File

@@ -0,0 +1,49 @@
import { createContext } from 'react'
import { EventEmitter } from '../core/events/emitter.js'
import type { TerminalQuerier } from '../core/terminal-querier.js'
export type Props = {
/**
* Stdin stream passed to `render()` in `options.stdin` or `process.stdin` by default. Useful if your app needs to handle user input.
*/
readonly stdin: NodeJS.ReadStream
/**
* Ink exposes this function via own `<StdinContext>` to be able to handle Ctrl+C, that's why you should use Ink's `setRawMode` instead of `process.stdin.setRawMode`.
* If the `stdin` stream passed to Ink does not support setRawMode, this function does nothing.
*/
readonly setRawMode: (value: boolean) => void
/**
* A boolean flag determining if the current `stdin` supports `setRawMode`. A component using `setRawMode` might want to use `isRawModeSupported` to nicely fall back in environments where raw mode is not supported.
*/
readonly isRawModeSupported: boolean
readonly internal_exitOnCtrlC: boolean
readonly internal_eventEmitter: EventEmitter
/** Query the terminal and await responses (DECRQM, OSC 11, etc.).
* Null only in the never-reached default context value. */
readonly internal_querier: TerminalQuerier | null
}
/**
* `StdinContext` is a React context, which exposes input stream.
*/
const StdinContext = createContext<Props>({
stdin: process.stdin,
internal_eventEmitter: new EventEmitter(),
setRawMode() {},
isRawModeSupported: false,
internal_exitOnCtrlC: true,
internal_querier: null,
})
// eslint-disable-next-line custom-rules/no-top-level-side-effects
StdinContext.displayName = 'InternalStdinContext'
export default StdinContext

View File

@@ -0,0 +1,53 @@
import React, { createContext, useMemo, useSyncExternalStore } from 'react'
import {
getTerminalFocused,
getTerminalFocusState,
subscribeTerminalFocus,
type TerminalFocusState,
} from '../core/terminal-focus-state.js'
export type { TerminalFocusState }
export type TerminalFocusContextProps = {
readonly isTerminalFocused: boolean
readonly terminalFocusState: TerminalFocusState
}
const TerminalFocusContext = createContext<TerminalFocusContextProps>({
isTerminalFocused: true,
terminalFocusState: 'unknown',
})
// eslint-disable-next-line custom-rules/no-top-level-side-effects
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({
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

View File

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

View File

@@ -0,0 +1,144 @@
import type { ReactNode } from 'react'
import React from 'react'
import type { Color, Styles, TextStyles } from '../core/styles.js'
type BaseProps = {
/**
* Change text color. Accepts a raw color value (rgb, hex, ansi).
*/
readonly color?: Color
/**
* Same as `color`, but for background.
*/
readonly backgroundColor?: Color
/**
* Make the text italic.
*/
readonly italic?: boolean
/**
* Make the text underlined.
*/
readonly underline?: boolean
/**
* Make the text crossed with a line.
*/
readonly strikethrough?: boolean
/**
* Inverse background and foreground colors.
*/
readonly inverse?: boolean
/**
* This property tells Ink to wrap or truncate text if its width is larger than container.
* If `wrap` is passed (by default), Ink will wrap text and split it into multiple lines.
* If `truncate-*` is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off.
*/
readonly wrap?: Styles['textWrap']
readonly children?: ReactNode
}
/**
* Bold and dim are mutually exclusive in terminals.
* This type ensures you can use one or the other, but not both.
*/
type WeightProps =
| { bold?: never; dim?: never }
| { bold: boolean; dim?: never }
| { dim: boolean; bold?: never }
export type Props = BaseProps & WeightProps
const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
wrap: {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'wrap',
},
'wrap-trim': {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'wrap-trim',
},
end: {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'end',
},
middle: {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'middle',
},
'truncate-end': {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'truncate-end',
},
truncate: {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'truncate',
},
'truncate-middle': {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'truncate-middle',
},
'truncate-start': {
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
textWrap: 'truncate-start',
},
} as const
/**
* This component can display text, and change its style to make it colorful, bold, underline, italic or strikethrough.
*/
export default function Text({
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
}
// 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 }),
}
return (
<ink-text style={memoizedStylesForWrap[wrap]} textStyles={textStyles}>
{children}
</ink-text>
)
}