2026-10-06 23:11:07 -04:00
|
|
|
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';
|
2026-10-07 20:15:47 -04:00
|
|
|
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;
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-10-06 23:11:07 -04:00
|
|
|
|
2026-10-08 00:32:23 -04:00
|
|
|
// 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',
|
|
|
|
|
}),
|
2026-10-06 23:11:07 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-08 00:32:23 -04:00
|
|
|
export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnter }) {
|
2026-10-06 23:11:07 -04:00
|
|
|
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,
|
2026-10-08 00:32:23 -04:00
|
|
|
theme: THEMES[theme],
|
2026-10-06 23:11:07 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const fit = new FitAddon();
|
|
|
|
|
term.loadAddon(fit);
|
|
|
|
|
term.loadAddon(new Unicode11Addon());
|
|
|
|
|
term.unicode.activeVersion = '11';
|
2026-10-07 20:15:47 -04:00
|
|
|
term.loadAddon(new ClipboardAddon(new Base64(), writeOnlyClipboard));
|
2026-10-06 23:11:07 -04:00
|
|
|
term.loadAddon(new WebLinksAddon((event, uri) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
window.open(uri, '_blank', 'noopener');
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
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+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()) {
|
|
|
|
|
navigator.clipboard?.writeText(term.getSelection()).catch(() => {});
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (event.code === 'KeyV') {
|
|
|
|
|
navigator.clipboard?.readText().then((text) => text && term.paste(text)).catch(() => {});
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
term.open(container);
|
|
|
|
|
|
2026-10-07 20:15:47 -04:00
|
|
|
// Touch devices: the DOM renderer is more reliable at high DPR than WebGL.
|
|
|
|
|
if (window.matchMedia?.('(pointer: coarse)').matches) return { term, fit };
|
2026-10-06 23:11:07 -04:00
|
|
|
try {
|
|
|
|
|
const webgl = new WebglAddon();
|
|
|
|
|
webgl.onContextLoss(() => webgl.dispose());
|
|
|
|
|
term.loadAddon(webgl);
|
|
|
|
|
} catch {
|
|
|
|
|
// DOM renderer fallback.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { term, fit };
|
|
|
|
|
}
|