feat: DevDesk v0.1.0, a persistent browser terminal for Claude Code
ttyd serves a single-file xterm.js UI styled with the LotusGuild TDS (web_template base.css). Sessions live in a dedicated tmux server unit (devdesk-tmux.service), so restarting or upgrading the web unit never stops Claude. Each named session maps to a project directory. A Claude SessionStart hook records the conversation ID, so after a reboot the session resumes that exact conversation. - bin/devdesk: attach / new / ls / end / pick, with an fzf picker - ttyd requires a credential that NPM injects; origin check enabled - debian/: dh 13 with --no-stop-on-upgrade for the tmux unit, bundled sha256-verified ttyd 1.7.7, lintian overrides, man page - CI: lint, test, build, smoke-test, artifact; v* tags publish to the Gitea apt registry and create a release Refs: tinker ticket 887531976 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+102
@@ -0,0 +1,102 @@
|
||||
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 { ClipboardAddon } from '@xterm/addon-clipboard';
|
||||
|
||||
// LotusGuild TDS tokens (web_template base.css :root) mapped onto the ANSI palette.
|
||||
export const THEME = 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: '#c4d9ee',
|
||||
brightBlack: '#3e607a',
|
||||
brightRed: '#FF5C7A',
|
||||
brightGreen: '#33FFAA',
|
||||
brightYellow: '#FFD700',
|
||||
brightBlue: '#6AA8FF',
|
||||
brightMagenta: '#D08CFF',
|
||||
brightCyan: '#33DFFF',
|
||||
brightWhite: '#FFFFFF',
|
||||
});
|
||||
|
||||
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, onShiftEnter }) {
|
||||
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: THEME,
|
||||
});
|
||||
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.loadAddon(new Unicode11Addon());
|
||||
term.unicode.activeVersion = '11';
|
||||
term.loadAddon(new ClipboardAddon()); // OSC 52 from tmux copy-mode / Claude
|
||||
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);
|
||||
|
||||
try {
|
||||
const webgl = new WebglAddon();
|
||||
webgl.onContextLoss(() => webgl.dispose());
|
||||
term.loadAddon(webgl);
|
||||
} catch {
|
||||
// DOM renderer fallback.
|
||||
}
|
||||
|
||||
return { term, fit };
|
||||
}
|
||||
Reference in New Issue
Block a user