init claude-code

This commit is contained in:
2026-04-01 17:32:37 +02:00
commit 73b208c009
1902 changed files with 513237 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
import { useContext, useEffect, useState } from 'react'
import { ClockContext } from '../components/ClockContext.js'
import type { DOMElement } from '../dom.js'
import { useTerminalViewport } from './use-terminal-viewport.js'
/**
* Hook for synchronized animations that pause when offscreen.
*
* Returns a ref to attach to the animated element and the current animation time.
* All instances share the same clock, so animations stay in sync.
* The clock only runs when at least one keepAlive subscriber exists.
*
* Pass `null` to pause — unsubscribes from the clock so no ticks fire.
* Time freezes at the last value and resumes from the current clock time
* when a number is passed again.
*
* @param intervalMs - How often to update, or null to pause
* @returns [ref, time] - Ref to attach to element, elapsed time in ms
*
* @example
* function Spinner() {
* const [ref, time] = useAnimationFrame(120)
* const frame = Math.floor(time / 120) % FRAMES.length
* return <Box ref={ref}>{FRAMES[frame]}</Box>
* }
*
* The clock automatically slows when the terminal is blurred,
* so consumers don't need to handle focus state.
*/
export function useAnimationFrame(
intervalMs: number | null = 16,
): [ref: (element: DOMElement | null) => void, time: number] {
const clock = useContext(ClockContext)
const [viewportRef, { isVisible }] = useTerminalViewport()
const [time, setTime] = useState(() => clock?.now() ?? 0)
const active = isVisible && intervalMs !== null
useEffect(() => {
if (!clock || !active) return
let lastUpdate = clock.now()
const onChange = (): void => {
const now = clock.now()
if (now - lastUpdate >= intervalMs!) {
lastUpdate = now
setTime(now)
}
}
// keepAlive: true — visible animations drive the clock
return clock.subscribe(onChange, true)
}, [clock, intervalMs, active])
return [viewportRef, time]
}
+8
View File
@@ -0,0 +1,8 @@
import { useContext } from 'react'
import AppContext from '../components/AppContext.js'
/**
* `useApp` is a React hook, which exposes a method to manually exit the app (unmount).
*/
const useApp = () => useContext(AppContext)
export default useApp
+73
View File
@@ -0,0 +1,73 @@
import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
import CursorDeclarationContext from '../components/CursorDeclarationContext.js'
import type { DOMElement } from '../dom.js'
/**
* Declares where the terminal cursor should be parked after each frame.
*
* Terminal emulators render IME preedit text at the physical cursor
* position, and screen readers / screen magnifiers track the native
* cursor — so parking it at the text input's caret makes CJK input
* appear inline and lets accessibility tools follow the input.
*
* Returns a ref callback to attach to the Box that contains the input.
* The declared (line, column) is interpreted relative to that Box's
* nodeCache rect (populated by renderNodeToOutput).
*
* Timing: Both ref attach and useLayoutEffect fire in React's layout
* phase — after resetAfterCommit calls scheduleRender. scheduleRender
* defers onRender via queueMicrotask, so onRender runs AFTER layout
* effects commit and reads the fresh declaration on the first frame
* (no one-keystroke lag). Test env uses onImmediateRender (synchronous,
* no microtask), so tests compensate by calling ink.onRender()
* explicitly after render.
*/
export function useDeclaredCursor({
line,
column,
active,
}: {
line: number
column: number
active: boolean
}): (element: DOMElement | null) => void {
const setCursorDeclaration = useContext(CursorDeclarationContext)
const nodeRef = useRef<DOMElement | null>(null)
const setNode = useCallback((node: DOMElement | null) => {
nodeRef.current = node
}, [])
// When active, set unconditionally. When inactive, clear conditionally
// (only if the currently-declared node is ours). The node-identity check
// handles two hazards:
// 1. A memo()ized active instance elsewhere (e.g. the search input in
// a memo'd Footer) doesn't re-render this commit — an inactive
// instance re-rendering here must not clobber it.
// 2. Sibling handoff (menu focus moving between list items) — when
// focus moves opposite to sibling order, the newly-inactive item's
// effect runs AFTER the newly-active item's set. Without the node
// check it would clobber.
// No dep array: must re-declare every commit so the active instance
// re-claims the declaration after another instance's unmount-cleanup or
// sibling handoff nulls it.
useLayoutEffect(() => {
const node = nodeRef.current
if (active && node) {
setCursorDeclaration({ relativeX: column, relativeY: line, node })
} else {
setCursorDeclaration(null, node)
}
})
// Clear on unmount (conditionally — another instance may own by then).
// Separate effect with empty deps so cleanup only fires once — not on
// every line/column change, which would transiently null between commits.
useLayoutEffect(() => {
return () => {
setCursorDeclaration(null, nodeRef.current)
}
}, [setCursorDeclaration])
return setNode
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useLayoutEffect } from 'react'
import { useEventCallback } from 'usehooks-ts'
import type { InputEvent, Key } from '../events/input-event.js'
import useStdin from './use-stdin.js'
type Handler = (input: string, key: Key, event: InputEvent) => void
type Options = {
/**
* Enable or disable capturing of user input.
* Useful when there are multiple useInput hooks used at once to avoid handling the same input several times.
*
* @default true
*/
isActive?: boolean
}
/**
* This hook is used for handling user input.
* It's a more convenient alternative to using `StdinContext` and listening to `data` events.
* The callback you pass to `useInput` is called for each character when user enters any input.
* However, if user pastes text and it's more than one character, the callback will be called only once and the whole string will be passed as `input`.
*
* ```
* import {useInput} from 'ink';
*
* const UserInput = () => {
* useInput((input, key) => {
* if (input === 'q') {
* // Exit program
* }
*
* if (key.leftArrow) {
* // Left arrow key pressed
* }
* });
*
* return …
* };
* ```
*/
const useInput = (inputHandler: Handler, options: Options = {}) => {
const { setRawMode, internal_exitOnCtrlC, internal_eventEmitter } = useStdin()
// useLayoutEffect (not useEffect) so that raw mode is enabled synchronously
// during React's commit phase, before render() returns. With useEffect, raw
// mode setup is deferred to the next event loop tick via React's scheduler,
// leaving the terminal in cooked mode — keystrokes echo and the cursor is
// visible until the effect fires.
useLayoutEffect(() => {
if (options.isActive === false) {
return
}
setRawMode(true)
return () => {
setRawMode(false)
}
}, [options.isActive, setRawMode])
// Register the listener once on mount so its slot in the EventEmitter's
// listener array is stable. If isActive were in the effect's deps, the
// listener would re-append on false→true, moving it behind listeners
// that registered while it was inactive — breaking
// stopImmediatePropagation() ordering. useEventCallback keeps the
// reference stable while reading latest isActive/inputHandler from
// closure (it syncs via useLayoutEffect, so it's compiler-safe).
const handleData = useEventCallback((event: InputEvent) => {
if (options.isActive === false) {
return
}
const { input, key } = event
// If app is not supposed to exit on Ctrl+C, then let input listener handle it
// Note: discreteUpdates is called at the App level when emitting events,
// so all listeners are already within a high-priority update context.
if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC) {
inputHandler(input, key, event)
}
})
useEffect(() => {
internal_eventEmitter?.on('input', handleData)
return () => {
internal_eventEmitter?.removeListener('input', handleData)
}
}, [internal_eventEmitter, handleData])
}
export default useInput
+67
View File
@@ -0,0 +1,67 @@
import { useContext, useEffect, useRef, useState } from 'react'
import { ClockContext } from '../components/ClockContext.js'
/**
* Returns the clock time, updating at the given interval.
* Subscribes as non-keepAlive — won't keep the clock alive on its own,
* but updates whenever a keepAlive subscriber (e.g. the spinner)
* is driving the clock.
*
* Use this to drive pure time-based computations (shimmer position,
* frame index) from the shared clock.
*/
export function useAnimationTimer(intervalMs: number): number {
const clock = useContext(ClockContext)
const [time, setTime] = useState(() => clock?.now() ?? 0)
useEffect(() => {
if (!clock) return
let lastUpdate = clock.now()
const onChange = (): void => {
const now = clock.now()
if (now - lastUpdate >= intervalMs) {
lastUpdate = now
setTime(now)
}
}
return clock.subscribe(onChange, false)
}, [clock, intervalMs])
return time
}
/**
* Interval hook backed by the shared Clock.
*
* Unlike `useInterval` from `usehooks-ts` (which creates its own setInterval),
* this piggybacks on the single shared clock so all timers consolidate into
* one wake-up. Pass `null` for intervalMs to pause.
*/
export function useInterval(
callback: () => void,
intervalMs: number | null,
): void {
const callbackRef = useRef(callback)
callbackRef.current = callback
const clock = useContext(ClockContext)
useEffect(() => {
if (!clock || intervalMs === null) return
let lastUpdate = clock.now()
const onChange = (): void => {
const now = clock.now()
if (now - lastUpdate >= intervalMs) {
lastUpdate = now
callbackRef.current()
}
}
return clock.subscribe(onChange, false)
}, [clock, intervalMs])
}
+53
View File
@@ -0,0 +1,53 @@
import { useContext, useMemo } from 'react'
import StdinContext from '../components/StdinContext.js'
import type { DOMElement } from '../dom.js'
import instances from '../instances.js'
import type { MatchPosition } from '../render-to-screen.js'
/**
* Set the search highlight query on the Ink instance. Non-empty → all
* visible occurrences are inverted on the next frame (SGR 7, screen-buffer
* overlay, same damage machinery as selection). Empty → clears.
*
* This is a screen-space highlight — it matches the RENDERED text, not the
* source message text. Works for anything visible (bash output, file paths,
* error messages) regardless of where it came from in the message tree. A
* query that matched in source but got truncated/ellipsized in rendering
* won't highlight; that's acceptable — we highlight what you see.
*/
export function useSearchHighlight(): {
setQuery: (query: string) => void
/** Paint an existing DOM subtree (from the MAIN tree) to a fresh
* Screen at its natural height, scan. Element-relative positions
* (row 0 = element top). Zero context duplication — the element
* IS the one built with all real providers. */
scanElement: (el: DOMElement) => MatchPosition[]
/** Position-based CURRENT highlight. Every frame writes yellow at
* positions[currentIdx] + rowOffset. The scan-highlight (inverse on
* all matches) still runs — this overlays on top. rowOffset tracks
* scroll; positions stay stable (message-relative). null clears. */
setPositions: (
state: {
positions: MatchPosition[]
rowOffset: number
currentIdx: number
} | null,
) => void
} {
useContext(StdinContext) // anchor to App subtree for hook rules
const ink = instances.get(process.stdout)
return useMemo(() => {
if (!ink) {
return {
setQuery: () => {},
scanElement: () => [],
setPositions: () => {},
}
}
return {
setQuery: (query: string) => ink.setSearchHighlight(query),
scanElement: (el: DOMElement) => ink.scanElementSubtree(el),
setPositions: state => ink.setSearchPositions(state),
}
}, [ink])
}
+104
View File
@@ -0,0 +1,104 @@
import { useContext, useMemo, useSyncExternalStore } from 'react'
import StdinContext from '../components/StdinContext.js'
import instances from '../instances.js'
import {
type FocusMove,
type SelectionState,
shiftAnchor,
} from '../selection.js'
/**
* Access to text selection operations on the Ink instance (fullscreen only).
* Returns no-op functions when fullscreen mode is disabled.
*/
export function useSelection(): {
copySelection: () => string
/** Copy without clearing the highlight (for copy-on-select). */
copySelectionNoClear: () => string
clearSelection: () => void
hasSelection: () => boolean
/** Read the raw mutable selection state (for drag-to-scroll). */
getState: () => SelectionState | null
/** Subscribe to selection mutations (start/update/finish/clear). */
subscribe: (cb: () => void) => () => void
/** Shift the anchor row by dRow, clamped to [minRow, maxRow]. */
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
/** Shift anchor AND focus by dRow (keyboard scroll: whole selection
* tracks content). Clamped points get col reset to the full-width edge
* since their content was captured by captureScrolledRows. Reads
* screen.width from the ink instance for the col-reset boundary. */
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
/** Keyboard selection extension (shift+arrow): move focus, anchor fixed.
* Left/right wrap across rows; up/down clamp at viewport edges. */
moveFocus: (move: FocusMove) => void
/** Capture text from rows about to scroll out of the viewport (call
* BEFORE scrollBy so the screen buffer still has the outgoing rows). */
captureScrolledRows: (
firstRow: number,
lastRow: number,
side: 'above' | 'below',
) => void
/** Set the selection highlight bg color (theme-piping; solid bg
* replaces the old SGR-7 inverse so syntax highlighting stays readable
* under selection). Call once on mount + whenever theme changes. */
setSelectionBgColor: (color: string) => void
} {
// Look up the Ink instance via stdout — same pattern as instances map.
// StdinContext is available (it's always provided), and the Ink instance
// is keyed by stdout which we can get from process.stdout since there's
// only one Ink instance per process in practice.
useContext(StdinContext) // anchor to App subtree for hook rules
const ink = instances.get(process.stdout)
// Memoize so callers can safely use the return value in dependency arrays.
// ink is a singleton per stdout — stable across renders.
return useMemo(() => {
if (!ink) {
return {
copySelection: () => '',
copySelectionNoClear: () => '',
clearSelection: () => {},
hasSelection: () => false,
getState: () => null,
subscribe: () => () => {},
shiftAnchor: () => {},
shiftSelection: () => {},
moveFocus: () => {},
captureScrolledRows: () => {},
setSelectionBgColor: () => {},
}
}
return {
copySelection: () => ink.copySelection(),
copySelectionNoClear: () => ink.copySelectionNoClear(),
clearSelection: () => ink.clearTextSelection(),
hasSelection: () => ink.hasTextSelection(),
getState: () => ink.selection,
subscribe: (cb: () => void) => ink.subscribeToSelectionChange(cb),
shiftAnchor: (dRow: number, minRow: number, maxRow: number) =>
shiftAnchor(ink.selection, dRow, minRow, maxRow),
shiftSelection: (dRow, minRow, maxRow) =>
ink.shiftSelectionForScroll(dRow, minRow, maxRow),
moveFocus: (move: FocusMove) => ink.moveSelectionFocus(move),
captureScrolledRows: (firstRow, lastRow, side) =>
ink.captureScrolledRows(firstRow, lastRow, side),
setSelectionBgColor: (color: string) => ink.setSelectionBgColor(color),
}
}, [ink])
}
const NO_SUBSCRIBE = () => () => {}
const ALWAYS_FALSE = () => false
/**
* Reactive selection-exists state. Re-renders the caller when a text
* selection is created or cleared. Always returns false outside
* fullscreen mode (selection is only available in alt-screen).
*/
export function useHasSelection(): boolean {
useContext(StdinContext)
const ink = instances.get(process.stdout)
return useSyncExternalStore(
ink ? ink.subscribeToSelectionChange : NO_SUBSCRIBE,
ink ? ink.hasTextSelection : ALWAYS_FALSE,
)
}
+8
View File
@@ -0,0 +1,8 @@
import { useContext } from 'react'
import StdinContext from '../components/StdinContext.js'
/**
* `useStdin` is a React hook, which exposes stdin stream.
*/
const useStdin = () => useContext(StdinContext)
export default useStdin
+72
View File
@@ -0,0 +1,72 @@
import { useContext, useEffect, useRef } from 'react'
import {
CLEAR_TAB_STATUS,
supportsTabStatus,
tabStatus,
wrapForMultiplexer,
} from '../termio/osc.js'
import type { Color } from '../termio/types.js'
import { TerminalWriteContext } from '../useTerminalNotification.js'
export type TabStatusKind = 'idle' | 'busy' | 'waiting'
const rgb = (r: number, g: number, b: number): Color => ({
type: 'rgb',
r,
g,
b,
})
// Per the OSC 21337 usage guide's suggested mapping.
const TAB_STATUS_PRESETS: Record<
TabStatusKind,
{ indicator: Color; status: string; statusColor: Color }
> = {
idle: {
indicator: rgb(0, 215, 95),
status: 'Idle',
statusColor: rgb(136, 136, 136),
},
busy: {
indicator: rgb(255, 149, 0),
status: 'Working…',
statusColor: rgb(255, 149, 0),
},
waiting: {
indicator: rgb(95, 135, 255),
status: 'Waiting',
statusColor: rgb(95, 135, 255),
},
}
/**
* Declaratively set the tab-status indicator (OSC 21337).
*
* Emits a colored dot + short status text to the tab sidebar. Terminals
* that don't support OSC 21337 discard the sequence silently, so this is
* safe to call unconditionally. Wrapped for tmux/screen passthrough.
*
* Pass `null` to opt out. If a status was previously set, transitioning to
* `null` emits CLEAR_TAB_STATUS so toggling off mid-session doesn't leave
* a stale dot. Process-exit cleanup is handled by ink.tsx's unmount path.
*/
export function useTabStatus(kind: TabStatusKind | null): void {
const writeRaw = useContext(TerminalWriteContext)
const prevKindRef = useRef<TabStatusKind | null>(null)
useEffect(() => {
// When kind transitions from non-null to null (e.g. user toggles off
// showStatusInTerminalTab mid-session), clear the stale dot.
if (kind === null) {
if (prevKindRef.current !== null && writeRaw && supportsTabStatus()) {
writeRaw(wrapForMultiplexer(CLEAR_TAB_STATUS))
}
prevKindRef.current = null
return
}
prevKindRef.current = kind
if (!writeRaw || !supportsTabStatus()) return
writeRaw(wrapForMultiplexer(tabStatus(TAB_STATUS_PRESETS[kind])))
}, [kind, writeRaw])
}
+16
View File
@@ -0,0 +1,16 @@
import { useContext } from 'react'
import TerminalFocusContext from '../components/TerminalFocusContext.js'
/**
* Hook to check if the terminal has focus.
*
* Uses DECSET 1004 focus reporting - the terminal sends escape sequences
* when it gains or loses focus. These are handled automatically
* by Ink and filtered from useInput.
*
* @returns true if the terminal is focused (or focus state is unknown)
*/
export function useTerminalFocus(): boolean {
const { isTerminalFocused } = useContext(TerminalFocusContext)
return isTerminalFocused
}
+31
View File
@@ -0,0 +1,31 @@
import { useContext, useEffect } from 'react'
import stripAnsi from 'strip-ansi'
import { OSC, osc } from '../termio/osc.js'
import { TerminalWriteContext } from '../useTerminalNotification.js'
/**
* Declaratively set the terminal tab/window title.
*
* Pass a string to set the title. ANSI escape sequences are stripped
* automatically so callers don't need to know about terminal encoding.
* Pass `null` to opt out — the hook becomes a no-op and leaves the
* terminal title untouched.
*
* On Windows, uses `process.title` (classic conhost doesn't support OSC).
* Elsewhere, writes OSC 0 (set title+icon) via Ink's stdout.
*/
export function useTerminalTitle(title: string | null): void {
const writeRaw = useContext(TerminalWriteContext)
useEffect(() => {
if (title === null || !writeRaw) return
const clean = stripAnsi(title)
if (process.platform === 'win32') {
process.title = clean
} else {
writeRaw(osc(OSC.SET_TITLE_AND_ICON, clean))
}
}, [title, writeRaw])
}
+96
View File
@@ -0,0 +1,96 @@
import { useCallback, useContext, useLayoutEffect, useRef } from 'react'
import { TerminalSizeContext } from '../components/TerminalSizeContext.js'
import type { DOMElement } from '../dom.js'
type ViewportEntry = {
/**
* Whether the element is currently within the terminal viewport
*/
isVisible: boolean
}
/**
* Hook to detect if a component is within the terminal viewport.
*
* Returns a callback ref and a viewport entry object.
* Attach the ref to the component you want to track.
*
* The entry is updated during the layout phase (useLayoutEffect) so callers
* always read fresh values during render. Visibility changes do NOT trigger
* re-renders on their own — callers that re-render for other reasons (e.g.
* animation ticks, state changes) will pick up the latest value naturally.
* This avoids infinite update loops when combined with other layout effects
* that also call setState.
*
* @example
* const [ref, entry] = useTerminalViewport()
* return <Box ref={ref}><Animation enabled={entry.isVisible}>...</Animation></Box>
*/
export function useTerminalViewport(): [
ref: (element: DOMElement | null) => void,
entry: ViewportEntry,
] {
const terminalSize = useContext(TerminalSizeContext)
const elementRef = useRef<DOMElement | null>(null)
const entryRef = useRef<ViewportEntry>({ isVisible: true })
const setElement = useCallback((el: DOMElement | null) => {
elementRef.current = el
}, [])
// Runs on every render because yoga layout values can change
// without React being aware. Only updates the ref — no setState
// to avoid cascading re-renders during the commit phase.
// Walks the DOM ancestor chain fresh each time to avoid holding stale
// references after yoga tree rebuilds.
useLayoutEffect(() => {
const element = elementRef.current
if (!element?.yogaNode || !terminalSize) {
return
}
const height = element.yogaNode.getComputedHeight()
const rows = terminalSize.rows
// Walk the DOM parent chain (not yoga.getParent()) so we can detect
// scroll containers and subtract their scrollTop. Yoga computes layout
// positions without scroll offset — scrollTop is applied at render time.
// Without this, an element inside a ScrollBox whose yoga position exceeds
// terminalRows would be considered offscreen even when scrolled into view
// (e.g., the spinner in fullscreen mode after enough messages accumulate).
let absoluteTop = element.yogaNode.getComputedTop()
let parent: DOMElement | undefined = element.parentNode
let root = element.yogaNode
while (parent) {
if (parent.yogaNode) {
absoluteTop += parent.yogaNode.getComputedTop()
root = parent.yogaNode
}
// scrollTop is only ever set on scroll containers (by ScrollBox + renderer).
// Non-scroll nodes have undefined scrollTop → falsy fast-path.
if (parent.scrollTop) absoluteTop -= parent.scrollTop
parent = parent.parentNode
}
// Only the root's height matters
const screenHeight = root.getComputedHeight()
const bottom = absoluteTop + height
// When content overflows the viewport (screenHeight > rows), the
// cursor-restore at frame end scrolls one extra row into scrollback.
// log-update.ts accounts for this with scrollbackRows = viewportY + 1.
// We must match, otherwise an element at the boundary is considered
// "visible" here (animation keeps ticking) but its row is treated as
// scrollback by log-update (content change → full reset → flicker).
const cursorRestoreScroll = screenHeight > rows ? 1 : 0
const viewportY = Math.max(0, screenHeight - rows) + cursorRestoreScroll
const viewportBottom = viewportY + rows
const visible = bottom > viewportY && absoluteTop < viewportBottom
if (visible !== entryRef.current.isVisible) {
entryRef.current = { isVisible: visible }
}
})
return [setElement, entryRef.current]
}