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
+38
View File
@@ -0,0 +1,38 @@
import { Event } from './event.js'
/**
* Mouse click event. Fired on left-button release without drag, only when
* mouse tracking is enabled (i.e. inside <AlternateScreen>).
*
* Bubbles from the deepest hit node up through parentNode. Call
* stopImmediatePropagation() to prevent ancestors' onClick from firing.
*/
export class ClickEvent extends Event {
/** 0-indexed screen column of the click */
readonly col: number
/** 0-indexed screen row of the click */
readonly row: number
/**
* Click column relative to the current handler's Box (col - box.x).
* Recomputed by dispatchClick before each handler fires, so an onClick
* on a container sees coords relative to that container, not to any
* child the click landed on.
*/
localCol = 0
/** Click row relative to the current handler's Box (row - box.y). */
localRow = 0
/**
* True if the clicked cell has no visible content (unwritten in the
* screen buffer — both packed words are 0). Handlers can check this to
* ignore clicks on blank space to the right of text, so accidental
* clicks on empty terminal space don't toggle state.
*/
readonly cellIsBlank: boolean
constructor(col: number, row: number, cellIsBlank: boolean) {
super()
this.col = col
this.row = row
this.cellIsBlank = cellIsBlank
}
}
+233
View File
@@ -0,0 +1,233 @@
import {
ContinuousEventPriority,
DefaultEventPriority,
DiscreteEventPriority,
NoEventPriority,
} from 'react-reconciler/constants.js'
import { logError } from '../../utils/log.js'
import { HANDLER_FOR_EVENT } from './event-handlers.js'
import type { EventTarget, TerminalEvent } from './terminal-event.js'
// --
type DispatchListener = {
node: EventTarget
handler: (event: TerminalEvent) => void
phase: 'capturing' | 'at_target' | 'bubbling'
}
function getHandler(
node: EventTarget,
eventType: string,
capture: boolean,
): ((event: TerminalEvent) => void) | undefined {
const handlers = node._eventHandlers
if (!handlers) return undefined
const mapping = HANDLER_FOR_EVENT[eventType]
if (!mapping) return undefined
const propName = capture ? mapping.capture : mapping.bubble
if (!propName) return undefined
return handlers[propName] as ((event: TerminalEvent) => void) | undefined
}
/**
* Collect all listeners for an event in dispatch order.
*
* Uses react-dom's two-phase accumulation pattern:
* - Walk from target to root
* - Capture handlers are prepended (unshift) → root-first
* - Bubble handlers are appended (push) → target-first
*
* Result: [root-cap, ..., parent-cap, target-cap, target-bub, parent-bub, ..., root-bub]
*/
function collectListeners(
target: EventTarget,
event: TerminalEvent,
): DispatchListener[] {
const listeners: DispatchListener[] = []
let node: EventTarget | undefined = target
while (node) {
const isTarget = node === target
const captureHandler = getHandler(node, event.type, true)
const bubbleHandler = getHandler(node, event.type, false)
if (captureHandler) {
listeners.unshift({
node,
handler: captureHandler,
phase: isTarget ? 'at_target' : 'capturing',
})
}
if (bubbleHandler && (event.bubbles || isTarget)) {
listeners.push({
node,
handler: bubbleHandler,
phase: isTarget ? 'at_target' : 'bubbling',
})
}
node = node.parentNode
}
return listeners
}
/**
* Execute collected listeners with propagation control.
*
* Before each handler, calls event._prepareForTarget(node) so event
* subclasses can do per-node setup.
*/
function processDispatchQueue(
listeners: DispatchListener[],
event: TerminalEvent,
): void {
let previousNode: EventTarget | undefined
for (const { node, handler, phase } of listeners) {
if (event._isImmediatePropagationStopped()) {
break
}
if (event._isPropagationStopped() && node !== previousNode) {
break
}
event._setEventPhase(phase)
event._setCurrentTarget(node)
event._prepareForTarget(node)
try {
handler(event)
} catch (error) {
logError(error)
}
previousNode = node
}
}
// --
/**
* Map terminal event types to React scheduling priorities.
* Mirrors react-dom's getEventPriority() switch.
*/
function getEventPriority(eventType: string): number {
switch (eventType) {
case 'keydown':
case 'keyup':
case 'click':
case 'focus':
case 'blur':
case 'paste':
return DiscreteEventPriority as number
case 'resize':
case 'scroll':
case 'mousemove':
return ContinuousEventPriority as number
default:
return DefaultEventPriority as number
}
}
// --
type DiscreteUpdates = <A, B>(
fn: (a: A, b: B) => boolean,
a: A,
b: B,
c: undefined,
d: undefined,
) => boolean
/**
* Owns event dispatch state and the capture/bubble dispatch loop.
*
* The reconciler host config reads currentEvent and currentUpdatePriority
* to implement resolveUpdatePriority, resolveEventType, and
* resolveEventTimeStamp — mirroring how react-dom's host config reads
* ReactDOMSharedInternals and window.event.
*
* discreteUpdates is injected after construction (by InkReconciler)
* to break the import cycle.
*/
export class Dispatcher {
currentEvent: TerminalEvent | null = null
currentUpdatePriority: number = DefaultEventPriority as number
discreteUpdates: DiscreteUpdates | null = null
/**
* Infer event priority from the currently-dispatching event.
* Called by the reconciler host config's resolveUpdatePriority
* when no explicit priority has been set.
*/
resolveEventPriority(): number {
if (this.currentUpdatePriority !== (NoEventPriority as number)) {
return this.currentUpdatePriority
}
if (this.currentEvent) {
return getEventPriority(this.currentEvent.type)
}
return DefaultEventPriority as number
}
/**
* Dispatch an event through capture and bubble phases.
* Returns true if preventDefault() was NOT called.
*/
dispatch(target: EventTarget, event: TerminalEvent): boolean {
const previousEvent = this.currentEvent
this.currentEvent = event
try {
event._setTarget(target)
const listeners = collectListeners(target, event)
processDispatchQueue(listeners, event)
event._setEventPhase('none')
event._setCurrentTarget(null)
return !event.defaultPrevented
} finally {
this.currentEvent = previousEvent
}
}
/**
* Dispatch with discrete (sync) priority.
* For user-initiated events: keyboard, click, focus, paste.
*/
dispatchDiscrete(target: EventTarget, event: TerminalEvent): boolean {
if (!this.discreteUpdates) {
return this.dispatch(target, event)
}
return this.discreteUpdates(
(t, e) => this.dispatch(t, e),
target,
event,
undefined,
undefined,
)
}
/**
* Dispatch with continuous priority.
* For high-frequency events: resize, scroll, mouse move.
*/
dispatchContinuous(target: EventTarget, event: TerminalEvent): boolean {
const previousPriority = this.currentUpdatePriority
try {
this.currentUpdatePriority = ContinuousEventPriority as number
return this.dispatch(target, event)
} finally {
this.currentUpdatePriority = previousPriority
}
}
}
+39
View File
@@ -0,0 +1,39 @@
import { EventEmitter as NodeEventEmitter } from 'events'
import { Event } from './event.js'
// Similar to node's builtin EventEmitter, but is also aware of our `Event`
// class, and so `emit` respects `stopImmediatePropagation()`.
export class EventEmitter extends NodeEventEmitter {
constructor() {
super()
// Disable the default maxListeners warning. In React, many components
// can legitimately listen to the same event (e.g., useInput hooks).
// The default limit of 10 causes spurious warnings.
this.setMaxListeners(0)
}
override emit(type: string | symbol, ...args: unknown[]): boolean {
// Delegate to node for `error`, since it's not treated like a normal event
if (type === 'error') {
return super.emit(type, ...args)
}
const listeners = this.rawListeners(type)
if (listeners.length === 0) {
return false
}
const ccEvent = args[0] instanceof Event ? args[0] : null
for (const listener of listeners) {
listener.apply(this, args)
if (ccEvent?.didStopImmediatePropagation()) {
break
}
}
return true
}
}
+73
View File
@@ -0,0 +1,73 @@
import type { ClickEvent } from './click-event.js'
import type { FocusEvent } from './focus-event.js'
import type { KeyboardEvent } from './keyboard-event.js'
import type { PasteEvent } from './paste-event.js'
import type { ResizeEvent } from './resize-event.js'
type KeyboardEventHandler = (event: KeyboardEvent) => void
type FocusEventHandler = (event: FocusEvent) => void
type PasteEventHandler = (event: PasteEvent) => void
type ResizeEventHandler = (event: ResizeEvent) => void
type ClickEventHandler = (event: ClickEvent) => void
type HoverEventHandler = () => void
/**
* Props for event handlers on Box and other host components.
*
* Follows the React/DOM naming convention:
* - onEventName: handler for bubble phase
* - onEventNameCapture: handler for capture phase
*/
export type EventHandlerProps = {
onKeyDown?: KeyboardEventHandler
onKeyDownCapture?: KeyboardEventHandler
onFocus?: FocusEventHandler
onFocusCapture?: FocusEventHandler
onBlur?: FocusEventHandler
onBlurCapture?: FocusEventHandler
onPaste?: PasteEventHandler
onPasteCapture?: PasteEventHandler
onResize?: ResizeEventHandler
onClick?: ClickEventHandler
onMouseEnter?: HoverEventHandler
onMouseLeave?: HoverEventHandler
}
/**
* Reverse lookup: event type string → handler prop names.
* Used by the dispatcher for O(1) handler lookup per node.
*/
export const HANDLER_FOR_EVENT: Record<
string,
{ bubble?: keyof EventHandlerProps; capture?: keyof EventHandlerProps }
> = {
keydown: { bubble: 'onKeyDown', capture: 'onKeyDownCapture' },
focus: { bubble: 'onFocus', capture: 'onFocusCapture' },
blur: { bubble: 'onBlur', capture: 'onBlurCapture' },
paste: { bubble: 'onPaste', capture: 'onPasteCapture' },
resize: { bubble: 'onResize' },
click: { bubble: 'onClick' },
}
/**
* Set of all event handler prop names, for the reconciler to detect
* event props and store them in _eventHandlers instead of attributes.
*/
export const EVENT_HANDLER_PROPS = new Set<string>([
'onKeyDown',
'onKeyDownCapture',
'onFocus',
'onFocusCapture',
'onBlur',
'onBlurCapture',
'onPaste',
'onPasteCapture',
'onResize',
'onClick',
'onMouseEnter',
'onMouseLeave',
])
+11
View File
@@ -0,0 +1,11 @@
export class Event {
private _didStopImmediatePropagation = false
didStopImmediatePropagation(): boolean {
return this._didStopImmediatePropagation
}
stopImmediatePropagation(): void {
this._didStopImmediatePropagation = true
}
}
+21
View File
@@ -0,0 +1,21 @@
import { type EventTarget, TerminalEvent } from './terminal-event.js'
/**
* Focus event for component focus changes.
*
* Dispatched when focus moves between elements. 'focus' fires on the
* newly focused element, 'blur' fires on the previously focused one.
* Both bubble, matching react-dom's use of focusin/focusout semantics
* so parent components can observe descendant focus changes.
*/
export class FocusEvent extends TerminalEvent {
readonly relatedTarget: EventTarget | null
constructor(
type: 'focus' | 'blur',
relatedTarget: EventTarget | null = null,
) {
super(type, { bubbles: true, cancelable: false })
this.relatedTarget = relatedTarget
}
}
+205
View File
@@ -0,0 +1,205 @@
import { nonAlphanumericKeys, type ParsedKey } from '../parse-keypress.js'
import { Event } from './event.js'
export type Key = {
upArrow: boolean
downArrow: boolean
leftArrow: boolean
rightArrow: boolean
pageDown: boolean
pageUp: boolean
wheelUp: boolean
wheelDown: boolean
home: boolean
end: boolean
return: boolean
escape: boolean
ctrl: boolean
shift: boolean
fn: boolean
tab: boolean
backspace: boolean
delete: boolean
meta: boolean
super: boolean
}
function parseKey(keypress: ParsedKey): [Key, string] {
const key: Key = {
upArrow: keypress.name === 'up',
downArrow: keypress.name === 'down',
leftArrow: keypress.name === 'left',
rightArrow: keypress.name === 'right',
pageDown: keypress.name === 'pagedown',
pageUp: keypress.name === 'pageup',
wheelUp: keypress.name === 'wheelup',
wheelDown: keypress.name === 'wheeldown',
home: keypress.name === 'home',
end: keypress.name === 'end',
return: keypress.name === 'return',
escape: keypress.name === 'escape',
fn: keypress.fn,
ctrl: keypress.ctrl,
shift: keypress.shift,
tab: keypress.name === 'tab',
backspace: keypress.name === 'backspace',
delete: keypress.name === 'delete',
// `parseKeypress` parses \u001B\u001B[A (meta + up arrow) as meta = false
// but with option = true, so we need to take this into account here
// to avoid breaking changes in Ink.
// TODO(vadimdemedes): consider removing this in the next major version.
meta: keypress.meta || keypress.name === 'escape' || keypress.option,
// Super (Cmd on macOS / Win key) — only arrives via kitty keyboard
// protocol CSI u sequences. Distinct from meta (Alt/Option) so
// bindings like cmd+c can be expressed separately from opt+c.
super: keypress.super,
}
let input = keypress.ctrl ? keypress.name : keypress.sequence
// Handle undefined input case
if (input === undefined) {
input = ''
}
// When ctrl is set, keypress.name for space is the literal word "space".
// Convert to actual space character for consistency with the CSI u branch
// (which maps 'space' → ' '). Without this, ctrl+space leaks the literal
// word "space" into text input.
if (keypress.ctrl && input === 'space') {
input = ' '
}
// Suppress unrecognized escape sequences that were parsed as function keys
// (matched by FN_KEY_RE) but have no name in the keyName map.
// Examples: ESC[25~ (F13/Right Alt on Windows), ESC[26~ (F14), etc.
// Without this, the ESC prefix is stripped below and the remainder (e.g.,
// "[25~") leaks into the input as literal text.
if (keypress.code && !keypress.name) {
input = ''
}
// Suppress ESC-less SGR mouse fragments. When a heavy React commit blocks
// the event loop past App's 50ms NORMAL_TIMEOUT flush, a CSI split across
// stdin chunks gets its buffered ESC flushed as a lone Escape key, and the
// continuation arrives as a text token with name='' — which falls through
// all of parseKeypress's ESC-anchored regexes and the nonAlphanumericKeys
// clear below (name is falsy). The fragment then leaks into the prompt as
// literal `[<64;74;16M`. This is the same defensive sink as the F13 guard
// above; the underlying tokenizer-flush race is upstream of this layer.
if (!keypress.name && /^\[<\d+;\d+;\d+[Mm]/.test(input)) {
input = ''
}
// Strip meta if it's still remaining after `parseKeypress`
// TODO(vadimdemedes): remove this in the next major version.
if (input.startsWith('\u001B')) {
input = input.slice(1)
}
// Track whether we've already processed this as a special sequence
// that converted input to the key name (CSI u or application keypad mode).
// For these, we don't want to clear input with nonAlphanumericKeys check.
let processedAsSpecialSequence = false
// Handle CSI u sequences (Kitty keyboard protocol): after stripping ESC,
// we're left with "[codepoint;modifieru" (e.g., "[98;3u" for Alt+b).
// Use the parsed key name instead for input handling. Require a digit
// after [ — real CSI u is always [<digits>…u, and a bare startsWith('[')
// false-matches X10 mouse at row 85 (Cy = 85+32 = 'u'), leaking the
// literal text "mouse" into the prompt via processedAsSpecialSequence.
if (/^\[\d/.test(input) && input.endsWith('u')) {
if (!keypress.name) {
// Unmapped Kitty functional key (Caps Lock 57358, F13F35, KP nav,
// bare modifiers, etc.) — keycodeToName() returned undefined. Swallow
// so the raw "[57358u" doesn't leak into the prompt. See #38781.
input = ''
} else {
// 'space' → ' '; 'escape' → '' (key.escape carries it;
// processedAsSpecialSequence bypasses the nonAlphanumericKeys
// clear below, so we must handle it explicitly here);
// otherwise use key name.
input =
keypress.name === 'space'
? ' '
: keypress.name === 'escape'
? ''
: keypress.name
}
processedAsSpecialSequence = true
}
// Handle xterm modifyOtherKeys sequences: after stripping ESC, we're left
// with "[27;modifier;keycode~" (e.g., "[27;3;98~" for Alt+b). Same
// extraction as CSI u — without this, printable-char keycodes (single-letter
// names) skip the nonAlphanumericKeys clear and leak "[27;..." as input.
if (input.startsWith('[27;') && input.endsWith('~')) {
if (!keypress.name) {
// Unmapped modifyOtherKeys keycode — swallow for consistency with
// the CSI u handler above. Practically untriggerable today (xterm
// modifyOtherKeys only sends ASCII keycodes, all mapped), but
// guards against future terminal behavior.
input = ''
} else {
input =
keypress.name === 'space'
? ' '
: keypress.name === 'escape'
? ''
: keypress.name
}
processedAsSpecialSequence = true
}
// Handle application keypad mode sequences: after stripping ESC,
// we're left with "O<letter>" (e.g., "Op" for numpad 0, "Oy" for numpad 9).
// Use the parsed key name (the digit character) for input handling.
if (
input.startsWith('O') &&
input.length === 2 &&
keypress.name &&
keypress.name.length === 1
) {
input = keypress.name
processedAsSpecialSequence = true
}
// Clear input for non-alphanumeric keys (arrows, function keys, etc.)
// Skip this for CSI u and application keypad mode sequences since
// those were already converted to their proper input characters.
if (
!processedAsSpecialSequence &&
keypress.name &&
nonAlphanumericKeys.includes(keypress.name)
) {
input = ''
}
// Set shift=true for uppercase letters (A-Z)
// Must check it's actually a letter, not just any char unchanged by toUpperCase
if (
input.length === 1 &&
typeof input[0] === 'string' &&
input[0] >= 'A' &&
input[0] <= 'Z'
) {
key.shift = true
}
return [key, input]
}
export class InputEvent extends Event {
readonly keypress: ParsedKey
readonly key: Key
readonly input: string
constructor(keypress: ParsedKey) {
super()
const [key, input] = parseKey(keypress)
this.keypress = keypress
this.key = key
this.input = input
}
}
+51
View File
@@ -0,0 +1,51 @@
import type { ParsedKey } from '../parse-keypress.js'
import { TerminalEvent } from './terminal-event.js'
/**
* Keyboard event dispatched through the DOM tree via capture/bubble.
*
* Follows browser KeyboardEvent semantics: `key` is the literal character
* for printable keys ('a', '3', ' ', '/') and a multi-char name for
* special keys ('down', 'return', 'escape', 'f1'). The idiomatic
* printable-char check is `e.key.length === 1`.
*/
export class KeyboardEvent extends TerminalEvent {
readonly key: string
readonly ctrl: boolean
readonly shift: boolean
readonly meta: boolean
readonly superKey: boolean
readonly fn: boolean
constructor(parsedKey: ParsedKey) {
super('keydown', { bubbles: true, cancelable: true })
this.key = keyFromParsed(parsedKey)
this.ctrl = parsedKey.ctrl
this.shift = parsedKey.shift
this.meta = parsedKey.meta || parsedKey.option
this.superKey = parsedKey.super
this.fn = parsedKey.fn
}
}
function keyFromParsed(parsed: ParsedKey): string {
const seq = parsed.sequence ?? ''
const name = parsed.name ?? ''
// Ctrl combos: sequence is a control byte (\x03 for ctrl+c), name is the
// letter. Browsers report e.key === 'c' with e.ctrlKey === true.
if (parsed.ctrl) return name
// Single printable char (space through ~, plus anything above ASCII):
// use the literal char. Browsers report e.key === '3', not 'Digit3'.
if (seq.length === 1) {
const code = seq.charCodeAt(0)
if (code >= 0x20 && code !== 0x7f) return seq
}
// Special keys (arrows, F-keys, return, tab, escape, etc.): sequence is
// either an escape sequence (\x1b[B) or a control byte (\r, \t), so use
// the parsed name. Browsers report e.key === 'ArrowDown'.
return name || seq
}
+107
View File
@@ -0,0 +1,107 @@
import { Event } from './event.js'
type EventPhase = 'none' | 'capturing' | 'at_target' | 'bubbling'
type TerminalEventInit = {
bubbles?: boolean
cancelable?: boolean
}
/**
* Base class for all terminal events with DOM-style propagation.
*
* Extends Event so existing event types (ClickEvent, InputEvent,
* TerminalFocusEvent) share a common ancestor and can migrate later.
*
* Mirrors the browser's Event API: target, currentTarget, eventPhase,
* stopPropagation(), preventDefault(), timeStamp.
*/
export class TerminalEvent extends Event {
readonly type: string
readonly timeStamp: number
readonly bubbles: boolean
readonly cancelable: boolean
private _target: EventTarget | null = null
private _currentTarget: EventTarget | null = null
private _eventPhase: EventPhase = 'none'
private _propagationStopped = false
private _defaultPrevented = false
constructor(type: string, init?: TerminalEventInit) {
super()
this.type = type
this.timeStamp = performance.now()
this.bubbles = init?.bubbles ?? true
this.cancelable = init?.cancelable ?? true
}
get target(): EventTarget | null {
return this._target
}
get currentTarget(): EventTarget | null {
return this._currentTarget
}
get eventPhase(): EventPhase {
return this._eventPhase
}
get defaultPrevented(): boolean {
return this._defaultPrevented
}
stopPropagation(): void {
this._propagationStopped = true
}
override stopImmediatePropagation(): void {
super.stopImmediatePropagation()
this._propagationStopped = true
}
preventDefault(): void {
if (this.cancelable) {
this._defaultPrevented = true
}
}
// -- Internal setters used by the Dispatcher
/** @internal */
_setTarget(target: EventTarget): void {
this._target = target
}
/** @internal */
_setCurrentTarget(target: EventTarget | null): void {
this._currentTarget = target
}
/** @internal */
_setEventPhase(phase: EventPhase): void {
this._eventPhase = phase
}
/** @internal */
_isPropagationStopped(): boolean {
return this._propagationStopped
}
/** @internal */
_isImmediatePropagationStopped(): boolean {
return this.didStopImmediatePropagation()
}
/**
* Hook for subclasses to do per-node setup before each handler fires.
* Default is a no-op.
*/
_prepareForTarget(_target: EventTarget): void {}
}
export type EventTarget = {
parentNode: EventTarget | undefined
_eventHandlers?: Record<string, unknown>
}
+19
View File
@@ -0,0 +1,19 @@
import { Event } from './event.js'
export type TerminalFocusEventType = 'terminalfocus' | 'terminalblur'
/**
* Event fired when the terminal window gains or loses focus.
*
* Uses DECSET 1004 focus reporting - the terminal sends:
* - CSI I (\x1b[I) when the terminal gains focus
* - CSI O (\x1b[O) when the terminal loses focus
*/
export class TerminalFocusEvent extends Event {
readonly type: TerminalFocusEventType
constructor(type: TerminalFocusEventType) {
super()
this.type = type
}
}