Files
devdesk/web/src/term.js
T
jaredandClaude Opus 5.5 24d71131e8
build-deb / Lint, test, package (push) Successful in 2m50s
build-deb / Deploy to CT 126 (push) Successful in 10s
build-deb / Notify on failure (push) Skipped
fix: v0.10.2 drag-select stays highlighted and copies
A left drag in the pane is turned into xterm's own selection (as Shift+drag
does) instead of tmux copy-mode, which dropped the highlight on release.
Copy on release with a toast; Ctrl+C copies when something is selected.
Wheel scroll and status-line clicks still reach tmux. Closes #51

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-10-12 16:35:26 -04:00

191 lines
6.9 KiB
JavaScript

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 };
}