import React, { PureComponent, type ReactNode } from 'react' // Business-layer callbacks — replaced with inline defaults so this package // has zero dependencies on business code. The business layer can inject // implementations via AppCallbacks when needed. type AppCallbacks = { updateLastInteractionTime?: () => void stopCapturingEarlyInput?: () => void isMouseClicksDisabled?: () => boolean logError?: (error: unknown) => void logForDebugging?: (message: string, opts?: { level?: string }) => void } /** Default no-op / safe-default implementations */ const defaultCallbacks: Required = { updateLastInteractionTime: () => {}, stopCapturingEarlyInput: () => {}, isMouseClicksDisabled: () => false, logError: (error: unknown) => console.error(error), logForDebugging: (_message: string, _opts?: { level?: string }) => {}, } /** * Override the default no-op callbacks. Call this from the business layer * (e.g. src/ink.tsx) before mounting . */ export function setAppCallbacks(cb: AppCallbacks): void { Object.assign(defaultCallbacks, cb) } function isEnvTruthy(value: string | undefined): boolean { return value === '1' || value === 'true' } import { EventEmitter } from '../core/events/emitter.js' import { InputEvent } from '../core/events/input-event.js' import { TerminalFocusEvent } from '../core/events/terminal-focus-event.js' import { INITIAL_STATE, type ParsedInput, type ParsedKey, type ParsedMouse, parseMultipleKeypresses, } from '../core/parse-keypress.js' import reconciler from '../core/reconciler.js' import { finishSelection, hasSelection, type SelectionState, startSelection, } from '../core/selection.js' import { isXtermJs, setXtversionName, supportsExtendedKeys, } from '../core/terminal.js' import { getTerminalFocused, setTerminalFocused, } from '../core/terminal-focus-state.js' import { TerminalQuerier, xtversion } from '../core/terminal-querier.js' import { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS, ENABLE_KITTY_KEYBOARD, ENABLE_MODIFY_OTHER_KEYS, FOCUS_IN, FOCUS_OUT, } from '../core/termio/csi.js' import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, HIDE_CURSOR, SHOW_CURSOR, } from '../core/termio/dec.js' import AppContext from './AppContext.js' import { ClockProvider } from './ClockContext.js' import CursorDeclarationContext, { type CursorDeclarationSetter, } from './CursorDeclarationContext.js' import ErrorOverview from './ErrorOverview.js' import StdinContext from './StdinContext.js' import { TerminalFocusProvider } from './TerminalFocusContext.js' import { TerminalSizeContext } from './TerminalSizeContext.js' // Platforms that support Unix-style process suspension (SIGSTOP/SIGCONT) const SUPPORTS_SUSPEND = process.platform !== 'win32' // After this many milliseconds of stdin silence, the next chunk triggers // a terminal mode re-assert (mouse tracking). Catches tmux detach→attach, // ssh reconnect, and laptop wake — the terminal resets DEC private modes // but no signal reaches us. 5s is well above normal inter-keystroke gaps // but short enough that the first scroll after reattach works. const STDIN_RESUME_GAP_MS = 5000 type Props = { readonly children: ReactNode readonly stdin: NodeJS.ReadStream readonly stdout: NodeJS.WriteStream readonly stderr: NodeJS.WriteStream readonly exitOnCtrlC: boolean readonly onExit: (error?: Error) => void readonly terminalColumns: number readonly terminalRows: number // Text selection state. App mutates this directly from mouse events // and calls onSelectionChange to trigger a repaint. Mouse events only // arrive when (or similar) enables mouse tracking, // so the handler is always wired but dormant until tracking is on. readonly selection: SelectionState readonly onSelectionChange: () => void // Dispatch a click at (col, row) — hit-tests the DOM tree and bubbles // onClick handlers. Returns true if a DOM handler consumed the click. // No-op (returns false) outside fullscreen mode (Ink.dispatchClick // gates on altScreenActive). readonly onClickAt: (col: number, row: number) => boolean // Dispatch hover (onMouseEnter/onMouseLeave) as the pointer moves over // DOM elements. Called for mode-1003 motion events with no button held. // No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive). readonly onHoverAt: (col: number, row: number) => void // Look up the OSC 8 hyperlink at (col, row) synchronously at click // time. Returns the URL or undefined. The browser-open is deferred by // MULTI_CLICK_TIMEOUT_MS so double-click can cancel it. readonly getHyperlinkAt: (col: number, row: number) => string | undefined // Open a hyperlink URL in the browser. Called after the timer fires. readonly onOpenHyperlink: (url: string) => void // Called on double/triple-click PRESS at (col, row). count=2 selects // the word under the cursor; count=3 selects the line. Ink reads the // screen buffer to find word/line boundaries and mutates selection, // setting isDragging=true so a subsequent drag extends by word/line. readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void // Called on drag-motion. Mode-aware: char mode updates focus to the // exact cell; word/line mode snaps to word/line boundaries. Needs // screen-buffer access (word boundaries) so lives on Ink, not here. readonly onSelectionDrag: (col: number, row: number) => void // Called when stdin data arrives after a >STDIN_RESUME_GAP_MS gap. // Ink re-asserts terminal modes: extended key reporting, and (when in // fullscreen) re-enters alt-screen + mouse tracking. Idempotent on the // terminal side. Optional so testing.tsx doesn't need to stub it. readonly onStdinResume?: () => void // Receives the declared native-cursor position from useDeclaredCursor // so ink.tsx can park the terminal cursor there after each frame. // Enables IME composition at the input caret and lets screen readers / // magnifiers track the input. Optional so testing.tsx doesn't stub it. readonly onCursorDeclaration?: CursorDeclarationSetter // Dispatch a keyboard event through the DOM tree. Called for each // parsed key alongside the legacy EventEmitter path. readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => void } // Multi-click detection thresholds. 500ms is the macOS default; a small // position tolerance allows for trackpad jitter between clicks. const MULTI_CLICK_TIMEOUT_MS = 500 const MULTI_CLICK_DISTANCE = 1 type State = { readonly error?: Error } // Root component for all Ink apps // It renders stdin and stdout contexts, so that children can access them if needed // It also handles Ctrl+C exiting and cursor visibility export default class App extends PureComponent { static displayName = 'InternalApp' static getDerivedStateFromError(error: Error) { return { error } } override state = { error: undefined, } // Count how many components enabled raw mode to avoid disabling // raw mode until all components don't need it anymore rawModeEnabledCount = 0 internal_eventEmitter = new EventEmitter() keyParseState = INITIAL_STATE // Timer for flushing incomplete escape sequences incompleteEscapeTimer: NodeJS.Timeout | null = null // Timeout durations for incomplete sequences (ms) readonly NORMAL_TIMEOUT = 50 // Short timeout for regular esc sequences readonly PASTE_TIMEOUT = 500 // Longer timeout for paste operations // Terminal query/response dispatch. Responses arrive on stdin (parsed // out by parse-keypress) and are routed to pending promise resolvers. querier = new TerminalQuerier(this.props.stdout) // Multi-click tracking for double/triple-click text selection. A click // within MULTI_CLICK_TIMEOUT_MS and MULTI_CLICK_DISTANCE of the previous // click increments clickCount; otherwise it resets to 1. lastClickTime = 0 lastClickCol = -1 lastClickRow = -1 clickCount = 0 // Deferred hyperlink-open timer — cancelled if a second click arrives // within MULTI_CLICK_TIMEOUT_MS (so double-clicking a hyperlink selects // the word without also opening the browser). DOM onClick dispatch is // NOT deferred — it returns true from onClickAt and skips this timer. pendingHyperlinkTimer: ReturnType | null = null // Last mode-1003 motion position. Terminals already dedupe to cell // granularity but this also lets us skip dispatchHover entirely on // repeat events (drag-then-release at same cell, etc.). lastHoverCol = -1 lastHoverRow = -1 // Timestamp of last stdin chunk. Used to detect long gaps (tmux attach, // ssh reconnect, laptop wake) and trigger terminal mode re-assert. // Initialized to now so startup doesn't false-trigger. lastStdinTime = Date.now() // Determines if TTY is supported on the provided stdin isRawModeSupported(): boolean { return this.props.stdin.isTTY } override render() { return ( {})} > {this.state.error ? ( ) : ( this.props.children )} ) } override componentDidMount() { // In accessibility mode, keep the native cursor visible for screen magnifiers and other tools if ( this.props.stdout.isTTY && !isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY) ) { this.props.stdout.write(HIDE_CURSOR) } } override componentWillUnmount() { if (this.props.stdout.isTTY) { this.props.stdout.write(SHOW_CURSOR) } // Clear any pending timers if (this.incompleteEscapeTimer) { clearTimeout(this.incompleteEscapeTimer) this.incompleteEscapeTimer = null } if (this.pendingHyperlinkTimer) { clearTimeout(this.pendingHyperlinkTimer) this.pendingHyperlinkTimer = null } // ignore calling setRawMode on an handle stdin it cannot be called if (this.isRawModeSupported()) { this.handleSetRawMode(false) } 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() } catch { // stdin may already be destroyed } } } override componentDidCatch(error: Error) { this.handleExit(error) } handleSetRawMode = (isEnabled: boolean): void => { const { stdin } = this.props if (!this.isRawModeSupported()) { if (stdin === process.stdin) { throw new Error( 'Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported', ) } else { throw new Error( 'Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported', ) } } stdin.setEncoding('utf8') if (isEnabled) { // Ensure raw mode is enabled only once if (this.rawModeEnabledCount === 0) { // Stop early input capture right before we add our own readable handler. // Both use the same stdin 'readable' + read() pattern, so they can't // coexist -- the early capture handler would drain stdin before ours // can see it. The buffered text is preserved for REPL.tsx via consumeEarlyInput(). 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') for (const listener of existingListeners) { if (listener !== this.handleReadable) { stdin.removeListener('readable', listener as any) } } stdin.ref() stdin.setRawMode(true) stdin.addListener('readable', this.handleReadable) // Enable bracketed paste mode this.props.stdout.write(EBP) // Enable terminal focus reporting (DECSET 1004) this.props.stdout.write(EFE) // Enable extended key reporting so ctrl+shift+ is // distinguishable from ctrl+. We write both the kitty stack // push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) — // terminals honor whichever they implement (tmux only accepts the // latter). if (supportsExtendedKeys()) { this.props.stdout.write(ENABLE_KITTY_KEYBOARD) this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS) } // Probe terminal identity. XTVERSION survives SSH (query/reply goes // through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base // detection when env vars are absent. Fire-and-forget: the DA1 // sentinel bounds the round-trip, and if the terminal ignores the // query, flush() still resolves and name stays undefined. // Deferred to next tick so it fires AFTER the current synchronous // init sequence completes — avoids interleaving with alt-screen/mouse // tracking enable writes that may happen in the same render cycle. setImmediate(() => { void Promise.all([ this.querier.send(xtversion()), this.querier.flush(), ]).then(([r]) => { if (r) { setXtversionName(r.name) defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`) } else { defaultCallbacks.logForDebugging('XTVERSION: no reply (terminal ignored query)') } }) }) } this.rawModeEnabledCount++ return } // Disable raw mode only when no components left that are using it if (--this.rawModeEnabledCount === 0) { // Guard: React 19 runs new useLayoutEffect setup before old cleanup when // replacing the tree (e.g., showSetupDialog → launchResumeChooser). // 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') if (activeListeners > 0) { this.rawModeEnabledCount = activeListeners return } this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS) this.props.stdout.write(DISABLE_KITTY_KEYBOARD) // Disable terminal focus reporting (DECSET 1004) this.props.stdout.write(DFE) // Disable bracketed paste mode this.props.stdout.write(DBP) stdin.setRawMode(false) stdin.removeListener('readable', this.handleReadable) stdin.unref() } } // Helper to flush incomplete escape sequences flushIncomplete = (): void => { // Clear the timer reference this.incompleteEscapeTimer = null // Only proceed if we have incomplete sequences if (!this.keyParseState.incomplete) return // Fullscreen: if stdin has data waiting, it's almost certainly the // continuation of the buffered sequence (e.g. `[<64;74;16M` after a // lone ESC). Node's event loop runs the timers phase before the poll // phase, so when a heavy render blocks the loop past 50ms, this timer // fires before the queued readable event even though the bytes are // already buffered. Re-arm instead of flushing: handleReadable will // drain stdin next and clear this timer. Prevents both the spurious // Escape key and the lost scroll event. if (this.props.stdin.readableLength > 0) { this.incompleteEscapeTimer = setTimeout( this.flushIncomplete, this.NORMAL_TIMEOUT, ) return } // Process incomplete as a flush operation (input=null) // This reuses all existing parsing logic this.processInput(null) } // Process input through the parser and handle the results processInput = (input: string | Buffer | null): void => { // Parse input using our state machine const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input) this.keyParseState = newState // Process ALL keys in a SINGLE discreteUpdates call to prevent // "Maximum update depth exceeded" error when many keys arrive at once // (e.g., from paste operations or holding keys rapidly). // This batches all state updates from handleInput and all useInput // listeners together within one high-priority update context. if (keys.length > 0) { reconciler.discreteUpdates( processKeysInBatch, this, keys, undefined, undefined, ) } // If we have incomplete escape sequences, set a timer to flush them if (this.keyParseState.incomplete) { // Cancel any existing timer first if (this.incompleteEscapeTimer) { clearTimeout(this.incompleteEscapeTimer) } this.incompleteEscapeTimer = setTimeout( this.flushIncomplete, this.keyParseState.mode === 'IN_PASTE' ? this.PASTE_TIMEOUT : this.NORMAL_TIMEOUT, ) } } handleReadable = (): void => { // Detect long stdin gaps (tmux attach, ssh reconnect, laptop wake). // The terminal may have reset DEC private modes; re-assert mouse // tracking. Checked before the read loop so one Date.now() covers // all chunks in this readable event. const now = Date.now() if (now - this.lastStdinTime > STDIN_RESUME_GAP_MS) { this.props.onStdinResume?.() } this.lastStdinTime = now try { let chunk while ((chunk = this.props.stdin.read() as string | null) !== null) { // Process the input chunk this.processInput(chunk) } } catch (error) { // In Bun, an uncaught throw inside a stream 'readable' handler can // permanently wedge the stream: data stays buffered and 'readable' // never re-emits. Catching here ensures the stream stays healthy so // subsequent keystrokes are still delivered. defaultCallbacks.logError(error) // Re-attach the listener in case the exception detached it. // Bun may remove the listener after an error; without this, // the session freezes permanently (stdin reader dead, event loop alive). const { stdin } = this.props if ( this.rawModeEnabledCount > 0 && !stdin.listeners('readable').includes(this.handleReadable) ) { defaultCallbacks.logForDebugging( 'handleReadable: re-attaching stdin readable listener after error recovery', { level: 'warn' }, ) stdin.addListener('readable', this.handleReadable) } } } handleInput = (input: string | undefined): void => { // Exit on Ctrl+C if (input === '\x03' && this.props.exitOnCtrlC) { this.handleExit() } // Note: Ctrl+Z (suspend) is now handled in processKeysInBatch using the // parsed key to support both raw (\x1a) and CSI u format from Kitty // keyboard protocol terminals (Ghostty, iTerm2, kitty, WezTerm) } handleExit = (error?: Error): void => { if (this.isRawModeSupported()) { this.handleSetRawMode(false) } this.props.onExit(error) } handleTerminalFocus = (isFocused: boolean): void => { // setTerminalFocused notifies subscribers: TerminalFocusProvider (context) // and Clock (interval speed) — no App setState needed. setTerminalFocused(isFocused) } handleSuspend = (): void => { if (!this.isRawModeSupported()) { return } // Store the exact raw mode count to restore it properly const rawModeCountBeforeSuspend = this.rawModeEnabledCount // Completely disable raw mode before suspending while (this.rawModeEnabledCount > 0) { this.handleSetRawMode(false) } // Show cursor, disable focus reporting, and disable mouse tracking // before suspending. DISABLE_MOUSE_TRACKING is a no-op if tracking // wasn't enabled, so it's safe to emit unconditionally — without // it, SGR mouse sequences would appear as garbled text at the // shell prompt while suspended. if (this.props.stdout.isTTY) { this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING) } // Emit suspend event for Claude Code to handle. Mostly just has a notification this.internal_eventEmitter.emit('suspend') // Set up resume handler const resumeHandler = () => { // Restore raw mode to exact previous state for (let i = 0; i < rawModeCountBeforeSuspend; i++) { if (this.isRawModeSupported()) { this.handleSetRawMode(true) } } // Hide cursor (unless in accessibility mode) and re-enable focus reporting after resuming if (this.props.stdout.isTTY) { if (!isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) { this.props.stdout.write(HIDE_CURSOR) } // Re-enable focus reporting to restore terminal state this.props.stdout.write(EFE) } // Emit resume event for Claude Code to handle this.internal_eventEmitter.emit('resume') process.removeListener('SIGCONT', resumeHandler) } process.on('SIGCONT', resumeHandler) process.kill(process.pid, 'SIGSTOP') } } // Helper to process all keys within a single discrete update context. // discreteUpdates expects (fn, a, b, c, d) -> fn(a, b, c, d) function processKeysInBatch( app: App, items: ParsedInput[], _unused1: undefined, _unused2: undefined, ): void { // Update interaction time for notification timeout tracking. // This is called from the central input handler to avoid having multiple // stdin listeners that can cause race conditions and dropped input. // Terminal responses (kind: 'response') are automated, not user input. // Mode-1003 no-button motion is also excluded — passive cursor drift is // not engagement (would suppress idle notifications + defer housekeeping). if ( items.some( i => i.kind === 'key' || (i.kind === 'mouse' && !((i.button & 0x20) !== 0 && (i.button & 0x03) === 3)), ) ) { defaultCallbacks.updateLastInteractionTime() } for (const item of items) { // Terminal responses (DECRPM, DA1, OSC replies, etc.) are not user // input — route them to the querier to resolve pending promises. if (item.kind === 'response') { app.querier.onResponse(item.response) continue } // Mouse click/drag events update selection state (fullscreen only). // Terminal sends 1-indexed col/row; convert to 0-indexed for the // screen buffer. Button bit 0x20 = drag (motion while button held). if (item.kind === 'mouse') { handleMouseEvent(app, item) continue } const sequence = item.sequence // Handle terminal focus events (DECSET 1004) if (sequence === FOCUS_IN) { app.handleTerminalFocus(true) const event = new TerminalFocusEvent('terminalfocus') app.internal_eventEmitter.emit('terminalfocus', event) continue } if (sequence === FOCUS_OUT) { app.handleTerminalFocus(false) // Defensive: if we lost the release event (mouse released outside // terminal window — some emulators drop it rather than capturing the // pointer), focus-out is the next observable signal that the drag is // over. Without this, drag-to-scroll's timer runs until the scroll // boundary is hit. if (app.props.selection.isDragging) { finishSelection(app.props.selection) app.props.onSelectionChange() } const event = new TerminalFocusEvent('terminalblur') app.internal_eventEmitter.emit('terminalblur', event) continue } // Failsafe: if we receive input, the terminal must be focused if (!getTerminalFocused()) { setTerminalFocused(true) } // Handle Ctrl+Z (suspend) using parsed key to support both raw (\x1a) and // CSI u format (\x1b[122;5u) from Kitty keyboard protocol terminals if (item.name === 'z' && item.ctrl && SUPPORTS_SUSPEND) { app.handleSuspend() continue } app.handleInput(sequence) const event = new InputEvent(item) app.internal_eventEmitter.emit('input', event) // Also dispatch through the DOM tree so onKeyDown handlers fire. app.props.dispatchKeyboardEvent(item) } } /** Exported for testing. Mutates app.props.selection and click/hover state. */ export function handleMouseEvent(app: App, m: ParsedMouse): void { // Allow disabling click handling while keeping wheel scroll (which goes // through the keybinding system as 'wheelup'/'wheeldown', not here). if (defaultCallbacks.isMouseClicksDisabled()) return const sel = app.props.selection // Terminal coords are 1-indexed; screen buffer is 0-indexed const col = m.col - 1 const row = m.row - 1 const baseButton = m.button & 0x03 if (m.action === 'press') { if ((m.button & 0x20) !== 0 && baseButton === 3) { // Mode-1003 motion with no button held. Dispatch hover; skip the // rest of this handler (no selection, no click-count side effects). // Lost-release recovery: no-button motion while isDragging=true means // the release happened outside the terminal window (iTerm2 doesn't // capture the pointer past window bounds, so the SGR 'm' never // arrives). Finish the selection here so copy-on-select fires. The // FOCUS_OUT handler covers the "switched apps" case but not "released // past the edge, came back" — and tmux drops focus events unless // `focus-events on` is set, so this is the more reliable signal. if (sel.isDragging) { finishSelection(sel) app.props.onSelectionChange() } if (col === app.lastHoverCol && row === app.lastHoverRow) return app.lastHoverCol = col app.lastHoverRow = row app.props.onHoverAt(col, row) return } if (baseButton !== 0) { // Non-left press breaks the multi-click chain. app.clickCount = 0 return } if ((m.button & 0x20) !== 0) { // Drag motion: mode-aware extension (char/word/line). onSelectionDrag // calls notifySelectionChange internally — no extra onSelectionChange. app.props.onSelectionDrag(col, row) return } // Lost-release fallback for mode-1002-only terminals: a fresh press // while isDragging=true means the previous release was dropped (cursor // left the window). Finish that selection so copy-on-select fires // before startSelection/onMultiClick clobbers it. Mode-1003 terminals // hit the no-button-motion recovery above instead, so this is rare. if (sel.isDragging) { finishSelection(sel) app.props.onSelectionChange() } // Fresh left press. Detect multi-click HERE (not on release) so the // word/line highlight appears immediately and a subsequent drag can // extend by word/line like native macOS. Previously detected on // release, which meant (a) visible latency before the word highlights // and (b) double-click+drag fell through to char-mode selection. const now = Date.now() const nearLast = now - app.lastClickTime < MULTI_CLICK_TIMEOUT_MS && Math.abs(col - app.lastClickCol) <= MULTI_CLICK_DISTANCE && Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE app.clickCount = nearLast ? app.clickCount + 1 : 1 app.lastClickTime = now app.lastClickCol = col app.lastClickRow = row if (app.clickCount >= 2) { // Cancel any pending hyperlink-open from the first click — this is // a double-click, not a single-click on a link. if (app.pendingHyperlinkTimer) { clearTimeout(app.pendingHyperlinkTimer) app.pendingHyperlinkTimer = null } // Cap at 3 (line select) for quadruple+ clicks. const count = app.clickCount === 2 ? 2 : 3 app.props.onMultiClick(col, row, count) return } startSelection(sel, col, row) // SGR bit 0x08 = alt (xterm.js wires altKey here, not metaKey — see // comment at the hyperlink-open guard below). On macOS xterm.js, // receiving alt means macOptionClickForcesSelection is OFF (otherwise // xterm.js would have consumed the event for native selection). sel.lastPressHadAlt = (m.button & 0x08) !== 0 app.props.onSelectionChange() return } // Release: end the drag even for non-zero button codes. Some terminals // encode release with the motion bit or button=3 "no button" (carried // over from pre-SGR X10 encoding) — filtering those would orphan // isDragging=true and leave drag-to-scroll's timer running until the // scroll boundary. Only act on non-left releases when we ARE dragging // (so an unrelated middle/right click-release doesn't touch selection). if (baseButton !== 0) { if (!sel.isDragging) return finishSelection(sel) app.props.onSelectionChange() return } finishSelection(sel) // NOTE: unlike the old release-based detection we do NOT reset clickCount // on release-after-drag. This aligns with NSEvent.clickCount semantics: // an intervening drag doesn't break the click chain. Practical upside: // trackpad jitter during an intended double-click (press→wobble→release // →press) now correctly resolves to word-select instead of breaking to a // fresh single click. The nearLast window (500ms, 1 cell) bounds the // effect — a deliberate drag past that just starts a fresh chain. // A press+release with no drag in char mode is a click: anchor set, // focus null → hasSelection false. In word/line mode the press already // set anchor+focus (hasSelection true), so release just keeps the // highlight. The anchor check guards against an orphaned release (no // prior press — e.g. button was held when mouse tracking was enabled). if (!hasSelection(sel) && sel.anchor) { // Single click: dispatch DOM click immediately (cursor repositioning // etc. are latency-sensitive). If no DOM handler consumed it, defer // the hyperlink check so a second click can cancel it. if (!app.props.onClickAt(col, row)) { // Resolve the hyperlink URL synchronously while the screen buffer // still reflects what the user clicked — deferring only the // browser-open so double-click can cancel it. const url = app.props.getHyperlinkAt(col, row) // xterm.js (VS Code, Cursor, Windsurf, etc.) has its own OSC 8 link // handler that fires on Cmd+click *without consuming the mouse event* // (Linkifier._handleMouseUp calls link.activate() but never // preventDefault/stopPropagation). The click is also forwarded to the // pty as SGR, so both VS Code's terminalLinkManager AND our handler // here would open the URL — twice. We can't filter on Cmd: xterm.js // drops metaKey before SGR encoding (ICoreMouseEvent has no meta // field; the SGR bit we call 'meta' is wired to alt). Let xterm.js // own link-opening; Cmd+click is the native UX there anyway. // TERM_PROGRAM is the sync fast-path; isXtermJs() is the XTVERSION // probe result (catches SSH + non-VS Code embedders like Hyper). if (url && process.env.TERM_PROGRAM !== 'vscode' && !isXtermJs()) { // Clear any prior pending timer — clicking a second link // supersedes the first (only the latest click opens). if (app.pendingHyperlinkTimer) { clearTimeout(app.pendingHyperlinkTimer) } app.pendingHyperlinkTimer = setTimeout( (app, url) => { app.pendingHyperlinkTimer = null app.props.onOpenHyperlink(url) }, MULTI_CLICK_TIMEOUT_MS, app, url, ) } } } app.props.onSelectionChange() }