import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { WebglAddon } from '@xterm/addon-webgl'; import { WebLinksAddon } from '@xterm/addon-web-links'; import { Unicode11Addon } from '@xterm/addon-unicode11'; import { Base64, ClipboardAddon } from '@xterm/addon-clipboard'; // Terminal output (tmux copy-mode, Claude) may *write* the browser clipboard // via OSC 52, but must never read it: anything printed could exfiltrate it (devdesk#7). const writeOnlyClipboard = { readText: () => '', writeText: (selection, text) => { if (selection === 'c') return navigator.clipboard?.writeText(text).catch(() => {}); return undefined; }, }; // Terminal palettes from the LotusGuild TDS tokens (devdesk#31). // // DevDesk runs Claude with its `dark-ansi` theme, so Claude draws with these 16 // colours instead of fixed RGB: body text is bright white, secondary text is // white, the band behind your messages is bright black, and text on coloured // backgrounds is black. Claude's theme is per process and printed text keeps its // colours, so instead of switching Claude, the light palette inverts the grey // ramp: bright white is ink, white is muted ink, bright black a light band and // black the page. Anything written for dark terminals, Claude's scrollback // included, stays readable, and toggling is instant. export const THEMES = Object.freeze({ dark: Object.freeze({ background: '#010304', foreground: '#c4d9ee', cursor: '#FF6B00', cursorAccent: '#010304', selectionBackground: 'rgba(0, 212, 255, 0.30)', selectionInactiveBackground: 'rgba(0, 212, 255, 0.15)', black: '#0d1520', red: '#FF2D55', green: '#00FF88', yellow: '#FFB300', blue: '#3A8DFF', magenta: '#BF5FFF', cyan: '#00D4FF', white: '#9fb6cc', brightBlack: '#3e607a', brightRed: '#FF5C7A', brightGreen: '#33FFAA', brightYellow: '#FFD700', brightBlue: '#6AA8FF', brightMagenta: '#D08CFF', brightCyan: '#33DFFF', brightWhite: '#FFFFFF', }), light: Object.freeze({ background: '#f4f6fa', foreground: '#16202e', cursor: '#c44e00', cursorAccent: '#f4f6fa', selectionBackground: 'rgba(0, 98, 184, 0.22)', selectionInactiveBackground: 'rgba(0, 98, 184, 0.12)', black: '#eef1f6', red: '#b5001f', green: '#006d35', yellow: '#8a5a00', blue: '#1f4fbf', magenta: '#6b2fb8', cyan: '#006b86', white: '#556578', brightBlack: '#c9d1dc', brightRed: '#cc1f3d', brightGreen: '#00803e', brightYellow: '#9a6400', brightBlue: '#0062b8', brightMagenta: '#7e3fd0', brightCyan: '#007f9e', brightWhite: '#16202e', }), }); export const FONT_FAMILY = '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace'; export async function loadFonts(size) { if (!document.fonts?.load) return; try { await Promise.all([ document.fonts.load(`${size}px "JetBrains Mono"`), document.fonts.load(`bold ${size}px "JetBrains Mono"`), ]); } catch { // Fall back to whatever monospace font is available. } } export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnter, onCopied }) { const term = new Terminal({ fontFamily: FONT_FAMILY, fontSize, lineHeight: 1.12, cursorBlink: true, scrollback: 2000, // tmux keeps the real history allowProposedApi: true, // unicode11 macOptionIsMeta: true, drawBoldTextInBrightColors: false, theme: THEMES[theme], }); const fit = new FitAddon(); term.loadAddon(fit); term.loadAddon(new Unicode11Addon()); term.unicode.activeVersion = '11'; term.loadAddon(new ClipboardAddon(new Base64(), writeOnlyClipboard)); term.loadAddon(new WebLinksAddon((event, uri) => { event.preventDefault(); window.open(uri, '_blank', 'noopener'); })); const copySelection = () => { const text = term.getSelection(); navigator.clipboard?.writeText(text).then(() => onCopied?.(text), () => {}); }; term.attachCustomKeyEventHandler((event) => { // Shift+Enter: newline in Claude's prompt (Ctrl+J), not submit. if (event.key === 'Enter' && event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) { if (event.type === 'keydown') onShiftEnter(); return false; } // Ctrl+C with text selected copies it instead of interrupting (devdesk#51). if (event.type === 'keydown' && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey && event.code === 'KeyC' && term.hasSelection()) { copySelection(); term.clearSelection(); return false; } // Ctrl+Shift+C / Ctrl+Shift+V: browser clipboard, like desktop terminals. if (event.type === 'keydown' && event.ctrlKey && event.shiftKey && !event.altKey) { if (event.code === 'KeyC' && term.hasSelection()) { copySelection(); return false; } if (event.code === 'KeyV') { navigator.clipboard?.readText().then((text) => text && term.paste(text)).catch(() => {}); return false; } } return true; }); term.open(container); // devdesk#51: tmux runs with the mouse on (wheel scrolls its history), so a // drag would select inside tmux, which copies and then drops the highlight on // release. A left-button drag in the terminal is made a browser selection // instead (what Shift+drag does): it stays highlighted and is copied on // release. Clicks on tmux's status line (the window tabs) still go to tmux. const screen = () => container.querySelector('.xterm-screen'); container.addEventListener('mousedown', (event) => { if (event.button !== 0 || event.shiftKey || event.ddForced || term.modes.mouseTrackingMode === 'none') return; const rect = screen()?.getBoundingClientRect(); if (!rect) return; const row = Math.floor(((event.clientY - rect.top) / rect.height) * term.rows); if (row >= term.rows - 1) return; // status line event.preventDefault(); event.stopImmediatePropagation(); const forced = new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window, detail: event.detail, button: 0, buttons: event.buttons, clientX: event.clientX, clientY: event.clientY, screenX: event.screenX, screenY: event.screenY, shiftKey: true, ctrlKey: event.ctrlKey, altKey: event.altKey, metaKey: event.metaKey, }); forced.ddForced = true; event.target.dispatchEvent(forced); }, true); // Copy when the drag (or double/triple click) ends with something selected. container.addEventListener('mouseup', () => { setTimeout(() => { if (term.hasSelection() && term.getSelection().trim()) copySelection(); }, 0); }); // Touch devices: the DOM renderer is more reliable at high DPR than WebGL. if (window.matchMedia?.('(pointer: coarse)').matches) return { term, fit }; try { const webgl = new WebglAddon(); webgl.onContextLoss(() => webgl.dispose()); term.loadAddon(webgl); } catch { // DOM renderer fallback. } return { term, fit }; }