diff --git a/src/components/shell/hooks/useShellTerminal.ts b/src/components/shell/hooks/useShellTerminal.ts index 076c5b76..8b343811 100644 --- a/src/components/shell/hooks/useShellTerminal.ts +++ b/src/components/shell/hooks/useShellTerminal.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { MutableRefObject, RefObject } from 'react'; +import { ClipboardAddon, type IClipboardProvider } from '@xterm/addon-clipboard'; import { FitAddon } from '@xterm/addon-fit'; import { WebLinksAddon } from '@xterm/addon-web-links'; import { WebglAddon } from '@xterm/addon-webgl'; @@ -19,6 +20,43 @@ import { import { sendSocketMessage } from '../utils/socket'; import { ensureXtermFocusStyles } from '../utils/terminalStyles'; +// CLIs running inside the pty (e.g. `claude auth login`'s "press c to copy" +// device-flow prompt) write to the clipboard via an OSC 52 escape sequence, +// not a browser event — xterm.js ignores OSC 52 unless a clipboard addon is +// loaded. Routes writes through the same fallback-aware helper the terminal's +// own selection-copy shortcut uses, since `navigator.clipboard` is often +// unavailable on self-hosted, non-HTTPS deployments. +// `ClipboardSelectionType.SYSTEM` is `'c'` (vs. `'p'` for the X11 primary +// selection) — compared as a literal since the addon ships it as a const +// enum, which isolatedModules builds (esbuild/Vite) can't import as a value. +const oscClipboardProvider: IClipboardProvider = { + readText: async (selection) => { + if (selection !== 'c') { + return ''; + } + try { + return (await navigator.clipboard?.readText?.()) || ''; + } catch { + return ''; + } + }, + writeText: async (selection, text) => { + if (selection !== 'c') { + return; + } + await copyTextToClipboard(text); + }, +}; + +// The addon's published typings declare a single `(provider?)` constructor +// param, but the shipped runtime actually takes `(base64?, provider?)` — see +// node_modules/@xterm/addon-clipboard/lib/addon-clipboard.js. Cast to call it +// the way it's really implemented. +const ClipboardAddonCtor = ClipboardAddon as unknown as new ( + base64?: unknown, + provider?: IClipboardProvider, +) => ClipboardAddon; + type UseShellTerminalOptions = { terminalContainerRef: RefObject; terminalRef: MutableRefObject; @@ -93,6 +131,8 @@ export function useShellTerminal({ fitAddonRef.current = nextFitAddon; nextTerminal.loadAddon(nextFitAddon); + nextTerminal.loadAddon(new ClipboardAddonCtor(undefined, oscClipboardProvider)); + // Avoid wrapped partial links in compact login flows. if (!minimal) { nextTerminal.loadAddon(new WebLinksAddon());