diff --git a/packages/core/src/utils/ansi.ts b/packages/core/src/utils/ansi.ts index f292c2024..2a6d0e08b 100644 --- a/packages/core/src/utils/ansi.ts +++ b/packages/core/src/utils/ansi.ts @@ -166,48 +166,96 @@ export function stripAnsiControl(str: string): string { return out; } -// ── Clipboard ─────────────────────────────────────── +// ── Clipboard ────────────────────────────────────────── + +const TMUX_DCS_START = '\x1bPtmux;'; +const TMUX_DCS_END = '\x1b\\'; +const SCREEN_DCS_START = '\x1bP'; +const SCREEN_DCS_END = '\x1b\\'; + +/** + * Wraps an OSC escape sequence for passthrough through tmux or GNU screen. + * Without this, multiplexers intercept and drop OSC 52 sequences instead + * of forwarding them to the underlying terminal. + */ +function wrapForMultiplexer(sequence: string): string { + if (process.env.TMUX) { + // tmux requires ESC bytes inside the payload to be doubled + const escaped = sequence.replace(/\x1b/g, '\x1b\x1b'); + return `${TMUX_DCS_START}${escaped}${TMUX_DCS_END}`; + } + if (process.env.STY) { + // GNU screen: wrap in a Device Control String + return `${SCREEN_DCS_START}${sequence}${SCREEN_DCS_END}`; + } + return sequence; +} /** * Write text to the system clipboard via OSC 52. * Supported by: xterm, iTerm2, Kitty, WezTerm, Alacritty, Windows Terminal. + * Automatically wraps the sequence for tmux/screen passthrough when detected. * @param text Plain text to copy to clipboard * @param stdout Target stream (default: process.stdout) */ export function writeClipboard(text: string, stdout: NodeJS.WriteStream = process.stdout): void { const encoded = Buffer.from(text, 'utf8').toString('base64'); - stdout.write(`${OSC}52;c;${encoded}\x07`); + const sequence = `${OSC}52;c;${encoded}\x07`; + stdout.write(wrapForMultiplexer(sequence)); } + +export interface ReadClipboardOptions { + /** Milliseconds to wait for a terminal response before rejecting (default: 1000) */ + timeoutMs?: number; +} + +/** + * Read text from the system clipboard via OSC 52 query. + * Rejects if the terminal doesn't respond within `timeoutMs` (e.g. the + * terminal doesn't support OSC 52 queries), preventing an indefinite hang. + */ export function readClipboard( stdin: NodeJS.ReadStream = process.stdin, - stdout: NodeJS.WriteStream = process.stdout + stdout: NodeJS.WriteStream = process.stdout, + options: ReadClipboardOptions = {} ): Promise { + const timeoutMs = options.timeoutMs ?? 1000; + return new Promise((resolve, reject) => { + let settled = false; + + const cleanup = () => { + stdin.off('data', handler); + clearTimeout(timer); + }; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error('readClipboard timed out: terminal did not respond to OSC 52 query')); + }, timeoutMs); + const handler = (data: Buffer) => { const str = data.toString('utf8'); - const match = str.match(/\x1b\]52;c;([^\x07]+)\x07/); - if (!match) return; - - stdin.off('data', handler); - + if (settled) return; + settled = true; + cleanup(); try { - resolve( - Buffer.from(match[1], 'base64').toString('utf8') - ); + resolve(Buffer.from(match[1], 'base64').toString('utf8')); } catch (err) { reject(err); } }; stdin.on('data', handler); - - stdout.write(`${OSC}52;c;?\x07`); + stdout.write(wrapForMultiplexer(`${OSC}52;c;?\x07`)); }); } export const clipboard = { write: writeClipboard, read: readClipboard, -}; +}; \ No newline at end of file diff --git a/packages/jsx/src/hooks.ts b/packages/jsx/src/hooks.ts index 09440b234..930751902 100644 --- a/packages/jsx/src/hooks.ts +++ b/packages/jsx/src/hooks.ts @@ -50,6 +50,7 @@ export interface Fiber { // ── Portal tracking ── /** Widgets created via createPortal and their target, for proper teardown */ portalChildren?: Array<{ widgets: Widget[]; target: Widget }>; + componentName?: string; } interface HookState { @@ -332,11 +333,23 @@ export interface KeyBinding { * ]); * ``` */ +// ── Module-level registry: which fibers currently bind which key ── +const _keymapOwners = new Map>(); + +function keymapLabel(key: string): string { + const [k, ctrl, alt, shift] = key.split('|'); + const parts: string[] = []; + if (ctrl === 'true') parts.push('ctrl'); + if (alt === 'true') parts.push('alt'); + if (shift === 'true') parts.push('shift'); + parts.push(k); + return parts.join('+'); +} + export function useKeymap(bindings: KeyBinding[]): void { const fiber = currentFiber(); const idx = fiber.hookIndex++; - // Dev-mode conflict detection on every render (moved outside the init block) if (process.env.NODE_ENV !== 'production') { const seen = new Map(); for (const b of bindings) { @@ -346,6 +359,45 @@ export function useKeymap(bindings: KeyBinding[]): void { } seen.set(key, b); } + + const name = fiber.componentName ?? 'anon'; + const prevKeys: Set = (fiber as any)._keymapKeys ?? new Set(); + const nextKeys = new Set(seen.keys()); + + for (const key of prevKeys) { + if (!nextKeys.has(key)) { + _keymapOwners.get(key)?.delete(fiber); + } + } + + for (const key of nextKeys) { + let owners = _keymapOwners.get(key); + if (!owners) { + owners = new Set(); + _keymapOwners.set(key, owners); + } + const others = [...owners].filter((f) => f !== fiber); + if (others.length > 0) { + const otherNames = others.map((f) => `<${f.componentName ?? 'anon'}>`).join(', '); + console.warn( + `[TermUI] Keymap conflict: "${keymapLabel(key)}" is bound in both ${otherNames} and <${name}>. ` + + `The most recently mounted binding takes priority.` + ); + } + owners.add(fiber); + } + + (fiber as any)._keymapKeys = nextKeys; + + if (!(fiber as any)._keymapCleanupRegistered) { + (fiber as any)._keymapCleanupRegistered = true; + fiber.cleanups.push(() => { + const keys: Set = (fiber as any)._keymapKeys ?? new Set(); + for (const key of keys) { + _keymapOwners.get(key)?.delete(fiber); + } + }); + } } if (idx >= fiber.hooks.length) { @@ -353,7 +405,6 @@ export function useKeymap(bindings: KeyBinding[]): void { } else { fiber.hooks[idx].value = bindings; } - fiber.onInput = (event: KeyEvent) => { const currentBindings: KeyBinding[] = fiber.hooks[idx].value; for (const b of currentBindings) { @@ -369,8 +420,6 @@ export function useKeymap(bindings: KeyBinding[]): void { } }; } - - /** * useInsertBefore — register a persistent line above the inline viewport. * The line is added when the component mounts and removed on unmount or when diff --git a/packages/jsx/src/reconciler.ts b/packages/jsx/src/reconciler.ts index d0f391d5b..4b69e9eca 100644 --- a/packages/jsx/src/reconciler.ts +++ b/packages/jsx/src/reconciler.ts @@ -410,16 +410,19 @@ function renderComponent( const existing = parentFiber._prevChildFibers.get(identityKey); if (existing && existing.component === component) { fiber = existing.fiber; + fiber.componentName = componentName; // Transfer to current render's childFibers parentFiber.childFibers!.set(identityKey, { fiber, component }); } else { // Different component at this position — destroy old, create new if (existing) destroyFiber(existing.fiber); fiber = createFiber(parentFiber); + fiber.componentName = componentName; parentFiber.childFibers!.set(identityKey, { fiber, component }); } } else { fiber = createFiber(parentFiber); + fiber.componentName = componentName; if (parentFiber?.childFibers) { parentFiber.childFibers.set(identityKey, { fiber, component }); }