mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 08:15:53 +00:00
style: 格式化 packages/@ant/ 下所有文件以通过 biome ci
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type Cursor = any;
|
||||
export type Cursor = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export {}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type PasteEvent = any;
|
||||
export type PasteEvent = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type ResizeEvent = any;
|
||||
export type ResizeEvent = any
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
68
packages/@ant/ink/src/types/ink-elements.d.ts
vendored
68
packages/@ant/ink/src/types/ink-elements.d.ts
vendored
@@ -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 {}
|
||||
|
||||
66
packages/@ant/ink/src/types/ink-jsx.d.ts
vendored
66
packages/@ant/ink/src/types/ink-jsx.d.ts
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user