style: 格式化 packages/@ant/ 下所有文件以通过 biome ci

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-05-01 21:55:51 +08:00
parent c32f26cf21
commit 9ea9859dce
92 changed files with 5903 additions and 5188 deletions

View File

@@ -1,23 +1,19 @@
import React, {
type PropsWithChildren,
useContext,
useInsertionEffect,
} from 'react'
import instances from '../core/instances.js'
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'
} 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
}>
mouseTracking?: boolean;
}>;
/**
* Run children in the terminal's alternate screen buffer, constrained to
@@ -39,12 +35,9 @@ 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({
children,
mouseTracking = true,
}: Props): React.ReactNode {
const size = useContext(TerminalSizeContext)
const writeRaw = useContext(TerminalWriteContext)
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
@@ -57,31 +50,22 @@ export function AlternateScreen({
// 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
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)
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])
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}
>
<Box flexDirection="column" height={size?.rows ?? 24} width="100%" flexShrink={0}>
{children}
</Box>
)
);
}

View File

@@ -1,14 +1,14 @@
import React, { PureComponent, type ReactNode } from 'react'
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
}
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> = {
@@ -17,46 +17,34 @@ const defaultCallbacks: Required<AppCallbacks> = {
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)
Object.assign(defaultCallbacks, cb);
}
function isEnvTruthy(value: string | undefined): boolean {
return value === '1' || value === 'true'
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 { 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'
} 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,
@@ -64,155 +52,145 @@ import {
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'
} 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'
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,
}
};
// Count how many components enabled raw mode to avoid disabling
// raw mode until all components don't need it anymore
rawModeEnabledCount = 0
rawModeEnabledCount = 0;
internal_eventEmitter = new EventEmitter()
keyParseState = INITIAL_STATE
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() {
@@ -242,56 +220,47 @@ export default class App extends PureComponent<Props, State> {
>
<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>
)
);
}
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);
} else {
// Even when raw mode was never enabled (e.g. non-TTY stdin on
// Windows Node.js), ensure stdin is unref'd so the process can
// exit. earlyInput may have called ref() before Ink mounted.
try {
this.props.stdin.unref()
this.props.stdin.unref();
} catch {
// stdin may already be destroyed
}
@@ -299,25 +268,25 @@ export default class App extends PureComponent<Props, State> {
}
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',
)
);
} 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')
stdin.setEncoding('utf8');
if (isEnabled) {
// Ensure raw mode is enabled only once
@@ -326,34 +295,34 @@ export default class App extends PureComponent<Props, State> {
// Both use the same stdin 'readable' + read() pattern, so they can't
// coexist -- the early capture handler would drain stdin before ours
// can see it. The buffered text is preserved for REPL.tsx via consumeEarlyInput().
defaultCallbacks.stopCapturingEarlyInput()
defaultCallbacks.stopCapturingEarlyInput();
// Safety net: remove any pre-existing readable listeners that aren't
// ours. In builds where setAppCallbacks() was never called, the early
// input capture's readableHandler remains attached and would consume
// all stdin data before our handleReadable sees it.
const existingListeners = stdin.listeners('readable')
const existingListeners = stdin.listeners('readable');
for (const listener of existingListeners) {
if (listener !== this.handleReadable) {
stdin.removeListener('readable', listener as any)
stdin.removeListener('readable', listener as any);
}
}
stdin.ref()
stdin.setRawMode(true)
stdin.addListener('readable', this.handleReadable)
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
@@ -364,22 +333,19 @@ 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)
defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`)
setXtversionName(r.name);
defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`);
} else {
defaultCallbacks.logForDebugging('XTVERSION: no reply (terminal ignored query)')
defaultCallbacks.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
@@ -389,31 +355,31 @@ export default class App extends PureComponent<Props, State> {
// If the old tree had more useInput hooks than the new tree, the old
// cleanup over-decrements the count to 0 even though the new tree has
// active listeners. Detect this and fix the count instead of disabling.
const activeListeners = this.internal_eventEmitter.listenerCount('input')
const activeListeners = this.internal_eventEmitter.listenerCount('input');
if (activeListeners > 0) {
this.rawModeEnabledCount = activeListeners
return
this.rawModeEnabledCount = activeListeners;
return;
}
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
@@ -424,23 +390,20 @@ 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
@@ -448,106 +411,94 @@ 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.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.
defaultCallbacks.logError(error)
defaultCallbacks.logError(error);
// Re-attach the listener in case the exception detached it.
// Bun may remove the listener after an error; without this,
// 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)
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()
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
@@ -556,49 +507,44 @@ 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')
this.internal_eventEmitter.emit('resume');
process.removeListener('SIGCONT', resumeHandler)
}
process.removeListener('SIGCONT', resumeHandler);
};
process.on('SIGCONT', resumeHandler)
process.kill(process.pid, 'SIGSTOP')
}
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.
@@ -606,75 +552,70 @@ function processKeysInBatch(
// 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)),
)
items.some(i => i.kind === 'key' || (i.kind === 'mouse' && !((i.button & 0x20) !== 0 && (i.button & 0x03) === 3)))
) {
defaultCallbacks.updateLastInteractionTime()
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
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);
}
}
@@ -682,13 +623,13 @@ function processKeysInBatch(
export function handleMouseEvent(app: App, m: ParsedMouse): void {
// Allow disabling click handling while keeping wheel scroll (which goes
// through the keybinding system as 'wheelup'/'wheeldown', not here).
if (defaultCallbacks.isMouseClicksDisabled()) return
if (defaultCallbacks.isMouseClicksDisabled()) return;
const sel = app.props.selection
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) {
@@ -702,25 +643,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
@@ -728,43 +669,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 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
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
@@ -774,12 +715,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:
@@ -800,7 +741,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
@@ -816,19 +757,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)
app.pendingHyperlinkTimer = null;
app.props.onOpenHyperlink(url);
},
MULTI_CLICK_TIMEOUT_MS,
app,
url,
)
);
}
}
}
app.props.onSelectionChange()
app.props.onSelectionChange();
}

View File

@@ -1,48 +1,48 @@
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'
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>
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.
@@ -68,23 +68,23 @@ function Box({
...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')
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
@@ -112,7 +112,7 @@ function Box({
>
{children}
</ink-box>
)
);
}
export default Box
export default Box;

View File

@@ -1,39 +1,33 @@
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'
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
}
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
@@ -41,64 +35,53 @@ export type Props = Except<Styles, 'textWrap'> & {
*
* If not provided, children render as-is (no state-dependent styling).
*/
children: ((state: ButtonState) => React.ReactNode) | React.ReactNode
}
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)
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)
const activeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (activeTimer.current) clearTimeout(activeTimer.current)
}
}, [])
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,
)
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();
},
[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 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
};
const content = typeof children === 'function' ? children(state) : children;
return (
<Box
@@ -115,8 +98,8 @@ function Button({
>
{content}
</Box>
)
);
}
export default Button
export type { ButtonState }
export default Button;
export type { ButtonState };

View File

@@ -1,99 +1,93 @@
import React, { createContext, useEffect, useState } from 'react'
import { FRAME_INTERVAL_MS } from '../core/constants.js'
import { useTerminalFocus } from '../hooks/use-terminal-focus.js'
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
}
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)
export const ClockContext = createContext<Clock | null>(null);
const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2
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()
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])
clock.setTickInterval(focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS);
}, [clock, focused]);
return <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>
return <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>;
}

View File

@@ -1,48 +1,48 @@
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()}/`, '')
}
return path?.replace(`file://${process.cwd()}/`, '');
};
let stackUtils: StackUtils | undefined
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
}
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
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)
lineWidth = Math.max(lineWidth, String(line).length);
}
}
} catch {
@@ -76,9 +76,7 @@ export default function ErrorOverview({ error }: Props) {
<Box width={lineWidth + 1}>
<Text
dim={line !== origin.line}
backgroundColor={
line === origin.line ? 'ansi:red' : undefined
}
backgroundColor={line === origin.line ? 'ansi:red' : undefined}
color={line === origin.line ? 'ansi:white' : undefined}
>
{String(line).padStart(lineWidth, ' ')}:
@@ -103,7 +101,7 @@ export default function ErrorOverview({ error }: Props) {
.split('\n')
.slice(1)
.map(line => {
const parsedLine = getStackUtils().parseLine(line)
const parsedLine = getStackUtils().parseLine(line);
// If the line from the stack cannot be parsed, we print out the unparsed line.
if (!parsedLine) {
@@ -112,7 +110,7 @@ export default function ErrorOverview({ error }: Props) {
<Text dim>- </Text>
<Text bold>{line}</Text>
</Box>
)
);
}
return (
@@ -121,14 +119,13 @@ export default function ErrorOverview({ error }: Props) {
<Text bold>{parsedLine.function}</Text>
<Text dim>
{' '}
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:
{parsedLine.column})
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:{parsedLine.column})
</Text>
</Box>
)
);
})}
</Box>
)}
</Box>
)
);
}

View File

@@ -1,21 +1,17 @@
import type { ReactNode } from 'react'
import React from 'react'
import { supportsHyperlinks } from '../core/supports-hyperlinks.js'
import Text from './Text.js'
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
}
readonly children?: ReactNode;
readonly url: string;
readonly fallback?: ReactNode;
};
export default function Link({
children,
url,
fallback,
}: Props): React.ReactNode {
export default function Link({ children, url, fallback }: Props): React.ReactNode {
// Use children if provided, otherwise display the URL
const content = children ?? url
const content = children ?? url;
if (supportsHyperlinks()) {
// Wrap in Text to ensure we're in a text context
@@ -24,8 +20,8 @@ export default function Link({
<Text>
<ink-link href={url}>{content}</ink-link>
</Text>
)
);
}
return <Text>{fallback ?? content}</Text>
return <Text>{fallback ?? content}</Text>;
}

View File

@@ -1,4 +1,4 @@
import React from 'react'
import React from 'react';
export type Props = {
/**
@@ -6,12 +6,12 @@ export type Props = {
*
* @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({ count = 1 }: Props) {
return <ink-text>{'\n'.repeat(count)}</ink-text>
return <ink-text>{'\n'.repeat(count)}</ink-text>;
}

View File

@@ -1,5 +1,5 @@
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'> & {
/**
@@ -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,14 +32,10 @@ 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({
children,
fromLeftEdge,
...boxProps
}: PropsWithChildren<Props>): React.ReactNode {
export function NoSelect({ children, fromLeftEdge, ...boxProps }: PropsWithChildren<Props>): React.ReactNode {
return (
<Box {...boxProps} noSelect={fromLeftEdge ? 'from-left-edge' : true}>
{children}
</Box>
)
);
}

View File

@@ -1,14 +1,14 @@
import 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
@@ -27,13 +27,7 @@ type Props = {
*/
export function RawAnsi({ lines, width }: Props): React.ReactNode {
if (lines.length === 0) {
return null
return null;
}
return (
<ink-raw-ansi
rawText={lines.join('\n')}
rawWidth={width}
rawHeight={lines.length}
/>
)
return <ink-raw-ansi rawText={lines.join('\n')} rawWidth={width} rawHeight={lines.length} />;
}

View File

@@ -1,20 +1,14 @@
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'
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
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
@@ -22,24 +16,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
@@ -47,14 +41,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;
@@ -63,20 +57,17 @@ export type ScrollBoxHandle = {
* content instead of blank spacer. Pass undefined to disable (sticky,
* cold start).
*/
setClampBounds: (min: number | undefined, max: number | undefined) => void
}
setClampBounds: (min: number | undefined, max: number | undefined) => void;
};
export type ScrollBoxProps = Except<
Styles,
'textWrap' | 'overflow' | 'overflowX' | 'overflowY'
> & {
ref?: Ref<ScrollBoxHandle>
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.
@@ -88,13 +79,8 @@ export type ScrollBoxProps = Except<
*
* 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)
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,
@@ -103,113 +89,109 @@ 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.
// noop — injected by business layer via onScrollActivity callback
markDirty(el)
markCommitStart()
notify()
if (renderQueuedRef.current) return
renderQueuedRef.current = true
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
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)
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)
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
const el = domRef.current;
if (!el) return;
el.stickyScroll = false;
// Wheel input cancels any in-flight anchor seek — user override.
el.scrollAnchor = undefined
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)
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)
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
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
return domRef.current?.pendingScrollDelta ?? 0;
},
getScrollHeight() {
return domRef.current?.scrollHeight ?? 0
return domRef.current?.scrollHeight ?? 0;
},
getFreshScrollHeight() {
const content = domRef.current?.childNodes[0] as DOMElement | undefined
return (
content?.yogaNode?.getComputedHeight() ??
domRef.current?.scrollHeight ??
0
)
const content = domRef.current?.childNodes[0] as DOMElement | undefined;
return content?.yogaNode?.getComputedHeight() ?? domRef.current?.scrollHeight ?? 0;
},
getViewportHeight() {
return domRef.current?.scrollViewportHeight ?? 0
return domRef.current?.scrollViewportHeight ?? 0;
},
getViewportTop() {
return domRef.current?.scrollViewportTop ?? 0
return domRef.current?.scrollViewportTop ?? 0;
},
isSticky() {
const el = domRef.current
if (!el) return false
return el.stickyScroll ?? Boolean(el.attributes['stickyScroll'])
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)
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
const el = domRef.current;
if (!el) return;
el.scrollClampMin = min;
el.scrollClampMax = max;
},
}),
// notify/scrollMutated are inline (no useCallback) but only close over
@@ -217,7 +199,7 @@ function ScrollBox({
// 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
@@ -233,8 +215,8 @@ function ScrollBox({
return (
<ink-box
ref={el => {
domRef.current = el
if (el) el.scrollTop ??= 0
domRef.current = el;
if (el) el.scrollTop ??= 0;
}}
style={{
flexWrap: 'nowrap',
@@ -251,7 +233,7 @@ function ScrollBox({
{children}
</Box>
</ink-box>
)
);
}
export default ScrollBox
export default ScrollBox;

View File

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

View File

@@ -1,53 +1,36 @@
import React, { createContext, useMemo, useSyncExternalStore } from 'react'
import React, { createContext, useMemo, useSyncExternalStore } from 'react';
import {
getTerminalFocused,
getTerminalFocusState,
subscribeTerminalFocus,
type TerminalFocusState,
} from '../core/terminal-focus-state.js'
} from '../core/terminal-focus-state.js';
export type { TerminalFocusState }
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',
})
});
// 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({
children,
}: {
children: React.ReactNode
}): React.ReactNode {
const isTerminalFocused = useSyncExternalStore(
subscribeTerminalFocus,
getTerminalFocused,
)
const terminalFocusState = useSyncExternalStore(
subscribeTerminalFocus,
getTerminalFocusState,
)
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],
)
const value = useMemo(() => ({ isTerminalFocused, terminalFocusState }), [isTerminalFocused, terminalFocusState]);
return (
<TerminalFocusContext.Provider value={value}>
{children}
</TerminalFocusContext.Provider>
)
return <TerminalFocusContext.Provider value={value}>{children}</TerminalFocusContext.Provider>;
}
export default TerminalFocusContext
export default TerminalFocusContext;

View File

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

View File

@@ -1,58 +1,55 @@
import type { ReactNode } from 'react'
import React from 'react'
import type { Color, Styles, TextStyles } from '../core/styles.js'
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
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 wrap?: Styles['textWrap'];
readonly children?: ReactNode
}
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 }
type WeightProps = { bold?: never; dim?: never } | { bold: boolean; dim?: never } | { dim: boolean; bold?: never };
export type Props = BaseProps & WeightProps
export type Props = BaseProps & WeightProps;
const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
wrap: {
@@ -103,7 +100,7 @@ const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
flexDirection: 'row',
textWrap: 'truncate-start',
},
} as const
} as const;
/**
* This component can display text, and change its style to make it colorful, bold, underline, italic or strikethrough.
@@ -121,7 +118,7 @@ export default function Text({
children,
}: Props): React.ReactNode {
if (children === undefined || children === null) {
return null
return null;
}
// Build textStyles object with only the properties that are set
@@ -134,11 +131,11 @@ export default function Text({
...(underline && { underline }),
...(strikethrough && { strikethrough }),
...(inverse && { inverse }),
}
};
return (
<ink-text style={memoizedStylesForWrap[wrap]} textStyles={textStyles}>
{children}
</ink-text>
)
);
}

View File

@@ -1,31 +1,26 @@
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.
@@ -35,43 +30,32 @@ type SpanProps = {
*
* Memoized to prevent re-renders when parent changes but children string is the same.
*/
export const Ansi = React.memo(function Ansi({
children,
dimColor,
}: Props): React.ReactNode {
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>
)
return dimColor ? <Text dim>{String(children)}</Text> : <Text>{String(children)}</Text>;
}
if (children === '') {
return null
return null;
}
const spans = parseToSpans(children)
const spans = parseToSpans(children);
if (spans.length === 0) {
return null
return null;
}
if (spans.length === 1 && !hasAnyProps(spans[0]!.props)) {
return dimColor ? (
<Text dim>{spans[0]!.text}</Text>
) : (
<Text>{spans[0]!.text}</Text>
)
return dimColor ? <Text dim>{spans[0]!.text}</Text> : <Text>{spans[0]!.text}</Text>;
}
const content = spans.map((span, i) => {
const hyperlink = span.props.hyperlink
const hyperlink = span.props.hyperlink;
// When dimColor is forced, override the span's dim prop
if (dimColor) {
span.props.dim = true
span.props.dim = true;
}
const hasTextProps = hasAnyTextProps(span.props)
const hasTextProps = hasAnyTextProps(span.props);
if (hyperlink) {
return hasTextProps ? (
@@ -93,7 +77,7 @@ export const Ansi = React.memo(function Ansi({
<Link key={i} url={hyperlink}>
{span.text}
</Link>
)
);
}
return hasTextProps ? (
@@ -112,79 +96,79 @@ export const Ansi = React.memo(function Ansi({
</StyledText>
) : (
span.text
)
})
);
});
return dimColor ? <Text dim>{content}</Text> : <Text>{content}</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[] = []
const parser = new Parser();
const actions = parser.feed(input);
const spans: Span[] = [];
let currentHyperlink: string | undefined
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 text = action.graphemes.map(g => g.value).join('');
if (!text) continue;
const props = textStyleToSpanProps(action.style)
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 = {}
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
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 fgColor = colorToString(style.fg);
if (fgColor) props.color = fgColor;
const bgColor = colorToString(style.bg)
if (bgColor) props.backgroundColor = bgColor
const bgColor = colorToString(style.bg);
if (bgColor) props.backgroundColor = bgColor;
return props
return props;
}
// Map termio named colors to the ansi: format
@@ -205,7 +189,7 @@ const NAMED_COLOR_MAP: Record<NamedColor, string> = {
brightMagenta: 'ansi:magentaBright',
brightCyan: 'ansi:cyanBright',
brightWhite: 'ansi:whiteBright',
}
};
/**
* Convert termio's Color to the string format used by Ink.
@@ -213,13 +197,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;
}
}
@@ -237,7 +221,7 @@ function propsEqual(a: SpanProps, b: SpanProps): boolean {
a.strikethrough === b.strikethrough &&
a.inverse === b.inverse &&
a.hyperlink === b.hyperlink
)
);
}
function hasAnyProps(props: SpanProps): boolean {
@@ -251,7 +235,7 @@ function hasAnyProps(props: SpanProps): boolean {
props.strikethrough === true ||
props.inverse === true ||
props.hyperlink !== undefined
)
);
}
function hasAnyTextProps(props: SpanProps): boolean {
@@ -264,18 +248,18 @@ function hasAnyTextProps(props: SpanProps): boolean {
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({
@@ -284,9 +268,9 @@ function StyledText({
children,
...rest
}: BaseTextStyleProps & {
bold?: boolean
dim?: boolean
children: string
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) {
@@ -294,14 +278,14 @@ function StyledText({
<Text {...rest} dim>
{children}
</Text>
)
);
}
if (bold) {
return (
<Text {...rest} bold>
{children}
</Text>
)
);
}
return <Text {...rest}>{children}</Text>
return <Text {...rest}>{children}</Text>;
}

View File

@@ -17,8 +17,16 @@
import bidiFactory from 'bidi-js'
type BidiInstance = {
getEmbeddingLevels: (text: string, defaultDirection?: string) => { paragraphLevel: number; levels: Uint8Array }
getReorderSegments: (text: string, embeddingLevels: { paragraphLevel: number; levels: Uint8Array }, start?: number, end?: number) => [number, number][]
getEmbeddingLevels: (
text: string,
defaultDirection?: string,
) => { paragraphLevel: number; levels: Uint8Array }
getReorderSegments: (
text: string,
embeddingLevels: { paragraphLevel: number; levels: Uint8Array },
start?: number,
end?: number,
) => [number, number][]
getVisualOrder: (reorderSegments: [number, number][]) => number[]
}

View File

@@ -1,2 +1,2 @@
// Auto-generated stub — replace with real implementation
export type Cursor = any;
export type Cursor = any

View File

@@ -1,2 +1,2 @@
// Auto-generated stub — replace with real implementation
export {};
export {}

View File

@@ -37,7 +37,9 @@ export class MouseActionEvent extends Event {
/** Recompute local coords relative to the target Box. */
prepareForTarget(target: EventTarget): void {
const dom = target as unknown as { yogaNode?: { getComputedLeft?(): number; getComputedTop?(): number } }
const dom = target as unknown as {
yogaNode?: { getComputedLeft?(): number; getComputedTop?(): number }
}
this.localCol = this.col - (dom.yogaNode?.getComputedLeft?.() ?? 0)
this.localRow = this.row - (dom.yogaNode?.getComputedTop?.() ?? 0)
}

View File

@@ -1,2 +1,2 @@
// Auto-generated stub — replace with real implementation
export type PasteEvent = any;
export type PasteEvent = any

View File

@@ -1,2 +1,2 @@
// Auto-generated stub — replace with real implementation
export type ResizeEvent = any;
export type ResizeEvent = any

View File

@@ -14,9 +14,18 @@ function execFileNoThrow(
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise(resolve => {
const { input, timeout } = options
const proc = nodeExecFile(command, args, { timeout }, (error, stdout, stderr) => {
resolve({ code: error ? 1 : 0, stdout: stdout ?? '', stderr: stderr ?? '' })
})
const proc = nodeExecFile(
command,
args,
{ timeout },
(error, stdout, stderr) => {
resolve({
code: error ? 1 : 0,
stdout: stdout ?? '',
stderr: stderr ?? '',
})
},
)
if (input && proc.stdin) {
proc.stdin.write(input)
proc.stdin.end()

View File

@@ -49,7 +49,13 @@ export default function sliceAnsi(
// pass start/end in display cells (via stringWidth), so position must
// track the same units.
const width =
token.type === 'ansi' ? 0 : token.type === 'char' ? (token.fullWidth ? 2 : stringWidth(token.value)) : 0
token.type === 'ansi'
? 0
: token.type === 'char'
? token.fullWidth
? 2
: stringWidth(token.value)
: 0
// Break AFTER trailing zero-width marks — a combining mark attaches to
// the preceding base char, so "भा" (भ + ा, 1 display cell) sliced at

View File

@@ -110,7 +110,9 @@ export function useSearchInput({
if (e.key === 'delete') {
e.preventDefault()
if (cursorOffset < query.length) {
setQueryState(query.slice(0, cursorOffset) + query.slice(cursorOffset + 1))
setQueryState(
query.slice(0, cursorOffset) + query.slice(cursorOffset + 1),
)
}
return
}
@@ -159,7 +161,9 @@ export function useSearchInput({
return
}
if (cursorOffset < query.length) {
setQueryState(query.slice(0, cursorOffset) + query.slice(cursorOffset + 1))
setQueryState(
query.slice(0, cursorOffset) + query.slice(cursorOffset + 1),
)
}
return
}
@@ -207,7 +211,9 @@ export function useSearchInput({
// Regular character input
if (e.key.length >= 1 && !UNHANDLED_SPECIAL_KEYS.has(e.key)) {
e.preventDefault()
setQueryState(query.slice(0, cursorOffset) + e.key + query.slice(cursorOffset))
setQueryState(
query.slice(0, cursorOffset) + e.key + query.slice(cursorOffset),
)
setCursorOffset(cursorOffset + 1)
}
}

View File

@@ -1,7 +1,16 @@
import { createContext, useCallback, useContext, useMemo } from 'react'
import { isProgressReportingAvailable, type Progress } from '../core/terminal.js'
import {
isProgressReportingAvailable,
type Progress,
} from '../core/terminal.js'
import { BEL } from '../core/termio/ansi.js'
import { ITERM2, OSC, osc, PROGRESS, wrapForMultiplexer } from '../core/termio/osc.js'
import {
ITERM2,
OSC,
osc,
PROGRESS,
wrapForMultiplexer,
} from '../core/termio/osc.js'
type WriteRaw = (data: string) => void

View File

@@ -10,13 +10,16 @@
// ============================================================
// Core API (render/createRoot)
// ============================================================
export { default as wrappedRender, renderSync, createRoot } from './core/root.js'
export {
default as wrappedRender,
renderSync,
createRoot,
} from './core/root.js'
export type { RenderOptions, Instance, Root } from './core/root.js'
export * from './theme/theme-types.js'
// InkCore class
export { default as Ink } from './core/ink.js'
// ============================================================
// Keybindings
// ============================================================
@@ -68,8 +71,21 @@ export type {
// ============================================================
// Core types
// ============================================================
export type { DOMElement, TextNode, ElementNames, DOMNodeAttribute } from './core/dom.js'
export type { Styles, TextStyles, Color, RGBColor, HexColor, Ansi256Color, AnsiColor } from './core/styles.js'
export type {
DOMElement,
TextNode,
ElementNames,
DOMNodeAttribute,
} from './core/dom.js'
export type {
Styles,
TextStyles,
Color,
RGBColor,
HexColor,
Ansi256Color,
AnsiColor,
} from './core/styles.js'
export type { Key } from './core/events/input-event.js'
export type { FlickerReason, FrameEvent } from './core/frame.js'
export type { MatchPosition } from './core/render-to-screen.js'
@@ -83,7 +99,10 @@ export { ClickEvent } from './core/events/click-event.js'
export { EventEmitter } from './core/events/emitter.js'
export { Event } from './core/events/event.js'
export { InputEvent } from './core/events/input-event.js'
export { TerminalFocusEvent, type TerminalFocusEventType } from './core/events/terminal-focus-event.js'
export {
TerminalFocusEvent,
type TerminalFocusEventType,
} from './core/events/terminal-focus-event.js'
export { KeyboardEvent } from './core/events/keyboard-event.js'
export { FocusEvent } from './core/events/focus-event.js'
export { FocusManager } from './core/focus.js'
@@ -92,17 +111,53 @@ export { stringWidth } from './core/stringWidth.js'
export { default as wrapText } from './core/wrap-text.js'
export { default as measureElement } from './core/measure-element.js'
export { supportsTabStatus } from './core/termio/osc.js'
export { setClipboard, getClipboardPath, CLEAR_ITERM2_PROGRESS, CLEAR_TAB_STATUS, CLEAR_TERMINAL_TITLE, wrapForMultiplexer } from './core/termio/osc.js'
export { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS } from './core/termio/csi.js'
export { SHOW_CURSOR, DBP, DFE, DISABLE_MOUSE_TRACKING, EXIT_ALT_SCREEN, HIDE_CURSOR, ENTER_ALT_SCREEN, ENABLE_MOUSE_TRACKING } from './core/termio/dec.js'
export {
setClipboard,
getClipboardPath,
CLEAR_ITERM2_PROGRESS,
CLEAR_TAB_STATUS,
CLEAR_TERMINAL_TITLE,
wrapForMultiplexer,
} from './core/termio/osc.js'
export {
DISABLE_KITTY_KEYBOARD,
DISABLE_MODIFY_OTHER_KEYS,
} from './core/termio/csi.js'
export {
SHOW_CURSOR,
DBP,
DFE,
DISABLE_MOUSE_TRACKING,
EXIT_ALT_SCREEN,
HIDE_CURSOR,
ENTER_ALT_SCREEN,
ENABLE_MOUSE_TRACKING,
} from './core/termio/dec.js'
export { default as instances } from './core/instances.js'
export { default as renderBorder, type BorderTextOptions } from './core/render-border.js'
export { isSynchronizedOutputSupported, isXtermJs, hasCursorUpViewportYankBug, writeDiffToTerminal } from './core/terminal.js'
export { colorize, applyColor, applyTextStyles, type ColorType } from './core/colorize.js'
export {
default as renderBorder,
type BorderTextOptions,
} from './core/render-border.js'
export {
isSynchronizedOutputSupported,
isXtermJs,
hasCursorUpViewportYankBug,
writeDiffToTerminal,
} from './core/terminal.js'
export {
colorize,
applyColor,
applyTextStyles,
type ColorType,
} from './core/colorize.js'
export { wrapAnsi } from './core/wrapAnsi.js'
export { default as styles } from './core/styles.js'
export { clamp } from './core/layout/geometry.js'
export { getTerminalFocusState, getTerminalFocused, subscribeTerminalFocus } from './core/terminal-focus-state.js'
export {
getTerminalFocusState,
getTerminalFocused,
subscribeTerminalFocus,
} from './core/terminal-focus-state.js'
export { supportsHyperlinks } from './core/supports-hyperlinks.js'
// ============================================================
@@ -112,7 +167,11 @@ export { default as BaseBox } from './components/Box.js'
export type { Props as BaseBoxProps } from './components/Box.js'
export { default as BaseText } from './components/Text.js'
export type { Props as BaseTextProps } from './components/Text.js'
export { default as Button, type ButtonState, type Props as ButtonProps } from './components/Button.js'
export {
default as Button,
type ButtonState,
type Props as ButtonProps,
} from './components/Button.js'
export { default as Link } from './components/Link.js'
export type { Props as LinkProps } from './components/Link.js'
export { default as Newline } from './components/Newline.js'
@@ -120,13 +179,19 @@ export type { Props as NewlineProps } from './components/Newline.js'
export { default as Spacer } from './components/Spacer.js'
export { NoSelect } from './components/NoSelect.js'
export { RawAnsi } from './components/RawAnsi.js'
export { default as ScrollBox, type ScrollBoxHandle } from './components/ScrollBox.js'
export {
default as ScrollBox,
type ScrollBoxHandle,
} from './components/ScrollBox.js'
export { AlternateScreen } from './components/AlternateScreen.js'
// App types
export type { Props as AppProps } from './components/AppContext.js'
export type { Props as StdinProps } from './components/StdinContext.js'
export { TerminalSizeContext, type TerminalSize } from './components/TerminalSizeContext.js'
export {
TerminalSizeContext,
type TerminalSize,
} from './components/TerminalSizeContext.js'
// ============================================================
// Hooks
@@ -140,14 +205,21 @@ export { default as useStdin } from './hooks/use-stdin.js'
export { useTerminalSize } from './hooks/useTerminalSize.js'
export { useTimeout } from './hooks/useTimeout.js'
export { useMinDisplayTime } from './hooks/useMinDisplayTime.js'
export { useDoublePress, DOUBLE_PRESS_TIMEOUT_MS } from './hooks/useDoublePress.js'
export {
useDoublePress,
DOUBLE_PRESS_TIMEOUT_MS,
} from './hooks/useDoublePress.js'
export { useTabStatus, type TabStatusKind } from './hooks/use-tab-status.js'
export { useTerminalFocus } from './hooks/use-terminal-focus.js'
export { useTerminalTitle } from './hooks/use-terminal-title.js'
export { useTerminalViewport } from './hooks/use-terminal-viewport.js'
export { useSearchHighlight } from './hooks/use-search-highlight.js'
export { useDeclaredCursor } from './hooks/use-declared-cursor.js'
export { TerminalWriteProvider, useTerminalNotification, type TerminalNotification } from './hooks/useTerminalNotification.js'
export {
TerminalWriteProvider,
useTerminalNotification,
type TerminalNotification,
} from './hooks/useTerminalNotification.js'
// ============================================================
// Theme (Layer 3)

View File

@@ -1,84 +1,63 @@
import React, {
createContext,
type RefObject,
useContext,
useLayoutEffect,
useMemo,
} from 'react'
import type { Key } from '../core/events/input-event.js'
import {
type ChordResolveResult,
getBindingDisplayText,
resolveKeyWithChordState,
} from './resolver.js'
import type {
KeybindingContextName,
ParsedBinding,
ParsedKeystroke,
} from './types.js'
import React, { createContext, type RefObject, useContext, useLayoutEffect, useMemo } from 'react';
import type { Key } from '../core/events/input-event.js';
import { type ChordResolveResult, getBindingDisplayText, resolveKeyWithChordState } from './resolver.js';
import type { KeybindingContextName, ParsedBinding, ParsedKeystroke } from './types.js';
/** Handler registration for action callbacks */
type HandlerRegistration = {
action: string
context: KeybindingContextName
handler: () => void
}
action: string;
context: KeybindingContextName;
handler: () => void;
};
type KeybindingContextValue = {
/** Resolve a key input to an action name (with chord support) */
resolve: (
input: string,
key: Key,
activeContexts: KeybindingContextName[],
) => ChordResolveResult
resolve: (input: string, key: Key, activeContexts: KeybindingContextName[]) => ChordResolveResult;
/** Update the pending chord state */
setPendingChord: (pending: ParsedKeystroke[] | null) => void
setPendingChord: (pending: ParsedKeystroke[] | null) => void;
/** Get display text for an action (e.g., "ctrl+t") */
getDisplayText: (
action: string,
context: KeybindingContextName,
) => string | undefined
getDisplayText: (action: string, context: KeybindingContextName) => string | undefined;
/** All parsed bindings (for help display) */
bindings: ParsedBinding[]
bindings: ParsedBinding[];
/** Current pending chord keystrokes (null if not in a chord) */
pendingChord: ParsedKeystroke[] | null
pendingChord: ParsedKeystroke[] | null;
/** Currently active keybinding contexts (for priority resolution) */
activeContexts: Set<KeybindingContextName>
activeContexts: Set<KeybindingContextName>;
/** Register a context as active (call on mount) */
registerActiveContext: (context: KeybindingContextName) => void
registerActiveContext: (context: KeybindingContextName) => void;
/** Unregister a context (call on unmount) */
unregisterActiveContext: (context: KeybindingContextName) => void
unregisterActiveContext: (context: KeybindingContextName) => void;
/** Register a handler for an action (used by useKeybinding) */
registerHandler: (registration: HandlerRegistration) => () => void
registerHandler: (registration: HandlerRegistration) => () => void;
/** Invoke all handlers for an action (used by ChordInterceptor) */
invokeAction: (action: string) => boolean
}
invokeAction: (action: string) => boolean;
};
const KeybindingContext = createContext<KeybindingContextValue | null>(null)
const KeybindingContext = createContext<KeybindingContextValue | null>(null);
type ProviderProps = {
bindings: ParsedBinding[]
bindings: ParsedBinding[];
/** Ref for immediate access to pending chord (avoids React state delay) */
pendingChordRef: RefObject<ParsedKeystroke[] | null>
pendingChordRef: RefObject<ParsedKeystroke[] | null>;
/** State value for re-renders (UI updates) */
pendingChord: ParsedKeystroke[] | null
setPendingChord: (pending: ParsedKeystroke[] | null) => void
activeContexts: Set<KeybindingContextName>
registerActiveContext: (context: KeybindingContextName) => void
unregisterActiveContext: (context: KeybindingContextName) => void
pendingChord: ParsedKeystroke[] | null;
setPendingChord: (pending: ParsedKeystroke[] | null) => void;
activeContexts: Set<KeybindingContextName>;
registerActiveContext: (context: KeybindingContextName) => void;
unregisterActiveContext: (context: KeybindingContextName) => void;
/** Ref to handler registry (used by ChordInterceptor) */
handlerRegistryRef: RefObject<Map<string, Set<HandlerRegistration>>>
children: React.ReactNode
}
handlerRegistryRef: RefObject<Map<string, Set<HandlerRegistration>>>;
children: React.ReactNode;
};
export function KeybindingProvider({
bindings,
@@ -93,60 +72,54 @@ export function KeybindingProvider({
}: ProviderProps): React.ReactNode {
const value = useMemo<KeybindingContextValue>(() => {
const getDisplay = (action: string, context: KeybindingContextName) =>
getBindingDisplayText(action, context, bindings)
getBindingDisplayText(action, context, bindings);
// Register a handler for an action
const registerHandler = (registration: HandlerRegistration) => {
const registry = handlerRegistryRef.current
if (!registry) return () => {}
const registry = handlerRegistryRef.current;
if (!registry) return () => {};
if (!registry.has(registration.action)) {
registry.set(registration.action, new Set())
registry.set(registration.action, new Set());
}
registry.get(registration.action)!.add(registration)
registry.get(registration.action)!.add(registration);
// Return unregister function
return () => {
const handlers = registry.get(registration.action)
const handlers = registry.get(registration.action);
if (handlers) {
handlers.delete(registration)
handlers.delete(registration);
if (handlers.size === 0) {
registry.delete(registration.action)
registry.delete(registration.action);
}
}
}
}
};
};
// Invoke all handlers for an action
const invokeAction = (action: string): boolean => {
const registry = handlerRegistryRef.current
if (!registry) return false
const registry = handlerRegistryRef.current;
if (!registry) return false;
const handlers = registry.get(action)
if (!handlers || handlers.size === 0) return false
const handlers = registry.get(action);
if (!handlers || handlers.size === 0) return false;
// Find handlers whose context is active
for (const registration of handlers) {
if (activeContexts.has(registration.context)) {
registration.handler()
return true
registration.handler();
return true;
}
}
return false
}
return false;
};
return {
// Use ref for immediate access to pending chord, avoiding React state delay
// This is critical for chord sequences where the second key might be pressed
// before React re-renders with the updated pendingChord state
resolve: (input, key, contexts) =>
resolveKeyWithChordState(
input,
key,
contexts,
bindings,
pendingChordRef.current,
),
resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current),
setPendingChord,
getDisplayText: getDisplay,
bindings,
@@ -156,7 +129,7 @@ export function KeybindingProvider({
unregisterActiveContext,
registerHandler,
invokeAction,
}
};
}, [
bindings,
pendingChordRef,
@@ -166,23 +139,17 @@ export function KeybindingProvider({
registerActiveContext,
unregisterActiveContext,
handlerRegistryRef,
])
]);
return (
<KeybindingContext.Provider value={value}>
{children}
</KeybindingContext.Provider>
)
return <KeybindingContext.Provider value={value}>{children}</KeybindingContext.Provider>;
}
export function useKeybindingContext(): KeybindingContextValue {
const ctx = useContext(KeybindingContext)
const ctx = useContext(KeybindingContext);
if (!ctx) {
throw new Error(
'useKeybindingContext must be used within KeybindingProvider',
)
throw new Error('useKeybindingContext must be used within KeybindingProvider');
}
return ctx
return ctx;
}
/**
@@ -190,7 +157,7 @@ export function useKeybindingContext(): KeybindingContextValue {
* Useful for components that may render before provider is available.
*/
export function useOptionalKeybindingContext(): KeybindingContextValue | null {
return useContext(KeybindingContext)
return useContext(KeybindingContext);
}
/**
@@ -208,18 +175,15 @@ export function useOptionalKeybindingContext(): KeybindingContextValue | null {
* }
* ```
*/
export function useRegisterKeybindingContext(
context: KeybindingContextName,
isActive: boolean = true,
): void {
const keybindingContext = useOptionalKeybindingContext()
export function useRegisterKeybindingContext(context: KeybindingContextName, isActive: boolean = true): void {
const keybindingContext = useOptionalKeybindingContext();
useLayoutEffect(() => {
if (!keybindingContext || !isActive) return
if (!keybindingContext || !isActive) return;
keybindingContext.registerActiveContext(context)
keybindingContext.registerActiveContext(context);
return () => {
keybindingContext.unregisterActiveContext(context)
}
}, [context, keybindingContext, isActive])
keybindingContext.unregisterActiveContext(context);
};
}, [context, keybindingContext, isActive]);
}

View File

@@ -5,49 +5,47 @@
* wrapper. App-specific dependencies (binding loading, change subscription,
* warning display, debug logging) are injected via props.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react'
import type { InputEvent } from '../core/events/input-event.js'
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { InputEvent } from '../core/events/input-event.js';
// ChordInterceptor intentionally uses useInput to intercept all keystrokes before
// other handlers process them - this is required for chord sequence support
// eslint-disable-next-line custom-rules/prefer-use-keybindings
import useInput from '../hooks/use-input.js'
import type { Key } from '../core/events/input-event.js'
import { KeybindingProvider } from './KeybindingContext.js'
import { resolveKeyWithChordState } from './resolver.js'
import useInput from '../hooks/use-input.js';
import type { Key } from '../core/events/input-event.js';
import { KeybindingProvider } from './KeybindingContext.js';
import { resolveKeyWithChordState } from './resolver.js';
import type {
KeybindingContextName,
KeybindingsLoadResult,
ParsedBinding,
ParsedKeystroke,
KeybindingWarning,
} from './types.js'
} from './types.js';
/**
* Timeout for chord sequences in milliseconds.
* If the user doesn't complete the chord within this time, it's cancelled.
*/
const CHORD_TIMEOUT_MS = 1000
const CHORD_TIMEOUT_MS = 1000;
export type KeybindingSetupProps = {
children: React.ReactNode
children: React.ReactNode;
/** Load bindings synchronously for initial render */
loadBindings: () => KeybindingsLoadResult
loadBindings: () => KeybindingsLoadResult;
/** Subscribe to binding changes; return an unsubscribe function */
subscribeToChanges: (
callback: (result: KeybindingsLoadResult) => void,
) => () => void
subscribeToChanges: (callback: (result: KeybindingsLoadResult) => void) => () => void;
/** Initialize any file watcher (idempotent). Called once on mount. */
initWatcher?: () => void | Promise<void>
initWatcher?: () => void | Promise<void>;
/** Optional callback when warnings are emitted (initial load or reload) */
onWarnings?: (warnings: KeybindingWarning[], isReload: boolean) => void
onWarnings?: (warnings: KeybindingWarning[], isReload: boolean) => void;
/** Optional debug logger */
onDebugLog?: (message: string) => void
}
onDebugLog?: (message: string) => void;
};
export function KeybindingSetup({
children,
@@ -59,115 +57,105 @@ export function KeybindingSetup({
}: KeybindingSetupProps): React.ReactNode {
// Load bindings synchronously for initial render
const [loadResult, setLoadResult] = useState<KeybindingsLoadResult>(() => {
const result = loadBindings()
const result = loadBindings();
onDebugLog?.(
`[keybindings] KeybindingSetup initialized with ${result.bindings.length} bindings, ${result.warnings.length} warnings`,
)
return result
})
);
return result;
});
const { bindings, warnings } = loadResult
const { bindings, warnings } = loadResult;
// Track if this is a reload (not initial load)
const [isReload, setIsReload] = useState(false)
const [isReload, setIsReload] = useState(false);
// Notify about warnings
useEffect(() => {
onWarnings?.(warnings, isReload)
}, [warnings, isReload, onWarnings])
onWarnings?.(warnings, isReload);
}, [warnings, isReload, onWarnings]);
// Chord state management - use ref for immediate access, state for re-renders
const pendingChordRef = useRef<ParsedKeystroke[] | null>(null)
const [pendingChord, setPendingChordState] = useState<
ParsedKeystroke[] | null
>(null)
const chordTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const pendingChordRef = useRef<ParsedKeystroke[] | null>(null);
const [pendingChord, setPendingChordState] = useState<ParsedKeystroke[] | null>(null);
const chordTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Handler registry for action callbacks (used by ChordInterceptor to invoke handlers)
const handlerRegistryRef = useRef(
new Map<
string,
Set<{
action: string
context: KeybindingContextName
handler: () => void
action: string;
context: KeybindingContextName;
handler: () => void;
}>
>(),
)
);
// Active context tracking for keybinding priority resolution
const activeContextsRef = useRef<Set<KeybindingContextName>>(new Set())
const activeContextsRef = useRef<Set<KeybindingContextName>>(new Set());
const registerActiveContext = useCallback(
(context: KeybindingContextName) => {
activeContextsRef.current.add(context)
},
[],
)
const registerActiveContext = useCallback((context: KeybindingContextName) => {
activeContextsRef.current.add(context);
}, []);
const unregisterActiveContext = useCallback(
(context: KeybindingContextName) => {
activeContextsRef.current.delete(context)
},
[],
)
const unregisterActiveContext = useCallback((context: KeybindingContextName) => {
activeContextsRef.current.delete(context);
}, []);
// Clear chord timeout when component unmounts or chord changes
const clearChordTimeout = useCallback(() => {
if (chordTimeoutRef.current) {
clearTimeout(chordTimeoutRef.current)
chordTimeoutRef.current = null
clearTimeout(chordTimeoutRef.current);
chordTimeoutRef.current = null;
}
}, [])
}, []);
// Wrapper for setPendingChord that manages timeout and syncs ref+state
const setPendingChord = useCallback(
(pending: ParsedKeystroke[] | null) => {
clearChordTimeout()
clearChordTimeout();
if (pending !== null) {
// Set timeout to cancel chord if not completed
chordTimeoutRef.current = setTimeout(
(pendingChordRef, setPendingChordState) => {
onDebugLog?.('[keybindings] Chord timeout - cancelling')
pendingChordRef.current = null
setPendingChordState(null)
onDebugLog?.('[keybindings] Chord timeout - cancelling');
pendingChordRef.current = null;
setPendingChordState(null);
},
CHORD_TIMEOUT_MS,
pendingChordRef,
setPendingChordState,
)
);
}
// Update ref immediately for synchronous access in resolve()
pendingChordRef.current = pending
pendingChordRef.current = pending;
// Update state to trigger re-renders for UI updates
setPendingChordState(pending)
setPendingChordState(pending);
},
[clearChordTimeout, onDebugLog],
)
);
useEffect(() => {
// Initialize file watcher (idempotent - only runs once)
void initWatcher?.()
void initWatcher?.();
// Subscribe to changes
const unsubscribe = subscribeToChanges(result => {
// Any callback invocation is a reload since initial load happens
// synchronously in useState, not via this subscription
setIsReload(true)
setIsReload(true);
setLoadResult(result)
onDebugLog?.(
`[keybindings] Reloaded: ${result.bindings.length} bindings, ${result.warnings.length} warnings`,
)
})
setLoadResult(result);
onDebugLog?.(`[keybindings] Reloaded: ${result.bindings.length} bindings, ${result.warnings.length} warnings`);
});
return () => {
unsubscribe()
clearChordTimeout()
}
}, [subscribeToChanges, initWatcher, clearChordTimeout, onDebugLog])
unsubscribe();
clearChordTimeout();
};
}, [subscribeToChanges, initWatcher, clearChordTimeout, onDebugLog]);
return (
<KeybindingProvider
@@ -189,7 +177,7 @@ export function KeybindingSetup({
/>
{children}
</KeybindingProvider>
)
);
}
/**
@@ -203,10 +191,10 @@ export function KeybindingSetup({
* system could recognize it as completing a chord.
*/
type HandlerRegistration = {
action: string
context: KeybindingContextName
handler: () => void
}
action: string;
context: KeybindingContextName;
handler: () => void;
};
function ChordInterceptor({
bindings,
@@ -215,11 +203,11 @@ function ChordInterceptor({
activeContexts,
handlerRegistryRef,
}: {
bindings: ParsedBinding[]
pendingChordRef: React.RefObject<ParsedKeystroke[] | null>
setPendingChord: (pending: ParsedKeystroke[] | null) => void
activeContexts: Set<KeybindingContextName>
handlerRegistryRef: React.RefObject<Map<string, Set<HandlerRegistration>>>
bindings: ParsedBinding[];
pendingChordRef: React.RefObject<ParsedKeystroke[] | null>;
setPendingChord: (pending: ParsedKeystroke[] | null) => void;
activeContexts: Set<KeybindingContextName>;
handlerRegistryRef: React.RefObject<Map<string, Set<HandlerRegistration>>>;
}): null {
const handleInput = useCallback(
(input: string, key: Key, event: InputEvent) => {
@@ -228,94 +216,78 @@ function ChordInterceptor({
// here. Skip the registry scan. Mid-chord wheel still falls through so
// scrolling cancels the pending chord like any other non-matching key.
if ((key.wheelUp || key.wheelDown) && pendingChordRef.current === null) {
return
return;
}
// Build context list from registered handlers + activeContexts + Global
const registry = handlerRegistryRef.current
const handlerContexts = new Set<KeybindingContextName>()
const registry = handlerRegistryRef.current;
const handlerContexts = new Set<KeybindingContextName>();
if (registry) {
for (const handlers of registry.values()) {
for (const registration of handlers) {
handlerContexts.add(registration.context)
handlerContexts.add(registration.context);
}
}
}
const contexts: KeybindingContextName[] = [
...handlerContexts,
...activeContexts,
'Global',
]
const contexts: KeybindingContextName[] = [...handlerContexts, ...activeContexts, 'Global'];
// Track whether we're completing a chord (pending was non-null)
const wasInChord = pendingChordRef.current !== null
const wasInChord = pendingChordRef.current !== null;
// Check if this keystroke is part of a chord sequence
const result = resolveKeyWithChordState(
input,
key,
contexts,
bindings,
pendingChordRef.current,
)
const result = resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current);
switch (result.type) {
case 'chord_started':
// This key starts a chord - store pending state and stop propagation
setPendingChord(result.pending)
event.stopImmediatePropagation()
break
setPendingChord(result.pending);
event.stopImmediatePropagation();
break;
case 'match': {
// Clear pending state
setPendingChord(null)
setPendingChord(null);
// Only invoke handlers and stop propagation for chord completions
// (multi-keystroke sequences). Single-keystroke matches should propagate
// to per-hook handlers to avoid interfering with other input handling.
if (wasInChord) {
const contextsSet = new Set(contexts)
const contextsSet = new Set(contexts);
if (registry) {
const handlers = registry.get(result.action)
const handlers = registry.get(result.action);
if (handlers && handlers.size > 0) {
for (const registration of handlers) {
if (contextsSet.has(registration.context)) {
registration.handler()
event.stopImmediatePropagation()
break
registration.handler();
event.stopImmediatePropagation();
break;
}
}
}
}
}
break
break;
}
case 'chord_cancelled':
setPendingChord(null)
event.stopImmediatePropagation()
break
setPendingChord(null);
event.stopImmediatePropagation();
break;
case 'unbound':
setPendingChord(null)
event.stopImmediatePropagation()
break
setPendingChord(null);
event.stopImmediatePropagation();
break;
case 'none':
// No chord involvement - let other handlers process
break
break;
}
},
[
bindings,
pendingChordRef,
setPendingChord,
activeContexts,
handlerRegistryRef,
],
)
[bindings, pendingChordRef, setPendingChord, activeContexts, handlerRegistryRef],
);
useInput(handleInput)
useInput(handleInput);
return null
return null;
}

View File

@@ -1,10 +1,10 @@
import React, { Children, isValidElement } from 'react'
import { Text } from '../index.js'
import React, { Children, isValidElement } from 'react';
import { Text } from '../index.js';
type Props = {
/** The items to join with a middot separator */
children: React.ReactNode
}
children: React.ReactNode;
};
/**
* Joins children with a middot separator (" · ") for inline metadata display.
@@ -36,22 +36,20 @@ type Props = {
*/
export function Byline({ children }: Props): React.ReactNode {
// Children.toArray already filters out null, undefined, and booleans
const validChildren = Children.toArray(children)
const validChildren = Children.toArray(children);
if (validChildren.length === 0) {
return null
return null;
}
return (
<>
{validChildren.map((child, index) => (
<React.Fragment
key={isValidElement(child) ? (child.key ?? index) : index}
>
<React.Fragment key={isValidElement(child) ? (child.key ?? index) : index}>
{index > 0 && <Text dimColor> · </Text>}
{child}
</React.Fragment>
))}
</>
)
);
}

View File

@@ -6,30 +6,18 @@
* internal theme components.
*/
import React from 'react'
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'
import React from 'react';
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
type Props = {
action: string
context: string
fallback: string
description: string
parens?: boolean
bold?: boolean
}
action: string;
context: string;
fallback: string;
description: string;
parens?: boolean;
bold?: boolean;
};
export function ConfigurableShortcutHint({
fallback,
description,
parens,
bold,
}: Props): React.ReactNode {
return (
<KeyboardShortcutHint
shortcut={fallback}
action={description}
parens={parens}
bold={bold}
/>
)
export function ConfigurableShortcutHint({ fallback, description, parens, bold }: Props): React.ReactNode {
return <KeyboardShortcutHint shortcut={fallback} action={description} parens={parens} bold={bold} />;
}

View File

@@ -1,26 +1,23 @@
import React from 'react'
import {
type ExitState,
useExitOnCtrlCDWithKeybindings,
} from '../hooks/useExitOnCtrlCD.js'
import { Box, Text } from '../index.js'
import { useKeybinding } from '../keybindings/useKeybinding.js'
import type { Theme } from './theme-types.js'
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'
import { Byline } from './Byline.js'
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'
import { Pane } from './Pane.js'
import React from 'react';
import { type ExitState, useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCD.js';
import { Box, Text } from '../index.js';
import { useKeybinding } from '../keybindings/useKeybinding.js';
import type { Theme } from './theme-types.js';
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js';
import { Byline } from './Byline.js';
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
import { Pane } from './Pane.js';
type DialogProps = {
title: React.ReactNode
subtitle?: React.ReactNode
children: React.ReactNode
onCancel: () => void
color?: keyof Theme
hideInputGuide?: boolean
hideBorder?: boolean
title: React.ReactNode;
subtitle?: React.ReactNode;
children: React.ReactNode;
onCancel: () => void;
color?: keyof Theme;
hideInputGuide?: boolean;
hideBorder?: boolean;
/** Custom input guide content. Receives exitState for Ctrl+C/D pending display. */
inputGuide?: (exitState: ExitState) => React.ReactNode
inputGuide?: (exitState: ExitState) => React.ReactNode;
/**
* Controls whether Dialog's built-in confirm:no (Esc/n) and app:exit/interrupt
* (Ctrl-C/D) keybindings are active. Set to `false` while an embedded text
@@ -28,8 +25,8 @@ type DialogProps = {
* consumed by Dialog. TextInput has its own ctrl+c/d handlers (cancel on
* press, delete-forward on ctrl+d with text). Defaults to `true`.
*/
isCancelActive?: boolean
}
isCancelActive?: boolean;
};
export function Dialog({
title,
@@ -42,11 +39,7 @@ export function Dialog({
inputGuide,
isCancelActive = true,
}: DialogProps): React.ReactNode {
const exitState = useExitOnCtrlCDWithKeybindings(
undefined,
undefined,
isCancelActive,
)
const exitState = useExitOnCtrlCDWithKeybindings(undefined, undefined, isCancelActive);
// Use configurable keybinding for ESC to cancel.
// isCancelActive lets consumers (e.g. ElicitationDialog) disable this while
@@ -55,21 +48,16 @@ export function Dialog({
useKeybinding('confirm:no', onCancel, {
context: 'Confirmation',
isActive: isCancelActive,
})
});
const defaultInputGuide = exitState.pending ? (
<Text>Press {exitState.keyName} again to exit</Text>
) : (
<Byline>
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
<ConfigurableShortcutHint
action="confirm:no"
context="Confirmation"
fallback="Esc"
description="cancel"
/>
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />
</Byline>
)
);
const content = (
<>
@@ -90,11 +78,11 @@ export function Dialog({
</Box>
)}
</>
)
);
if (hideBorder) {
return content
return content;
}
return <Pane color={color}>{content}</Pane>
return <Pane color={color}>{content}</Pane>;
}

View File

@@ -1,33 +1,33 @@
import React from 'react'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { stringWidth } from '../core/stringWidth.js'
import { Ansi, Text } from '../index.js'
import type { Theme } from './theme-types.js'
import React from 'react';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { stringWidth } from '../core/stringWidth.js';
import { Ansi, Text } from '../index.js';
import type { Theme } from './theme-types.js';
type DividerProps = {
/**
* Width of the divider in characters.
* Defaults to terminal width.
*/
width?: number
width?: number;
/**
* Theme color for the divider.
* If not provided, dimColor is used.
*/
color?: keyof Theme
color?: keyof Theme;
/**
* Character to use for the divider line.
* @default '─'
*/
char?: string
char?: string;
/**
* Padding to subtract from the width (e.g., for indentation).
* @default 0
*/
padding?: number
padding?: number;
/**
* Title shown in the middle of the divider.
@@ -37,8 +37,8 @@ type DividerProps = {
* // ─────────── Title ───────────
* <Divider title="Title" />
*/
title?: string
}
title?: string;
};
/**
* A horizontal divider line.
@@ -63,21 +63,15 @@ type DividerProps = {
* // With centered title
* <Divider title="3 new messages" />
*/
export function Divider({
width,
color,
char = '─',
padding = 0,
title,
}: DividerProps): React.ReactNode {
const { columns: terminalWidth } = useTerminalSize()
const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding)
export function Divider({ width, color, char = '─', padding = 0, title }: DividerProps): React.ReactNode {
const { columns: terminalWidth } = useTerminalSize();
const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding);
if (title) {
const titleWidth = stringWidth(title) + 2 // +2 for spaces around title
const sideWidth = Math.max(0, effectiveWidth - titleWidth)
const leftWidth = Math.floor(sideWidth / 2)
const rightWidth = sideWidth - leftWidth
const titleWidth = stringWidth(title) + 2; // +2 for spaces around title
const sideWidth = Math.max(0, effectiveWidth - titleWidth);
const leftWidth = Math.floor(sideWidth / 2);
const rightWidth = sideWidth - leftWidth;
return (
<Text color={color} dimColor={!color}>
{char.repeat(leftWidth)}{' '}
@@ -86,12 +80,12 @@ export function Divider({
</Text>{' '}
{char.repeat(rightWidth)}
</Text>
)
);
}
return (
<Text color={color} dimColor={!color}>
{char.repeat(effectiveWidth)}
</Text>
)
);
}

View File

@@ -1,72 +1,72 @@
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useSearchInput } from '../hooks/useSearchInput.js'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
import { clamp } from '../core/layout/geometry.js'
import { Box, Text, useTerminalFocus } from '../index.js'
import { SearchBox } from './SearchBox.js'
import { Byline } from './Byline.js'
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'
import { ListItem } from './ListItem.js'
import { Pane } from './Pane.js'
import * as React from 'react';
import { useEffect, useState } from 'react';
import { useSearchInput } from '../hooks/useSearchInput.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import type { KeyboardEvent } from '../core/events/keyboard-event.js';
import { clamp } from '../core/layout/geometry.js';
import { Box, Text, useTerminalFocus } from '../index.js';
import { SearchBox } from './SearchBox.js';
import { Byline } from './Byline.js';
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
import { ListItem } from './ListItem.js';
import { Pane } from './Pane.js';
type PickerAction<T> = {
/** Hint label shown in the byline, e.g. "mention" → "Tab to mention". */
action: string
handler: (item: T) => void
}
action: string;
handler: (item: T) => void;
};
type Props<T> = {
title: string
placeholder?: string
initialQuery?: string
items: readonly T[]
getKey: (item: T) => string
title: string;
placeholder?: string;
initialQuery?: string;
items: readonly T[];
getKey: (item: T) => string;
/** Keep to one line — preview handles overflow. */
renderItem: (item: T, isFocused: boolean) => React.ReactNode
renderPreview?: (item: T) => React.ReactNode
renderItem: (item: T, isFocused: boolean) => React.ReactNode;
renderPreview?: (item: T) => React.ReactNode;
/** 'right' keeps hints stable (no bounce), but needs width. */
previewPosition?: 'bottom' | 'right'
visibleCount?: number
previewPosition?: 'bottom' | 'right';
visibleCount?: number;
/**
* 'up' puts items[0] at the bottom next to the input (atuin-style). Arrows
* always match screen direction — ↑ walks visually up regardless.
*/
direction?: 'down' | 'up'
direction?: 'down' | 'up';
/** Caller owns filtering: re-filter on each call and pass new items. */
onQueryChange: (query: string) => void
onQueryChange: (query: string) => void;
/** Enter key. Primary action. */
onSelect: (item: T) => void
onSelect: (item: T) => void;
/**
* Tab key. If provided, Tab no longer aliases Enter — it gets its own
* handler and hint. Shift+Tab falls through to this if onShiftTab is unset.
*/
onTab?: PickerAction<T>
onTab?: PickerAction<T>;
/** Shift+Tab key. Gets its own hint. */
onShiftTab?: PickerAction<T>
onShiftTab?: PickerAction<T>;
/**
* Fires when the focused item changes (via arrows or when items reset).
* Useful for async preview loading — keeps I/O out of renderPreview.
*/
onFocus?: (item: T | undefined) => void
onCancel: () => void
onFocus?: (item: T | undefined) => void;
onCancel: () => void;
/** Shown when items is empty. Caller bakes loading/searching state into this. */
emptyMessage?: string | ((query: string) => string)
emptyMessage?: string | ((query: string) => string);
/**
* Status line below the list, e.g. "500+ matches" or "42 matches…".
* Caller decides when to show it — pass undefined to hide.
*/
matchLabel?: string
selectAction?: string
extraHints?: React.ReactNode
}
matchLabel?: string;
selectAction?: string;
extraHints?: React.ReactNode;
};
const DEFAULT_VISIBLE = 8
const DEFAULT_VISIBLE = 8;
// Pane (paddingTop + Divider) + title + 3 gaps + SearchBox (rounded border = 3
// rows) + hints. matchLabel adds +1 when present, accounted for separately.
const CHROME_ROWS = 10
const MIN_VISIBLE = 2
const CHROME_ROWS = 10;
const MIN_VISIBLE = 2;
export function FuzzyPicker<T>({
title,
@@ -90,25 +90,22 @@ export function FuzzyPicker<T>({
selectAction = 'select',
extraHints,
}: Props<T>): React.ReactNode {
const isTerminalFocused = useTerminalFocus()
const { rows, columns } = useTerminalSize()
const [focusedIndex, setFocusedIndex] = useState(0)
const isTerminalFocused = useTerminalFocus();
const { rows, columns } = useTerminalSize();
const [focusedIndex, setFocusedIndex] = useState(0);
// Cap visibleCount so the picker never exceeds the terminal height. When it
// overflows, each re-render (arrow key, ctrl+p) mis-positions the cursor-up
// by the overflow amount and a previously-drawn line flashes blank.
const visibleCount = Math.max(
MIN_VISIBLE,
Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0)),
)
const visibleCount = Math.max(MIN_VISIBLE, Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0)));
// Full hint row with onTab+onShiftTab is ~100 chars and wraps inconsistently
// below that. Compact mode drops shift+tab and shortens labels.
const compact = columns < 120
const compact = columns < 120;
const step = (delta: 1 | -1) => {
setFocusedIndex(i => clamp(i + delta, 0, items.length - 1))
}
setFocusedIndex(i => clamp(i + delta, 0, items.length - 1));
};
// onKeyDown fires after useSearchInput's useInput, so onExit must be a
// no-op — return/downArrow are handled by handleKeyDown below. onCancel
@@ -120,67 +117,62 @@ export function FuzzyPicker<T>({
onCancel,
initialQuery,
backspaceExitsOnEmpty: false,
})
});
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'up' || (e.ctrl && e.key === 'p')) {
e.preventDefault()
e.stopImmediatePropagation()
step(direction === 'up' ? 1 : -1)
return
e.preventDefault();
e.stopImmediatePropagation();
step(direction === 'up' ? 1 : -1);
return;
}
if (e.key === 'down' || (e.ctrl && e.key === 'n')) {
e.preventDefault()
e.stopImmediatePropagation()
step(direction === 'up' ? -1 : 1)
return
e.preventDefault();
e.stopImmediatePropagation();
step(direction === 'up' ? -1 : 1);
return;
}
if (e.key === 'return') {
e.preventDefault()
e.stopImmediatePropagation()
const selected = items[focusedIndex]
if (selected) onSelect(selected)
return
e.preventDefault();
e.stopImmediatePropagation();
const selected = items[focusedIndex];
if (selected) onSelect(selected);
return;
}
if (e.key === 'tab') {
e.preventDefault()
e.stopImmediatePropagation()
const selected = items[focusedIndex]
if (!selected) return
const tabAction = e.shift ? (onShiftTab ?? onTab) : onTab
e.preventDefault();
e.stopImmediatePropagation();
const selected = items[focusedIndex];
if (!selected) return;
const tabAction = e.shift ? (onShiftTab ?? onTab) : onTab;
if (tabAction) {
tabAction.handler(selected)
tabAction.handler(selected);
} else {
onSelect(selected)
onSelect(selected);
}
}
}
};
useEffect(() => {
onQueryChange(query)
setFocusedIndex(0)
onQueryChange(query);
setFocusedIndex(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
}, [query]);
useEffect(() => {
setFocusedIndex(i => clamp(i, 0, items.length - 1))
}, [items.length])
setFocusedIndex(i => clamp(i, 0, items.length - 1));
}, [items.length]);
const focused = items[focusedIndex]
const focused = items[focusedIndex];
useEffect(() => {
onFocus?.(focused)
onFocus?.(focused);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focused])
}, [focused]);
const windowStart = clamp(
focusedIndex - visibleCount + 1,
0,
items.length - visibleCount,
)
const visible = items.slice(windowStart, windowStart + visibleCount)
const windowStart = clamp(focusedIndex - visibleCount + 1, 0, items.length - visibleCount);
const visible = items.slice(windowStart, windowStart + visibleCount);
const emptyText =
typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage
const emptyText = typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage;
const searchBox = (
<SearchBox
@@ -190,7 +182,7 @@ export function FuzzyPicker<T>({
isFocused
isTerminalFocused={isTerminalFocused}
/>
)
);
const listBlock = (
<List
@@ -204,25 +196,21 @@ export function FuzzyPicker<T>({
renderItem={renderItem}
emptyText={emptyText}
/>
)
);
const preview =
renderPreview && focused ? (
<Box flexDirection="column" flexGrow={1}>
{renderPreview(focused)}
</Box>
) : null
) : null;
// Structure must not depend on preview truthiness — when focused goes
// undefined (e.g. delete clears matches), switching row→fragment would
// change both layout AND gap count, bouncing the searchBox below.
const listGroup =
renderPreview && previewPosition === 'right' ? (
<Box
flexDirection="row"
gap={2}
height={visibleCount + (matchLabel ? 1 : 0)}
>
<Box flexDirection="row" gap={2} height={visibleCount + (matchLabel ? 1 : 0)}>
<Box flexDirection="column" flexShrink={0}>
{listBlock}
{matchLabel && <Text dimColor>{matchLabel}</Text>}
@@ -238,18 +226,12 @@ export function FuzzyPicker<T>({
{matchLabel && <Text dimColor>{matchLabel}</Text>}
{preview}
</Box>
)
);
const inputAbove = direction !== 'up'
const inputAbove = direction !== 'up';
return (
<Pane color="permission">
<Box
flexDirection="column"
gap={1}
tabIndex={0}
autoFocus
onKeyDown={handleKeyDown}
>
<Box flexDirection="column" gap={1} tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
<Text bold color="permission">
{title}
</Text>
@@ -258,42 +240,26 @@ export function FuzzyPicker<T>({
{!inputAbove && searchBox}
<Text dimColor>
<Byline>
<KeyboardShortcutHint
shortcut="↑/↓"
action={compact ? 'nav' : 'navigate'}
/>
<KeyboardShortcutHint
shortcut="Enter"
action={compact ? firstWord(selectAction) : selectAction}
/>
{onTab && (
<KeyboardShortcutHint shortcut="Tab" action={onTab.action} />
)}
{onShiftTab && !compact && (
<KeyboardShortcutHint
shortcut="shift+tab"
action={onShiftTab.action}
/>
)}
<KeyboardShortcutHint shortcut="↑/↓" action={compact ? 'nav' : 'navigate'} />
<KeyboardShortcutHint shortcut="Enter" action={compact ? firstWord(selectAction) : selectAction} />
{onTab && <KeyboardShortcutHint shortcut="Tab" action={onTab.action} />}
{onShiftTab && !compact && <KeyboardShortcutHint shortcut="shift+tab" action={onShiftTab.action} />}
<KeyboardShortcutHint shortcut="Esc" action="cancel" />
{extraHints}
</Byline>
</Text>
</Box>
</Pane>
)
);
}
type ListProps<T> = Pick<
Props<T>,
'visibleCount' | 'direction' | 'getKey' | 'renderItem'
> & {
visible: readonly T[]
windowStart: number
total: number
focusedIndex: number
emptyText: string
}
type ListProps<T> = Pick<Props<T>, 'visibleCount' | 'direction' | 'getKey' | 'renderItem'> & {
visible: readonly T[];
windowStart: number;
total: number;
focusedIndex: number;
emptyText: string;
};
function List<T>({
visible,
@@ -311,15 +277,14 @@ function List<T>({
<Box height={visibleCount} flexShrink={0}>
<Text dimColor>{emptyText}</Text>
</Box>
)
);
}
const rows = visible.map((item, i) => {
const actualIndex = windowStart + i
const isFocused = actualIndex === focusedIndex
const atLowEdge = i === 0 && windowStart > 0
const atHighEdge =
i === visible.length - 1 && windowStart + visibleCount! < total
const actualIndex = windowStart + i;
const isFocused = actualIndex === focusedIndex;
const atLowEdge = i === 0 && windowStart > 0;
const atHighEdge = i === visible.length - 1 && windowStart + visibleCount! < total;
return (
<ListItem
key={getKey(item)}
@@ -330,21 +295,17 @@ function List<T>({
>
{renderItem(item, isFocused)}
</ListItem>
)
})
);
});
return (
<Box
height={visibleCount}
flexShrink={0}
flexDirection={direction === 'up' ? 'column-reverse' : 'column'}
>
<Box height={visibleCount} flexShrink={0} flexDirection={direction === 'up' ? 'column-reverse' : 'column'}>
{rows}
</Box>
)
);
}
function firstWord(s: string): string {
const i = s.indexOf(' ')
return i === -1 ? s : s.slice(0, i)
const i = s.indexOf(' ');
return i === -1 ? s : s.slice(0, i);
}

View File

@@ -1,16 +1,16 @@
import React from 'react'
import Text from '../components/Text.js'
import React from 'react';
import Text from '../components/Text.js';
type Props = {
/** The key or chord to display (e.g., "ctrl+o", "Enter", "↑/↓") */
shortcut: string
shortcut: string;
/** The action the key performs (e.g., "expand", "select", "navigate") */
action: string
action: string;
/** Whether to wrap the hint in parentheses. Default: false */
parens?: boolean
parens?: boolean;
/** Whether to render the shortcut in bold. Default: false */
bold?: boolean
}
bold?: boolean;
};
/**
* Renders a keyboard shortcut hint like "ctrl+o to expand" or "(tab to toggle)"
@@ -35,24 +35,19 @@ type Props = {
* </Byline>
* </Text>
*/
export function KeyboardShortcutHint({
shortcut,
action,
parens = false,
bold = false,
}: Props): React.ReactNode {
const shortcutText = bold ? <Text bold>{shortcut}</Text> : shortcut
export function KeyboardShortcutHint({ shortcut, action, parens = false, bold = false }: Props): React.ReactNode {
const shortcutText = bold ? <Text bold>{shortcut}</Text> : shortcut;
if (parens) {
return (
<Text>
({shortcutText} to {action})
</Text>
)
);
}
return (
<Text>
{shortcutText} to {action}
</Text>
)
);
}

View File

@@ -1,44 +1,44 @@
import figures from 'figures'
import type { ReactNode } from 'react'
import React from 'react'
import { useDeclaredCursor } from '../hooks/use-declared-cursor.js'
import { Box, Text } from '../index.js'
import figures from 'figures';
import type { ReactNode } from 'react';
import React from 'react';
import { useDeclaredCursor } from '../hooks/use-declared-cursor.js';
import { Box, Text } from '../index.js';
type ListItemProps = {
/**
* Whether this item is currently focused (keyboard selection).
* Shows the pointer indicator () when true.
*/
isFocused: boolean
isFocused: boolean;
/**
* Whether this item is selected (chosen/checked).
* Shows the checkmark indicator (✓) when true.
* @default false
*/
isSelected?: boolean
isSelected?: boolean;
/**
* The content to display for this item.
*/
children: ReactNode
children: ReactNode;
/**
* Optional description text displayed below the main content.
*/
description?: string
description?: string;
/**
* Show a down arrow indicator instead of pointer (for scroll hints).
* Only applies when not focused.
*/
showScrollDown?: boolean
showScrollDown?: boolean;
/**
* Show an up arrow indicator instead of pointer (for scroll hints).
* Only applies when not focused.
*/
showScrollUp?: boolean
showScrollUp?: boolean;
/**
* Whether to apply automatic styling to the children based on focus/selection state.
@@ -46,21 +46,21 @@ type ListItemProps = {
* - When false: children are rendered as-is, allowing custom styling
* @default true
*/
styled?: boolean
styled?: boolean;
/**
* Whether this item is disabled. Disabled items show dimmed text and no indicators.
* @default false
*/
disabled?: boolean
disabled?: boolean;
/**
* Whether this ListItem should declare the terminal cursor position.
* Set false when a child (e.g. BaseTextInput) declares its own cursor.
* @default true
*/
declareCursor?: boolean
}
declareCursor?: boolean;
};
/**
* A list item component for selection UIs (dropdowns, multi-selects, menus).
@@ -115,46 +115,46 @@ export function ListItem({
// Determine which indicator to show
function renderIndicator(): ReactNode {
if (disabled) {
return <Text> </Text>
return <Text> </Text>;
}
if (isFocused) {
return <Text color="suggestion">{figures.pointer}</Text>
return <Text color="suggestion">{figures.pointer}</Text>;
}
if (showScrollDown) {
return <Text dimColor>{figures.arrowDown}</Text>
return <Text dimColor>{figures.arrowDown}</Text>;
}
if (showScrollUp) {
return <Text dimColor>{figures.arrowUp}</Text>
return <Text dimColor>{figures.arrowUp}</Text>;
}
return <Text> </Text>
return <Text> </Text>;
}
// Determine text color based on state
function getTextColor(): 'success' | 'suggestion' | 'inactive' | undefined {
if (disabled) {
return 'inactive'
return 'inactive';
}
if (!styled) {
return undefined
return undefined;
}
if (isSelected) {
return 'success'
return 'success';
}
if (isFocused) {
return 'suggestion'
return 'suggestion';
}
return undefined
return undefined;
}
const textColor = getTextColor()
const textColor = getTextColor();
// Park the native terminal cursor on the pointer indicator so screen
// readers / magnifiers track the focused item. (0,0) is the top-left of
@@ -163,7 +163,7 @@ export function ListItem({
line: 0,
column: 0,
active: isFocused && !disabled && declareCursor !== false,
})
});
return (
<Box ref={cursorRef} flexDirection="column">
@@ -184,5 +184,5 @@ export function ListItem({
</Box>
)}
</Box>
)
);
}

View File

@@ -1,30 +1,30 @@
import React from 'react'
import { Box, Text } from '../index.js'
import { Spinner } from './Spinner.js'
import React from 'react';
import { Box, Text } from '../index.js';
import { Spinner } from './Spinner.js';
type LoadingStateProps = {
/**
* The loading message to display next to the spinner.
*/
message: string
message: string;
/**
* Display the message in bold.
* @default false
*/
bold?: boolean
bold?: boolean;
/**
* Display the message in dimmed color.
* @default false
*/
dimColor?: boolean
dimColor?: boolean;
/**
* Optional subtitle displayed below the main message.
*/
subtitle?: string
}
subtitle?: string;
};
/**
* A spinner with loading message for async operations.
@@ -62,5 +62,5 @@ export function LoadingState({
</Box>
{subtitle && <Text dimColor>{subtitle}</Text>}
</Box>
)
);
}

View File

@@ -1,16 +1,16 @@
import React from 'react'
import { useIsInsideModal } from './modalContext.js'
import { Box } from '../index.js'
import type { Theme } from './theme-types.js'
import { Divider } from './Divider.js'
import React from 'react';
import { useIsInsideModal } from './modalContext.js';
import { Box } from '../index.js';
import type { Theme } from './theme-types.js';
import { Divider } from './Divider.js';
type PaneProps = {
children: React.ReactNode
children: React.ReactNode;
/**
* Theme color for the top border line.
*/
color?: keyof Theme
}
color?: keyof Theme;
};
/**
* A pane — a region of the terminal that appears below the REPL prompt,
@@ -44,7 +44,7 @@ export function Pane({ children, color }: PaneProps): React.ReactNode {
<Box flexDirection="column" paddingX={1} flexShrink={0}>
{children}
</Box>
)
);
}
return (
<Box flexDirection="column" paddingTop={1}>
@@ -53,5 +53,5 @@ export function Pane({ children, color }: PaneProps): React.ReactNode {
{children}
</Box>
</Box>
)
);
}

View File

@@ -1,48 +1,43 @@
import React from 'react'
import { Text } from '../index.js'
import type { Theme } from './theme-types.js'
import React from 'react';
import { Text } from '../index.js';
import type { Theme } from './theme-types.js';
type Props = {
/**
* How much progress to display, between 0 and 1 inclusive
*/
ratio: number // [0, 1]
ratio: number; // [0, 1]
/**
* How many characters wide to draw the progress bar
*/
width: number // how many characters wide
width: number; // how many characters wide
/**
* Optional color for the filled portion of the bar
*/
fillColor?: keyof Theme
fillColor?: keyof Theme;
/**
* Optional color for the empty portion of the bar
*/
emptyColor?: keyof Theme
}
emptyColor?: keyof Theme;
};
const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█']
const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
export function ProgressBar({
ratio: inputRatio,
width,
fillColor,
emptyColor,
}: Props): React.ReactNode {
const ratio = Math.min(1, Math.max(0, inputRatio))
const whole = Math.floor(ratio * width)
const segments = [BLOCKS[BLOCKS.length - 1]!.repeat(whole)]
export function ProgressBar({ ratio: inputRatio, width, fillColor, emptyColor }: Props): React.ReactNode {
const ratio = Math.min(1, Math.max(0, inputRatio));
const whole = Math.floor(ratio * width);
const segments = [BLOCKS[BLOCKS.length - 1]!.repeat(whole)];
if (whole < width) {
const remainder = ratio * width - whole
const middle = Math.floor(remainder * BLOCKS.length)
segments.push(BLOCKS[middle]!)
const remainder = ratio * width - whole;
const middle = Math.floor(remainder * BLOCKS.length);
segments.push(BLOCKS[middle]!);
const empty = width - whole - 1
const empty = width - whole - 1;
if (empty > 0) {
segments.push(BLOCKS[0]!.repeat(empty))
segments.push(BLOCKS[0]!.repeat(empty));
}
}
@@ -50,5 +45,5 @@ export function ProgressBar({
<Text color={fillColor} backgroundColor={emptyColor}>
{segments.join('')}
</Text>
)
);
}

View File

@@ -1,39 +1,39 @@
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { useTerminalViewport } from '../hooks/use-terminal-viewport.js'
import { Box, type DOMElement, measureElement } from '../index.js'
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useTerminalViewport } from '../hooks/use-terminal-viewport.js';
import { Box, type DOMElement, measureElement } from '../index.js';
type Props = {
children: React.ReactNode
lock?: 'always' | 'offscreen'
}
children: React.ReactNode;
lock?: 'always' | 'offscreen';
};
export function Ratchet({ children, lock = 'always' }: Props): React.ReactNode {
const [viewportRef, { isVisible }] = useTerminalViewport()
const { rows } = useTerminalSize()
const innerRef = useRef<DOMElement | null>(null)
const maxHeight = useRef(0)
const [minHeight, setMinHeight] = useState(0)
const [viewportRef, { isVisible }] = useTerminalViewport();
const { rows } = useTerminalSize();
const innerRef = useRef<DOMElement | null>(null);
const maxHeight = useRef(0);
const [minHeight, setMinHeight] = useState(0);
const outerRef = useCallback(
(el: DOMElement | null) => {
viewportRef(el)
viewportRef(el);
},
[viewportRef],
)
);
const engaged = lock === 'always' || !isVisible
const engaged = lock === 'always' || !isVisible;
useLayoutEffect(() => {
if (!innerRef.current) {
return
return;
}
const { height } = measureElement(innerRef.current)
const { height } = measureElement(innerRef.current);
if (height > maxHeight.current) {
maxHeight.current = Math.min(height, rows)
setMinHeight(maxHeight.current)
maxHeight.current = Math.min(height, rows);
setMinHeight(maxHeight.current);
}
})
});
return (
<Box minHeight={engaged ? minHeight : undefined} ref={outerRef}>
@@ -41,5 +41,5 @@ export function Ratchet({ children, lock = 'always' }: Props): React.ReactNode {
{children}
</Box>
</Box>
)
);
}

View File

@@ -1,16 +1,16 @@
import React from 'react'
import { Box, Text } from '../index.js'
import React from 'react';
import { Box, Text } from '../index.js';
type Props = {
query: string
placeholder?: string
isFocused: boolean
isTerminalFocused: boolean
prefix?: string
width?: number | string
cursorOffset?: number
borderless?: boolean
}
query: string;
placeholder?: string;
isFocused: boolean;
isTerminalFocused: boolean;
prefix?: string;
width?: number | string;
cursorOffset?: number;
borderless?: boolean;
};
export function SearchBox({
query,
@@ -22,7 +22,7 @@ export function SearchBox({
cursorOffset,
borderless = false,
}: Props): React.ReactNode {
const offset = cursorOffset ?? query.length
const offset = cursorOffset ?? query.length;
return (
<Box
@@ -41,12 +41,8 @@ export function SearchBox({
isTerminalFocused ? (
<>
<Text>{query.slice(0, offset)}</Text>
<Text inverse>
{offset < query.length ? query[offset] : ' '}
</Text>
{offset < query.length && (
<Text>{query.slice(offset + 1)}</Text>
)}
<Text inverse>{offset < query.length ? query[offset] : ' '}</Text>
{offset < query.length && <Text>{query.slice(offset + 1)}</Text>}
</>
) : (
<Text>{query}</Text>
@@ -67,5 +63,5 @@ export function SearchBox({
)}
</Text>
</Box>
)
);
}

View File

@@ -1,20 +1,20 @@
import React, { useState, useEffect } from 'react'
import { Text } from '../index.js'
import React, { useState, useEffect } from 'react';
import { Text } from '../index.js';
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
/**
* A simple animated spinner for loading states.
*/
export function Spinner(): React.ReactNode {
const [frame, setFrame] = useState(0)
const [frame, setFrame] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setFrame(f => (f + 1) % FRAMES.length)
}, 80)
return () => clearInterval(timer)
}, [])
setFrame(f => (f + 1) % FRAMES.length);
}, 80);
return () => clearInterval(timer);
}, []);
return <Text>{FRAMES[frame]}</Text>
return <Text>{FRAMES[frame]}</Text>;
}

View File

@@ -1,8 +1,8 @@
import figures from 'figures'
import React from 'react'
import { Text } from '../index.js'
import figures from 'figures';
import React from 'react';
import { Text } from '../index.js';
type Status = 'success' | 'error' | 'warning' | 'info' | 'pending' | 'loading'
type Status = 'success' | 'error' | 'warning' | 'info' | 'pending' | 'loading';
type Props = {
/**
@@ -15,19 +15,19 @@ type Props = {
* - `pending`: Dimmed circle (○)
* - `loading`: Dimmed ellipsis (…)
*/
status: Status
status: Status;
/**
* Include a trailing space after the icon. Useful when followed by text.
* @default false
*/
withSpace?: boolean
}
withSpace?: boolean;
};
const STATUS_CONFIG: Record<
Status,
{
icon: string
color: 'success' | 'error' | 'warning' | 'suggestion' | undefined
icon: string;
color: 'success' | 'error' | 'warning' | 'suggestion' | undefined;
}
> = {
success: { icon: figures.tick, color: 'success' },
@@ -36,7 +36,7 @@ const STATUS_CONFIG: Record<
info: { icon: figures.info, color: 'suggestion' },
pending: { icon: figures.circle, color: undefined },
loading: { icon: '…', color: undefined },
}
};
/**
* Renders a status indicator icon with appropriate color.
@@ -56,16 +56,13 @@ const STATUS_CONFIG: Record<
* Waiting for response
* </Text>
*/
export function StatusIcon({
status,
withSpace = false,
}: Props): React.ReactNode {
const config = STATUS_CONFIG[status]
export function StatusIcon({ status, withSpace = false }: Props): React.ReactNode {
const config = STATUS_CONFIG[status];
return (
<Text color={config.color} dimColor={!config.color}>
{config.icon}
{withSpace && ' '}
</Text>
)
);
}

View File

@@ -1,37 +1,28 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react'
import {
useIsInsideModal,
useModalScrollRef,
} from './modalContext.js'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import ScrollBox from '../components/ScrollBox.js'
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
import { stringWidth } from '../core/stringWidth.js'
import { Box, Text } from '../index.js'
import { useKeybindings } from '../keybindings/useKeybinding.js'
import type { Theme } from './theme-types.js'
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { useIsInsideModal, useModalScrollRef } from './modalContext.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import ScrollBox from '../components/ScrollBox.js';
import type { KeyboardEvent } from '../core/events/keyboard-event.js';
import { stringWidth } from '../core/stringWidth.js';
import { Box, Text } from '../index.js';
import { useKeybindings } from '../keybindings/useKeybinding.js';
import type { Theme } from './theme-types.js';
type TabsProps = {
children: Array<React.ReactElement<TabProps>>
title?: string
color?: keyof Theme
defaultTab?: string
hidden?: boolean
useFullWidth?: boolean
children: Array<React.ReactElement<TabProps>>;
title?: string;
color?: keyof Theme;
defaultTab?: string;
hidden?: boolean;
useFullWidth?: boolean;
/** Controlled mode: current selected tab id/title */
selectedTab?: string
selectedTab?: string;
/** Controlled mode: callback when tab changes */
onTabChange?: (tabId: string) => void
onTabChange?: (tabId: string) => void;
/** Optional banner to display below tabs header */
banner?: React.ReactNode
banner?: React.ReactNode;
/** Disable keyboard navigation (e.g. when a child component handles arrow keys) */
disableNavigation?: boolean
disableNavigation?: boolean;
/**
* Initial focus state for the tab header row. Defaults to true (header
* focused, nav always works). Keep the default for Select/list content —
@@ -40,29 +31,29 @@ type TabsProps = {
* content actually binds left/right/tab (e.g. enum cycling), and show a
* "↑ tabs" footer hint — without it tabs look broken.
*/
initialHeaderFocused?: boolean
initialHeaderFocused?: boolean;
/**
* Fixed height for the content area. When set, all tabs render within the
* same height (overflow hidden) so switching tabs doesn't cause layout
* shifts. Shorter tabs get whitespace; taller tabs are clipped.
*/
contentHeight?: number
contentHeight?: number;
/**
* Let Tab/←/→ switch tabs from focused content. Opt-in since some
* content uses those keys; pass a reactive boolean to cede them when
* needed. Switching from content focuses the header.
*/
navFromContent?: boolean
}
navFromContent?: boolean;
};
type TabsContextValue = {
selectedTab: string | undefined
width: number | undefined
headerFocused: boolean
focusHeader: () => void
blurHeader: () => void
registerOptIn: () => () => void
}
selectedTab: string | undefined;
width: number | undefined;
headerFocused: boolean;
focusHeader: () => void;
blurHeader: () => void;
registerOptIn: () => () => void;
};
const TabsContext = createContext<TabsContextValue>({
selectedTab: undefined,
@@ -73,7 +64,7 @@ const TabsContext = createContext<TabsContextValue>({
focusHeader: () => {},
blurHeader: () => {},
registerOptIn: () => () => {},
})
});
export function Tabs({
title,
@@ -90,64 +81,51 @@ export function Tabs({
contentHeight,
navFromContent = false,
}: TabsProps): React.ReactNode {
const { columns: terminalWidth } = useTerminalSize()
const tabs = children.map(child => [
child.props.id ?? child.props.title,
child.props.title,
])
const defaultTabIndex = defaultTab
? tabs.findIndex(tab => defaultTab === tab[0])
: 0
const { columns: terminalWidth } = useTerminalSize();
const tabs = children.map(child => [child.props.id ?? child.props.title, child.props.title]);
const defaultTabIndex = defaultTab ? tabs.findIndex(tab => defaultTab === tab[0]) : 0;
// Support both controlled and uncontrolled modes
const isControlled = controlledSelectedTab !== undefined
const [internalSelectedTab, setInternalSelectedTab] = useState(
defaultTabIndex !== -1 ? defaultTabIndex : 0,
)
const isControlled = controlledSelectedTab !== undefined;
const [internalSelectedTab, setInternalSelectedTab] = useState(defaultTabIndex !== -1 ? defaultTabIndex : 0);
// In controlled mode, find the index of the controlled tab
const controlledTabIndex = isControlled
? tabs.findIndex(tab => tab[0] === controlledSelectedTab)
: -1
const selectedTabIndex = isControlled
? controlledTabIndex !== -1
? controlledTabIndex
: 0
: internalSelectedTab
const controlledTabIndex = isControlled ? tabs.findIndex(tab => tab[0] === controlledSelectedTab) : -1;
const selectedTabIndex = isControlled ? (controlledTabIndex !== -1 ? controlledTabIndex : 0) : internalSelectedTab;
const modalScrollRef = useModalScrollRef()
const modalScrollRef = useModalScrollRef();
// Header focus: left/right/tab only switch tabs when the header row is
// focused. Children with interactive content call focusHeader() (via
// useTabHeaderFocus) on up-arrow to hand focus back here; down-arrow
// returns it. Tabs that never call the hook see no behavior change —
// initialHeaderFocused defaults to true so nav always works.
const [headerFocused, setHeaderFocused] = useState(initialHeaderFocused)
const focusHeader = useCallback(() => setHeaderFocused(true), [])
const blurHeader = useCallback(() => setHeaderFocused(false), [])
const [headerFocused, setHeaderFocused] = useState(initialHeaderFocused);
const focusHeader = useCallback(() => setHeaderFocused(true), []);
const blurHeader = useCallback(() => setHeaderFocused(false), []);
// Count of mounted children using useTabHeaderFocus(). Down-arrow blur and
// the ↓ hint only engage when at least one child has opted in — otherwise
// pressing down on a legacy tab would strand the user with nav disabled.
const [optInCount, setOptInCount] = useState(0)
const [optInCount, setOptInCount] = useState(0);
const registerOptIn = useCallback(() => {
setOptInCount(n => n + 1)
return () => setOptInCount(n => n - 1)
}, [])
const optedIn = optInCount > 0
setOptInCount(n => n + 1);
return () => setOptInCount(n => n - 1);
}, []);
const optedIn = optInCount > 0;
const handleTabChange = (offset: number) => {
const newIndex = (selectedTabIndex + tabs.length + offset) % tabs.length
const newTabId = tabs[newIndex]?.[0]
const newIndex = (selectedTabIndex + tabs.length + offset) % tabs.length;
const newTabId = tabs[newIndex]?.[0];
if (isControlled && onTabChange && newTabId) {
onTabChange(newTabId)
onTabChange(newTabId);
} else {
setInternalSelectedTab(newIndex)
setInternalSelectedTab(newIndex);
}
// Tab switching is a header action — stay focused so the user can keep
// cycling. The newly mounted tab can blur via its own interaction.
setHeaderFocused(true)
}
setHeaderFocused(true);
};
useKeybindings(
{
@@ -158,54 +136,49 @@ export function Tabs({
context: 'Tabs',
isActive: !hidden && !disableNavigation && headerFocused,
},
)
);
// When the header is focused, down-arrow returns focus to content. Only
// active when the selected tab has opted in via useTabHeaderFocus() —
// legacy tabs have nowhere to return focus to.
const handleKeyDown = (e: KeyboardEvent) => {
if (!headerFocused || !optedIn || hidden) return
if (!headerFocused || !optedIn || hidden) return;
if (e.key === 'down') {
e.preventDefault()
setHeaderFocused(false)
e.preventDefault();
setHeaderFocused(false);
}
}
};
// Opt-in: same tabs:next/previous actions, active from content. Focuses
// the header so subsequent presses cycle via the handler above.
useKeybindings(
{
'tabs:next': () => {
handleTabChange(1)
setHeaderFocused(true)
handleTabChange(1);
setHeaderFocused(true);
},
'tabs:previous': () => {
handleTabChange(-1)
setHeaderFocused(true)
handleTabChange(-1);
setHeaderFocused(true);
},
},
{
context: 'Tabs',
isActive:
navFromContent &&
!headerFocused &&
optedIn &&
!hidden &&
!disableNavigation,
isActive: navFromContent && !headerFocused && optedIn && !hidden && !disableNavigation,
},
)
);
// Calculate spacing to fill the available width. No keyboard hint in the
// header row — content footers own hints (see useTabHeaderFocus docs).
const titleWidth = title ? stringWidth(title) + 1 : 0 // +1 for gap
const titleWidth = title ? stringWidth(title) + 1 : 0; // +1 for gap
const tabsWidth = tabs.reduce(
(sum, [, tabTitle]) => sum + (tabTitle ? stringWidth(tabTitle) : 0) + 2 + 1, // +2 for padding, +1 for gap
0,
)
const usedWidth = titleWidth + tabsWidth
const spacerWidth = useFullWidth ? Math.max(0, terminalWidth - usedWidth) : 0
);
const usedWidth = titleWidth + tabsWidth;
const spacerWidth = useFullWidth ? Math.max(0, terminalWidth - usedWidth) : 0;
const contentWidth = useFullWidth ? terminalWidth : undefined
const contentWidth = useFullWidth ? terminalWidth : undefined;
return (
<TabsContext.Provider
@@ -230,19 +203,15 @@ export function Tabs({
flexShrink={modalScrollRef ? 0 : undefined}
>
{!hidden && (
<Box
flexDirection="row"
gap={1}
flexShrink={modalScrollRef ? 0 : undefined}
>
<Box flexDirection="row" gap={1} flexShrink={modalScrollRef ? 0 : undefined}>
{title !== undefined && (
<Text bold color={color}>
{title}
</Text>
)}
{tabs.map(([id, title], i) => {
const isCurrent = selectedTabIndex === i
const hasColorCursor = color && isCurrent && headerFocused
const isCurrent = selectedTabIndex === i;
const hasColorCursor = color && isCurrent && headerFocused;
return (
<Text
key={id}
@@ -254,7 +223,7 @@ export function Tabs({
{' '}
{title}{' '}
</Text>
)
);
})}
{spacerWidth > 0 && <Text>{' '.repeat(spacerWidth)}</Text>}
</Box>
@@ -267,12 +236,7 @@ export function Tabs({
// ModalContext. Keyed by selectedTabIndex → remounts on tab
// switch, resetting scrollTop to 0 without scrollTo() timing games.
<Box width={contentWidth} marginTop={hidden ? 0 : 1} flexShrink={0}>
<ScrollBox
key={selectedTabIndex}
ref={modalScrollRef}
flexDirection="column"
flexShrink={0}
>
<ScrollBox key={selectedTabIndex} ref={modalScrollRef} flexDirection="column" flexShrink={0}>
{children}
</ScrollBox>
</Box>
@@ -288,32 +252,32 @@ export function Tabs({
)}
</Box>
</TabsContext.Provider>
)
);
}
type TabProps = {
title: string
id?: string
children: React.ReactNode
}
title: string;
id?: string;
children: React.ReactNode;
};
export function Tab({ title, id, children }: TabProps): React.ReactNode {
const { selectedTab, width } = useContext(TabsContext)
const insideModal = useIsInsideModal()
const { selectedTab, width } = useContext(TabsContext);
const insideModal = useIsInsideModal();
if (selectedTab !== (id ?? title)) {
return null
return null;
}
return (
<Box width={width} flexShrink={insideModal ? 0 : undefined}>
{children}
</Box>
)
);
}
export function useTabsWidth(): number | undefined {
const { width } = useContext(TabsContext)
return width
const { width } = useContext(TabsContext);
return width;
}
/**
@@ -328,12 +292,11 @@ export function useTabsWidth(): number | undefined {
* when the Select renders.
*/
export function useTabHeaderFocus(): {
headerFocused: boolean
focusHeader: () => void
blurHeader: () => void
headerFocused: boolean;
focusHeader: () => void;
blurHeader: () => void;
} {
const { headerFocused, focusHeader, blurHeader, registerOptIn } =
useContext(TabsContext)
useEffect(registerOptIn, [registerOptIn])
return { headerFocused, focusHeader, blurHeader }
const { headerFocused, focusHeader, blurHeader, registerOptIn } = useContext(TabsContext);
useEffect(registerOptIn, [registerOptIn]);
return { headerFocused, focusHeader, blurHeader };
}

View File

@@ -1,44 +1,38 @@
import { feature } from 'bun:bundle'
import React, {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import useStdin from '../hooks/use-stdin.js'
import { getSystemThemeName, type SystemTheme } from './systemTheme.js'
import type { ThemeName, ThemeSetting } from './theme-types.js'
import { feature } from 'bun:bundle';
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import useStdin from '../hooks/use-stdin.js';
import { getSystemThemeName, type SystemTheme } from './systemTheme.js';
import type { ThemeName, ThemeSetting } from './theme-types.js';
// -- Config persistence injection --
// Business layer provides these via setThemeConfigCallbacks().
// Defaults read/write from a simple module-level store.
let _loadTheme: () => ThemeSetting = () => 'dark'
let _saveTheme: (setting: ThemeSetting) => void = () => {}
let _loadTheme: () => ThemeSetting = () => 'dark';
let _saveTheme: (setting: ThemeSetting) => void = () => {};
/** Inject config persistence from the business layer. Call once at startup. */
export function setThemeConfigCallbacks(opts: {
loadTheme: () => ThemeSetting
saveTheme: (setting: ThemeSetting) => void
loadTheme: () => ThemeSetting;
saveTheme: (setting: ThemeSetting) => void;
}): void {
_loadTheme = opts.loadTheme
_saveTheme = opts.saveTheme
_loadTheme = opts.loadTheme;
_saveTheme = opts.saveTheme;
}
type ThemeContextValue = {
/** The saved user preference. May be 'auto'. */
themeSetting: ThemeSetting
setThemeSetting: (setting: ThemeSetting) => void
setPreviewTheme: (setting: ThemeSetting) => void
savePreview: () => void
cancelPreview: () => void
themeSetting: ThemeSetting;
setThemeSetting: (setting: ThemeSetting) => void;
setPreviewTheme: (setting: ThemeSetting) => void;
savePreview: () => void;
cancelPreview: () => void;
/** The resolved theme to render with. Never 'auto'. */
currentTheme: ThemeName
}
currentTheme: ThemeName;
};
// Non-'auto' default so useTheme() works without a provider (tests, tooling).
const DEFAULT_THEME: ThemeName = 'dark'
const DEFAULT_THEME: ThemeName = 'dark';
const ThemeContext = createContext<ThemeContextValue>({
themeSetting: DEFAULT_THEME,
@@ -47,105 +41,96 @@ const ThemeContext = createContext<ThemeContextValue>({
savePreview: () => {},
cancelPreview: () => {},
currentTheme: DEFAULT_THEME,
})
});
type Props = {
children: React.ReactNode
initialState?: ThemeSetting
onThemeSave?: (setting: ThemeSetting) => void
}
children: React.ReactNode;
initialState?: ThemeSetting;
onThemeSave?: (setting: ThemeSetting) => void;
};
function defaultInitialTheme(): ThemeSetting {
return _loadTheme()
return _loadTheme();
}
function defaultSaveTheme(setting: ThemeSetting): void {
_saveTheme(setting)
_saveTheme(setting);
}
export function ThemeProvider({
children,
initialState,
onThemeSave = defaultSaveTheme,
}: Props) {
const [themeSetting, setThemeSetting] = useState(
initialState ?? defaultInitialTheme,
)
const [previewTheme, setPreviewTheme] = useState<ThemeSetting | null>(null)
export function ThemeProvider({ children, initialState, onThemeSave = defaultSaveTheme }: Props) {
const [themeSetting, setThemeSetting] = useState(initialState ?? defaultInitialTheme);
const [previewTheme, setPreviewTheme] = useState<ThemeSetting | null>(null);
// Track terminal theme for 'auto' resolution. Seeds from $COLORFGBG (or
// 'dark' if unset); the OSC 11 watcher corrects it on first poll.
const [systemTheme, setSystemTheme] = useState<SystemTheme>(() =>
(initialState ?? themeSetting) === 'auto' ? getSystemThemeName() : 'dark',
)
);
// The setting currently in effect (preview wins while picker is open)
const activeSetting = previewTheme ?? themeSetting
const activeSetting = previewTheme ?? themeSetting;
const { internal_querier } = useStdin()
const { internal_querier } = useStdin();
// Watch for live terminal theme changes while 'auto' is active.
// Positive feature() pattern so the watcher import is dead-code-eliminated
// in external builds.
useEffect(() => {
if (feature('AUTO_THEME')) {
if (activeSetting !== 'auto' || !internal_querier) return
let cleanup: (() => void) | undefined
let cancelled = false
void import('../../utils/systemThemeWatcher.js').then(
({ watchSystemTheme }) => {
if (cancelled) return
cleanup = watchSystemTheme(internal_querier, setSystemTheme)
},
)
if (activeSetting !== 'auto' || !internal_querier) return;
let cleanup: (() => void) | undefined;
let cancelled = false;
void import('../../utils/systemThemeWatcher.js').then(({ watchSystemTheme }) => {
if (cancelled) return;
cleanup = watchSystemTheme(internal_querier, setSystemTheme);
});
return () => {
cancelled = true
cleanup?.()
}
cancelled = true;
cleanup?.();
};
}
}, [activeSetting, internal_querier])
}, [activeSetting, internal_querier]);
const currentTheme: ThemeName =
activeSetting === 'auto' ? systemTheme : activeSetting
const currentTheme: ThemeName = activeSetting === 'auto' ? systemTheme : activeSetting;
const value = useMemo<ThemeContextValue>(
() => ({
themeSetting,
setThemeSetting: (newSetting: ThemeSetting) => {
setThemeSetting(newSetting)
setPreviewTheme(null)
setThemeSetting(newSetting);
setPreviewTheme(null);
// Switching to 'auto' restarts the watcher (activeSetting dep), whose
// first poll fires immediately. Seed from the cache so the OSC
// round-trip doesn't flash the wrong palette.
if (newSetting === 'auto') {
setSystemTheme(getSystemThemeName())
setSystemTheme(getSystemThemeName());
}
onThemeSave?.(newSetting)
onThemeSave?.(newSetting);
},
setPreviewTheme: (newSetting: ThemeSetting) => {
setPreviewTheme(newSetting)
setPreviewTheme(newSetting);
if (newSetting === 'auto') {
setSystemTheme(getSystemThemeName())
setSystemTheme(getSystemThemeName());
}
},
savePreview: () => {
if (previewTheme !== null) {
setThemeSetting(previewTheme)
setPreviewTheme(null)
onThemeSave?.(previewTheme)
setThemeSetting(previewTheme);
setPreviewTheme(null);
onThemeSave?.(previewTheme);
}
},
cancelPreview: () => {
if (previewTheme !== null) {
setPreviewTheme(null)
setPreviewTheme(null);
}
},
currentTheme,
}),
[themeSetting, previewTheme, currentTheme, onThemeSave],
)
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
/**
@@ -153,8 +138,8 @@ export function ThemeProvider({
* accepts any ThemeSetting (including 'auto').
*/
export function useTheme(): [ThemeName, (setting: ThemeSetting) => void] {
const { currentTheme, setThemeSetting } = useContext(ThemeContext)
return [currentTheme, setThemeSetting]
const { currentTheme, setThemeSetting } = useContext(ThemeContext);
return [currentTheme, setThemeSetting];
}
/**
@@ -162,11 +147,10 @@ export function useTheme(): [ThemeName, (setting: ThemeSetting) => void] {
* needs to show 'auto' as a distinct choice (e.g., ThemePicker).
*/
export function useThemeSetting(): ThemeSetting {
return useContext(ThemeContext).themeSetting
return useContext(ThemeContext).themeSetting;
}
export function usePreviewTheme() {
const { setPreviewTheme, savePreview, cancelPreview } =
useContext(ThemeContext)
return { setPreviewTheme, savePreview, cancelPreview }
const { setPreviewTheme, savePreview, cancelPreview } = useContext(ThemeContext);
return { setPreviewTheme, savePreview, cancelPreview };
}

View File

@@ -1,22 +1,22 @@
import React, { type PropsWithChildren, type Ref } from 'react'
import Box from '../components/Box.js'
import type { DOMElement } from '../core/dom.js'
import type { ClickEvent } from '../core/events/click-event.js'
import type { FocusEvent } from '../core/events/focus-event.js'
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
import type { Color, Styles } from '../core/styles.js'
import { getTheme, type Theme } from './theme-types.js'
import { useTheme } from './ThemeProvider.js'
import React, { type PropsWithChildren, type Ref } from 'react';
import Box from '../components/Box.js';
import type { DOMElement } from '../core/dom.js';
import type { ClickEvent } from '../core/events/click-event.js';
import type { FocusEvent } from '../core/events/focus-event.js';
import type { KeyboardEvent } from '../core/events/keyboard-event.js';
import type { Color, Styles } from '../core/styles.js';
import { getTheme, type Theme } from './theme-types.js';
import { useTheme } from './ThemeProvider.js';
// Color props that accept theme keys
type ThemedColorProps = {
readonly borderColor?: keyof Theme | Color
readonly borderTopColor?: keyof Theme | Color
readonly borderBottomColor?: keyof Theme | Color
readonly borderLeftColor?: keyof Theme | Color
readonly borderRightColor?: keyof Theme | Color
readonly backgroundColor?: keyof Theme | Color
}
readonly borderColor?: keyof Theme | Color;
readonly borderTopColor?: keyof Theme | Color;
readonly borderBottomColor?: keyof Theme | Color;
readonly borderLeftColor?: keyof Theme | Color;
readonly borderRightColor?: keyof Theme | Color;
readonly backgroundColor?: keyof Theme | Color;
};
// Base Styles without color props (they'll be overridden)
type BaseStylesWithoutColors = Omit<
@@ -28,43 +28,35 @@ type BaseStylesWithoutColors = Omit<
| 'borderLeftColor'
| 'borderRightColor'
| 'backgroundColor'
>
>;
export type Props = BaseStylesWithoutColors &
ThemedColorProps & {
ref?: Ref<DOMElement>
tabIndex?: number
autoFocus?: boolean
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
onMouseEnter?: () => void
onMouseLeave?: () => void
}
ref?: Ref<DOMElement>;
tabIndex?: number;
autoFocus?: boolean;
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;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
};
/**
* Resolves a color value that may be a theme key to a raw Color.
*/
function resolveColor(
color: keyof Theme | Color | undefined,
theme: Theme,
): Color | undefined {
if (!color) return undefined
function resolveColor(color: keyof Theme | Color | undefined, theme: Theme): Color | undefined {
if (!color) return undefined;
// Check if it's a raw color (starts with rgb(, #, ansi256(, or ansi:)
if (
color.startsWith('rgb(') ||
color.startsWith('#') ||
color.startsWith('ansi256(') ||
color.startsWith('ansi:')
) {
return color as Color
if (color.startsWith('rgb(') || color.startsWith('#') || color.startsWith('ansi256(') || color.startsWith('ansi:')) {
return color as Color;
}
// It's a theme key - resolve it
return theme[color as keyof Theme] as Color
return theme[color as keyof Theme] as Color;
}
/**
@@ -82,16 +74,16 @@ function ThemedBox({
ref,
...rest
}: PropsWithChildren<Props>): React.ReactNode {
const [themeName] = useTheme()
const theme = getTheme(themeName)
const [themeName] = useTheme();
const theme = getTheme(themeName);
// Resolve theme keys to raw colors
const resolvedBorderColor = resolveColor(borderColor, theme)
const resolvedBorderTopColor = resolveColor(borderTopColor, theme)
const resolvedBorderBottomColor = resolveColor(borderBottomColor, theme)
const resolvedBorderLeftColor = resolveColor(borderLeftColor, theme)
const resolvedBorderRightColor = resolveColor(borderRightColor, theme)
const resolvedBackgroundColor = resolveColor(backgroundColor, theme)
const resolvedBorderColor = resolveColor(borderColor, theme);
const resolvedBorderTopColor = resolveColor(borderTopColor, theme);
const resolvedBorderBottomColor = resolveColor(borderBottomColor, theme);
const resolvedBorderLeftColor = resolveColor(borderLeftColor, theme);
const resolvedBorderRightColor = resolveColor(borderRightColor, theme);
const resolvedBackgroundColor = resolveColor(backgroundColor, theme);
return (
<Box
@@ -106,7 +98,7 @@ function ThemedBox({
>
{children}
</Box>
)
);
}
export default ThemedBox
export default ThemedBox;

View File

@@ -1,87 +1,77 @@
import type { ReactNode } from 'react'
import React, { useContext } from 'react'
import Text from '../components/Text.js'
import type { Color, Styles } from '../core/styles.js'
import { getTheme, type Theme } from './theme-types.js'
import { useTheme } from './ThemeProvider.js'
import type { ReactNode } from 'react';
import React, { useContext } from 'react';
import Text from '../components/Text.js';
import type { Color, Styles } from '../core/styles.js';
import { getTheme, type Theme } from './theme-types.js';
import { useTheme } from './ThemeProvider.js';
/** Colors uncolored ThemedText in the subtree. Precedence: explicit `color` >
* this > dimColor. Crosses Box boundaries (Ink's style cascade doesn't). */
export const TextHoverColorContext = React.createContext<
keyof Theme | undefined
>(undefined)
export const TextHoverColorContext = React.createContext<keyof Theme | undefined>(undefined);
export type Props = {
/**
* Change text color. Accepts a theme key or raw color value.
*/
readonly color?: keyof Theme | Color
readonly color?: keyof Theme | Color;
/**
* Same as `color`, but for background. Must be a theme key.
*/
readonly backgroundColor?: keyof Theme
readonly backgroundColor?: keyof Theme;
/**
* Dim the color using the theme's inactive color.
* This is compatible with bold (unlike ANSI dim).
*/
readonly dimColor?: boolean
readonly dimColor?: boolean;
/**
* Make the text bold.
*/
readonly bold?: boolean
readonly bold?: boolean;
/**
* 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 wrap?: Styles['textWrap'];
readonly children?: ReactNode
}
readonly children?: ReactNode;
};
/**
* Resolves a color value that may be a theme key to a raw Color.
*/
function resolveColor(
color: keyof Theme | Color | undefined,
theme: Theme,
): Color | undefined {
if (!color) return undefined
function resolveColor(color: keyof Theme | Color | undefined, theme: Theme): Color | undefined {
if (!color) return undefined;
// Check if it's a raw color (starts with rgb(, #, ansi256(, or ansi:)
if (
color.startsWith('rgb(') ||
color.startsWith('#') ||
color.startsWith('ansi256(') ||
color.startsWith('ansi:')
) {
return color as Color
if (color.startsWith('rgb(') || color.startsWith('#') || color.startsWith('ansi256(') || color.startsWith('ansi:')) {
return color as Color;
}
// It's a theme key - resolve it
return theme[color as keyof Theme] as Color
return theme[color as keyof Theme] as Color;
}
/**
@@ -100,9 +90,9 @@ export default function ThemedText({
wrap = 'wrap',
children,
}: Props): React.ReactNode {
const [themeName] = useTheme()
const theme = getTheme(themeName)
const hoverColor = useContext(TextHoverColorContext)
const [themeName] = useTheme();
const theme = getTheme(themeName);
const hoverColor = useContext(TextHoverColorContext);
// Resolve theme keys to raw colors
const resolvedColor =
@@ -110,10 +100,8 @@ export default function ThemedText({
? resolveColor(hoverColor, theme)
: dimColor
? (theme.inactive as Color)
: resolveColor(color, theme)
const resolvedBackgroundColor = backgroundColor
? (theme[backgroundColor] as Color)
: undefined
: resolveColor(color, theme);
const resolvedBackgroundColor = backgroundColor ? (theme[backgroundColor] as Color) : undefined;
return (
<Text
@@ -128,5 +116,5 @@ export default function ThemedText({
>
{children}
</Text>
)
);
}

View File

@@ -1,49 +1,49 @@
// Type declarations for custom Ink JSX elements
// Note: The detailed prop types are defined in ink-jsx.d.ts via React module augmentation.
// This file provides the global JSX namespace fallback declarations.
import type { ReactNode, Ref } from 'react';
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, TextStyles } from '../core/styles.js';
import type { DOMElement } from '../core/dom.js';
import type { ReactNode, Ref } from 'react'
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, TextStyles } from '../core/styles.js'
import type { DOMElement } from '../core/dom.js'
declare global {
namespace JSX {
interface IntrinsicElements {
'ink-box': {
ref?: Ref<DOMElement>;
tabIndex?: number;
autoFocus?: boolean;
onClick?: (event: ClickEvent) => void;
onFocus?: (event: FocusEvent) => void;
onFocusCapture?: (event: FocusEvent) => void;
onBlur?: (event: FocusEvent) => void;
onBlurCapture?: (event: FocusEvent) => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
onKeyDown?: (event: KeyboardEvent) => void;
onKeyDownCapture?: (event: KeyboardEvent) => void;
style?: Styles;
stickyScroll?: boolean;
children?: ReactNode;
};
ref?: Ref<DOMElement>
tabIndex?: number
autoFocus?: boolean
onClick?: (event: ClickEvent) => void
onFocus?: (event: FocusEvent) => void
onFocusCapture?: (event: FocusEvent) => void
onBlur?: (event: FocusEvent) => void
onBlurCapture?: (event: FocusEvent) => void
onMouseEnter?: () => void
onMouseLeave?: () => void
onKeyDown?: (event: KeyboardEvent) => void
onKeyDownCapture?: (event: KeyboardEvent) => void
style?: Styles
stickyScroll?: boolean
children?: ReactNode
}
'ink-text': {
style?: Styles;
textStyles?: TextStyles;
children?: ReactNode;
};
style?: Styles
textStyles?: TextStyles
children?: ReactNode
}
'ink-link': {
href?: string;
children?: ReactNode;
};
href?: string
children?: ReactNode
}
'ink-raw-ansi': {
rawText?: string;
rawWidth?: number;
rawHeight?: number;
};
rawText?: string
rawWidth?: number
rawHeight?: number
}
}
}
}
export {};
export {}

View File

@@ -8,47 +8,47 @@
* This file must be a module (have an import/export) for `declare module`
* augmentation to work correctly.
*/
import type { ReactNode, Ref } from 'react';
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, TextStyles } from '../core/styles.js';
import type { DOMElement } from '../core/dom.js';
import type { ReactNode, Ref } from 'react'
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, TextStyles } from '../core/styles.js'
import type { DOMElement } from '../core/dom.js'
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'ink-box': {
ref?: Ref<DOMElement>;
tabIndex?: number;
autoFocus?: boolean;
onClick?: (event: ClickEvent) => void;
onFocus?: (event: FocusEvent) => void;
onFocusCapture?: (event: FocusEvent) => void;
onBlur?: (event: FocusEvent) => void;
onBlurCapture?: (event: FocusEvent) => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
onKeyDown?: (event: KeyboardEvent) => void;
onKeyDownCapture?: (event: KeyboardEvent) => void;
style?: Styles;
stickyScroll?: boolean;
children?: ReactNode;
};
ref?: Ref<DOMElement>
tabIndex?: number
autoFocus?: boolean
onClick?: (event: ClickEvent) => void
onFocus?: (event: FocusEvent) => void
onFocusCapture?: (event: FocusEvent) => void
onBlur?: (event: FocusEvent) => void
onBlurCapture?: (event: FocusEvent) => void
onMouseEnter?: () => void
onMouseLeave?: () => void
onKeyDown?: (event: KeyboardEvent) => void
onKeyDownCapture?: (event: KeyboardEvent) => void
style?: Styles
stickyScroll?: boolean
children?: ReactNode
}
'ink-text': {
style?: Styles;
textStyles?: TextStyles;
children?: ReactNode;
};
style?: Styles
textStyles?: TextStyles
children?: ReactNode
}
'ink-link': {
href?: string;
children?: ReactNode;
};
href?: string
children?: ReactNode
}
'ink-raw-ansi': {
rawText?: string;
rawWidth?: number;
rawHeight?: number;
};
rawText?: string
rawWidth?: number
rawHeight?: number
}
}
}
}