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:
@@ -0,0 +1,75 @@
|
||||
// Builds dist/index.html: one self-contained page (ttyd --index serves a single
|
||||
// file and nothing else), with CSS, JS, fonts and the favicon all inlined.
|
||||
import { build, transform } from 'esbuild';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const at = (...parts) => join(root, ...parts);
|
||||
const read = (...parts) => readFile(at(...parts), 'utf8');
|
||||
|
||||
// Version: $DEVDESK_VERSION (CI) or the top entry of debian/changelog.
|
||||
async function version() {
|
||||
if (process.env.DEVDESK_VERSION) return process.env.DEVDESK_VERSION;
|
||||
const changelog = await read('debian', 'changelog');
|
||||
return /^devdesk \(([^)]+)\)/.exec(changelog)?.[1] ?? 'dev';
|
||||
}
|
||||
|
||||
// JetBrains Mono (OFL-1.1) from @fontsource: keep the latin + latin-ext faces
|
||||
// of the given weights and inline their woff2 files.
|
||||
async function fontFaces(weights) {
|
||||
const pkg = at('node_modules', '@fontsource', 'jetbrains-mono');
|
||||
const faces = [];
|
||||
for (const weight of weights) {
|
||||
const css = await readFile(join(pkg, `${weight}.css`), 'utf8');
|
||||
for (const block of css.match(/\/\* jetbrains-mono-latin(-ext)?-\d+-normal \*\/\s*@font-face\s*{[^}]*}/g) ?? []) {
|
||||
const file = /url\(\.\/files\/([^)]+\.woff2)\)/.exec(block)[1];
|
||||
const data = (await readFile(join(pkg, 'files', file))).toString('base64');
|
||||
faces.push(block.replace(/src:[^;]*;/, `src: url(data:font/woff2;base64,${data}) format('woff2');`));
|
||||
}
|
||||
}
|
||||
if (!faces.length) throw new Error('no JetBrains Mono faces found');
|
||||
return faces.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ver = await version();
|
||||
|
||||
const js = await build({
|
||||
entryPoints: [at('web', 'src', 'main.js')],
|
||||
bundle: true,
|
||||
minify: true,
|
||||
format: 'iife',
|
||||
target: ['es2020'],
|
||||
write: false,
|
||||
legalComments: 'eof',
|
||||
define: { __DEVDESK_VERSION__: JSON.stringify(ver) },
|
||||
});
|
||||
|
||||
const css = await transform([
|
||||
await read('vendor', 'web_template', 'base.css'),
|
||||
await read('node_modules', '@xterm', 'xterm', 'css', 'xterm.css'),
|
||||
await fontFaces([400, 700]),
|
||||
await read('web', 'styles.css'),
|
||||
].join('\n'), { loader: 'css', minify: true, legalComments: 'eof' });
|
||||
|
||||
const { FAVICON: favicon } = await import('./src/icon.js');
|
||||
|
||||
const script = js.outputFiles[0].text.replace(/<\/script/gi, '<\\/script');
|
||||
const style = css.code.replace(/<\/style/gi, '<\\/style');
|
||||
const template = await read('web', 'index.template.html');
|
||||
const html = template
|
||||
.replace('__FAVICON__', () => favicon)
|
||||
.replace('<!--STYLES-->', () => `<style>${style}</style>`)
|
||||
.replace('<!--SCRIPT-->', () => `<script>${script}</script>`);
|
||||
|
||||
await mkdir(at('dist'), { recursive: true });
|
||||
await writeFile(at('dist', 'index.html'), html);
|
||||
console.log(`dist/index.html ${(html.length / 1024).toFixed(0)} KiB (devdesk ${ver})`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="theme-color" content="#030508">
|
||||
<meta name="generator" content="devdesk">
|
||||
<title>DevDesk</title>
|
||||
<link rel="icon" id="favicon" href="__FAVICON__">
|
||||
<!--STYLES-->
|
||||
</head>
|
||||
<body class="dd-app">
|
||||
|
||||
<header class="lt-header dd-header">
|
||||
<div class="lt-header-left">
|
||||
<div class="lt-brand">
|
||||
<span class="lt-brand-title">DevDesk</span>
|
||||
<span class="lt-brand-subtitle">Claude Code workspace</span>
|
||||
</div>
|
||||
<div class="dd-session">
|
||||
<span class="lt-chip lt-chip-info" id="session-chip">—</span>
|
||||
<span class="dd-pane-title" id="pane-title"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-header-right">
|
||||
<span class="lt-ws-status" id="ws-status" data-state="connecting" role="status">
|
||||
<span class="lt-dot"></span><span id="ws-label">connecting</span>
|
||||
</span>
|
||||
<button type="button" class="lt-chip dd-sso" id="sso-badge" data-state="unknown" title="Signed in through Authelia">SSO</button>
|
||||
<div class="lt-btn-group dd-tools">
|
||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="font-down" title="Smaller text" aria-label="Smaller text">A−</button>
|
||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="font-up" title="Larger text" aria-label="Larger text">A+</button>
|
||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="notify-toggle" title="Desktop notification when Claude needs you">Alerts off</button>
|
||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="fullscreen-toggle" title="Fullscreen (also captures browser shortcuts in Chromium)">Full</button>
|
||||
<button type="button" class="lt-btn lt-btn-sm" id="sidebar-toggle" aria-controls="sidebar" aria-expanded="false">Panels</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="dd-main">
|
||||
<section class="lt-frame dd-terminal-frame" aria-label="Terminal">
|
||||
<div id="terminal" class="dd-terminal"></div>
|
||||
<div id="overlay" class="dd-overlay" data-tone="info" hidden>
|
||||
<div class="lt-frame dd-overlay-card" role="alertdialog" aria-labelledby="overlay-title" aria-describedby="overlay-text">
|
||||
<div class="lt-section-header" id="overlay-title">Disconnected</div>
|
||||
<div class="lt-section-body">
|
||||
<p id="overlay-text" class="dd-overlay-text"></p>
|
||||
<div class="lt-btn-group" id="overlay-actions"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside id="sidebar" class="dd-sidebar" aria-label="Panels">
|
||||
<section class="lt-frame dd-widget" id="widget-sessions" data-state="live">
|
||||
<div class="lt-section-header">Sessions
|
||||
<button type="button" class="dd-header-action" id="sessions-refresh" title="Refresh" aria-label="Refresh sessions">↻</button>
|
||||
</div>
|
||||
<div class="lt-section-body">
|
||||
<ul class="dd-session-list" id="sessions-list"></ul>
|
||||
<div class="dd-new-session">
|
||||
<select class="lt-select" id="project-select" aria-label="Project for a new session"></select>
|
||||
<button type="button" class="lt-btn lt-btn-primary lt-btn-sm" id="project-open">Open</button>
|
||||
</div>
|
||||
<div class="dd-widget-foot">
|
||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="open-picker">Picker</button>
|
||||
<span class="dd-muted" id="sessions-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lt-frame dd-widget" id="widget-repo" data-state="stub">
|
||||
<div class="lt-section-header">Repo / CI <span class="lt-badge lt-badge-sm dd-soon">v1.1</span></div>
|
||||
<div class="lt-section-body">
|
||||
<div class="lt-kv-grid">
|
||||
<span class="lt-kv-key">Repo</span><span class="lt-kv-val" data-field="repo">—</span>
|
||||
<span class="lt-kv-key">Branch</span><span class="lt-kv-val" data-field="branch">—</span>
|
||||
<span class="lt-kv-key">Tree</span><span class="lt-kv-val" data-field="dirty">—</span>
|
||||
<span class="lt-kv-key">CI · HEAD</span><span class="lt-kv-val"><span class="lt-status lt-status-pending" data-field="ci">no data</span></span>
|
||||
<span class="lt-kv-key">Open PRs</span><span class="lt-kv-val" data-field="prs">—</span>
|
||||
</div>
|
||||
<p class="dd-muted dd-stub-note">Gitea status for this session's repo arrives with devdesk-api.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lt-frame dd-widget" id="widget-alerts" data-state="stub">
|
||||
<div class="lt-section-header">Ops Alerts <span class="lt-badge lt-badge-sm dd-soon">v2</span></div>
|
||||
<div class="lt-section-body">
|
||||
<ul class="dd-alert-list">
|
||||
<li class="dd-alert">
|
||||
<span class="lt-dot lt-dot-idle"></span>
|
||||
<span class="dd-alert-text">No alert source connected</span>
|
||||
<button type="button" class="lt-btn lt-btn-sm dd-ask" disabled
|
||||
data-ask-claude="Investigate the firing alert: ">Ask Claude</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="dd-muted dd-stub-note">Grafana alerts and GANDALF status, each with an “Ask Claude” prompt.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lt-frame dd-widget" id="widget-context" data-state="stub">
|
||||
<div class="lt-section-header">Claude Context <span class="lt-badge lt-badge-sm dd-soon">v1.1</span></div>
|
||||
<div class="lt-section-body">
|
||||
<div class="lt-progress lt-progress--cyan" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
|
||||
<div class="lt-progress-bar" style="width:0%"></div>
|
||||
</div>
|
||||
<div class="lt-kv-grid dd-context-grid">
|
||||
<span class="lt-kv-key">Used</span><span class="lt-kv-val" data-field="used">—</span>
|
||||
<span class="lt-kv-key">Model</span><span class="lt-kv-val" data-field="model">—</span>
|
||||
</div>
|
||||
<p class="dd-muted dd-stub-note">Fed by a Claude statusLine hook through devdesk-api.</p>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<footer class="lt-footer dd-footer">
|
||||
<div class="lt-footer-hints">
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">⇧⏎</span>newline</span>
|
||||
<span class="lt-footer-sep">│</span>
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">Esc</span>interrupt</span>
|
||||
<span class="lt-footer-sep">│</span>
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">^b 0/1</span>claude/shell</span>
|
||||
<span class="lt-footer-sep">│</span>
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">^b s</span>switch</span>
|
||||
<span class="lt-footer-sep">│</span>
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">^b d</span>detach</span>
|
||||
<span class="lt-footer-sep">│</span>
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">⇧drag</span>select</span>
|
||||
</div>
|
||||
<span class="dd-muted" id="version">devdesk</span>
|
||||
</footer>
|
||||
|
||||
<!--SCRIPT-->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
Server, LIST_ARG, decodeServer, encodeBinaryInput, encodeCommand, encodeInit,
|
||||
encodeInput, encodeResize, parseListOutput, tokenUrl, wsUrl, FlowControl,
|
||||
} from './protocol.js';
|
||||
|
||||
export class AuthExpiredError extends Error {}
|
||||
export class UnauthorizedError extends Error {}
|
||||
|
||||
// GET /token. Through NPM this also exercises the Authelia gate, so it doubles
|
||||
// as the session probe: a redirect means the SSO session expired.
|
||||
export async function fetchToken() {
|
||||
const res = await fetch(tokenUrl(window.location), {
|
||||
cache: 'no-store', credentials: 'same-origin', redirect: 'manual',
|
||||
});
|
||||
if (res.type === 'opaqueredirect') throw new AuthExpiredError('SSO session expired');
|
||||
if (res.status === 401 || res.status === 403 || res.status === 407) {
|
||||
throw new UnauthorizedError(`HTTP ${res.status}`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`token request failed: HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
return body.token || '';
|
||||
}
|
||||
|
||||
// One WebSocket to ttyd, i.e. one `devdesk attach <arg>` process on the server.
|
||||
export class TtydConnection {
|
||||
constructor({ arg, size, onOpen, onOutput, onClose, onPreferences }) {
|
||||
Object.assign(this, { arg, size, onOpen, onOutput, onClose, onPreferences });
|
||||
this.flow = new FlowControl();
|
||||
this.socket = null;
|
||||
this.disposed = false;
|
||||
}
|
||||
|
||||
async open() {
|
||||
const token = await fetchToken();
|
||||
if (this.disposed) return;
|
||||
const socket = new WebSocket(wsUrl(window.location, this.arg), ['tty']);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
this.socket = socket;
|
||||
socket.onopen = () => {
|
||||
const { cols, rows } = this.size();
|
||||
socket.send(encodeInit(token, cols, rows));
|
||||
this.onOpen?.();
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
const { type, payload } = decodeServer(event.data);
|
||||
if (type === Server.OUTPUT) this.onOutput?.(payload);
|
||||
else if (type === Server.SET_PREFERENCES) this.onPreferences?.(payload);
|
||||
// SET_WINDOW_TITLE only carries ttyd's "<command> (<host>)"; real titles come via OSC.
|
||||
};
|
||||
socket.onclose = (event) => {
|
||||
if (this.socket === socket) this.socket = null;
|
||||
if (!this.disposed) this.onClose?.(event.code, event.reason);
|
||||
};
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
return this.socket?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
send(frame) {
|
||||
if (this.isOpen) this.socket.send(frame);
|
||||
}
|
||||
|
||||
sendInput(text) {
|
||||
this.send(encodeInput(text));
|
||||
}
|
||||
|
||||
sendBinary(binary) {
|
||||
this.send(encodeBinaryInput(binary));
|
||||
}
|
||||
|
||||
resize(cols, rows) {
|
||||
this.send(encodeResize(cols, rows));
|
||||
}
|
||||
|
||||
// Called with each output chunk; returns a write-callback when tracking is needed.
|
||||
flowWrite(length) {
|
||||
const { command, track } = this.flow.onData(length);
|
||||
if (command) this.send(encodeCommand(command));
|
||||
if (!track) return undefined;
|
||||
return () => {
|
||||
const resume = this.flow.release();
|
||||
if (resume) this.send(encodeCommand(resume));
|
||||
};
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
if (this.socket) {
|
||||
this.socket.onclose = null;
|
||||
this.socket.close(1000);
|
||||
this.socket = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ask the server for sessions + projects: a short-lived `devdesk attach --list`.
|
||||
export async function fetchSessionList(timeoutMs = 8000) {
|
||||
const token = await fetchToken();
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(wsUrl(window.location, LIST_ARG), ['tty']);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
const chunks = [];
|
||||
const timer = setTimeout(() => {
|
||||
socket.close();
|
||||
reject(new Error('session list timed out'));
|
||||
}, timeoutMs);
|
||||
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
|
||||
socket.onmessage = (event) => {
|
||||
const { type, payload } = decodeServer(event.data);
|
||||
if (type === Server.OUTPUT) chunks.push(payload.slice());
|
||||
};
|
||||
socket.onerror = () => {};
|
||||
socket.onclose = () => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const text = new TextDecoder().decode(concat(chunks));
|
||||
resolve(parseListOutput(text));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function concat(chunks) {
|
||||
const total = chunks.reduce((n, c) => n + c.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
out.set(c, offset);
|
||||
offset += c.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// The DevDesk mark: an orange TDS diamond; the badge variant flags unread bells.
|
||||
const svg = (badge) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="10" fill="#030508"/>
|
||||
<path d="M32 6 58 32 32 58 6 32Z" fill="none" stroke="#FF6B00" stroke-width="5"/>
|
||||
<path d="M32 20 44 32 32 44 20 32Z" fill="#00D4FF"/>
|
||||
${badge ? '<circle cx="50" cy="14" r="11" fill="#00FF88" stroke="#030508" stroke-width="3"/>' : ''}
|
||||
</svg>`;
|
||||
|
||||
const dataUri = (text) => `data:image/svg+xml,${encodeURIComponent(text)}`;
|
||||
|
||||
export const FAVICON = dataUri(svg(false));
|
||||
export const FAVICON_BADGE = dataUri(svg(true));
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
/* global __DEVDESK_VERSION__ */
|
||||
import { createTerminal, loadFonts } from './term.js';
|
||||
import { TtydConnection, fetchToken, AuthExpiredError, UnauthorizedError } from './connection.js';
|
||||
import { parseTitle, sessionFromSearch } from './protocol.js';
|
||||
import { SessionsPanel, rememberSession } from './sessions.js';
|
||||
import { bindAskClaude } from './widgets.js';
|
||||
import * as notify from './notify.js';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const store = {
|
||||
get(key, fallback) {
|
||||
try {
|
||||
const value = localStorage.getItem(`devdesk.${key}`);
|
||||
return value === null ? fallback : value;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
set(key, value) {
|
||||
try {
|
||||
localStorage.setItem(`devdesk.${key}`, String(value));
|
||||
} catch {
|
||||
// Private mode: preferences just won't persist.
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const ui = {
|
||||
body: document.body,
|
||||
wsStatus: $('ws-status'),
|
||||
wsLabel: $('ws-label'),
|
||||
sso: $('sso-badge'),
|
||||
sessionChip: $('session-chip'),
|
||||
paneTitle: $('pane-title'),
|
||||
overlay: $('overlay'),
|
||||
overlayTitle: $('overlay-title'),
|
||||
overlayText: $('overlay-text'),
|
||||
overlayActions: $('overlay-actions'),
|
||||
notifyButton: $('notify-toggle'),
|
||||
sidebarButton: $('sidebar-toggle'),
|
||||
};
|
||||
|
||||
const state = {
|
||||
session: sessionFromSearch(window.location.search), // null = picker
|
||||
conn: null,
|
||||
attempts: 0,
|
||||
retryTimer: null,
|
||||
openedAt: 0,
|
||||
lastActivity: Date.now(),
|
||||
};
|
||||
|
||||
let term;
|
||||
let fit;
|
||||
let sessionsPanel;
|
||||
|
||||
// ---------------------------------------------------------------- status UI
|
||||
|
||||
function setWsState(stateName, label) {
|
||||
ui.wsStatus.dataset.state = stateName;
|
||||
ui.wsLabel.textContent = label;
|
||||
}
|
||||
|
||||
function setSso(stateName) {
|
||||
ui.sso.dataset.state = stateName;
|
||||
ui.sso.textContent = stateName === 'expired' ? 'SSO expired' : 'SSO';
|
||||
ui.sso.title = stateName === 'expired'
|
||||
? 'Authelia session expired. Click to sign in again.'
|
||||
: 'Signed in through Authelia';
|
||||
}
|
||||
|
||||
function showOverlay({ title, text, actions = [], tone = 'info' }) {
|
||||
ui.overlay.hidden = false;
|
||||
ui.overlay.dataset.tone = tone;
|
||||
ui.overlayTitle.textContent = title;
|
||||
ui.overlayText.textContent = text;
|
||||
ui.overlayActions.replaceChildren(...actions.map(({ label, run, primary }) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `lt-btn lt-btn-sm${primary ? ' lt-btn-primary' : ''}`;
|
||||
button.textContent = label;
|
||||
button.addEventListener('click', run);
|
||||
return button;
|
||||
}));
|
||||
}
|
||||
|
||||
function hideOverlay() {
|
||||
ui.overlay.hidden = true;
|
||||
}
|
||||
|
||||
function updateUrl(session) {
|
||||
const url = new URL(window.location.href);
|
||||
if (session) url.searchParams.set('arg', session);
|
||||
else url.searchParams.delete('arg');
|
||||
if (url.href !== window.location.href) window.history.replaceState(null, '', url);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- connection
|
||||
|
||||
function connect(session) {
|
||||
clearTimeout(state.retryTimer);
|
||||
state.conn?.dispose();
|
||||
state.session = session;
|
||||
updateUrl(session);
|
||||
sessionsPanel?.setCurrent(session);
|
||||
ui.sessionChip.textContent = session || 'picker';
|
||||
hideOverlay();
|
||||
setWsState('connecting', 'connecting');
|
||||
state.openedAt = 0;
|
||||
term.reset();
|
||||
|
||||
const conn = new TtydConnection({
|
||||
arg: session,
|
||||
size: () => ({ cols: term.cols, rows: term.rows }),
|
||||
onOpen: () => {
|
||||
state.openedAt = Date.now();
|
||||
state.attempts = 0;
|
||||
setWsState('connected', 'live');
|
||||
setSso('ok');
|
||||
if (session) rememberSession(session);
|
||||
fit.fit();
|
||||
term.focus();
|
||||
},
|
||||
onOutput: (bytes) => term.write(bytes, conn.flowWrite(bytes.length)),
|
||||
onClose: (code) => onClosed(code),
|
||||
});
|
||||
state.conn = conn;
|
||||
conn.open().catch(onFailure);
|
||||
}
|
||||
|
||||
const pickerAction = { label: 'Sessions…', run: () => connect(null) };
|
||||
|
||||
function onClosed(code) {
|
||||
setWsState('disconnected', 'offline');
|
||||
// ttyd reports a non-zero exit of `devdesk attach` with an invalid close code,
|
||||
// which browsers surface as 1006 — same as a dropped network. A close within a
|
||||
// few seconds of opening means the session could not start (the reason is
|
||||
// printed in the terminal), so don't retry forever.
|
||||
const failedFast = code !== 1000 && Date.now() - state.openedAt < 4000;
|
||||
if (failedFast) {
|
||||
showOverlay({
|
||||
title: 'Session did not start',
|
||||
text: `Could not open "${state.session || 'picker'}". The reason is shown in the terminal behind this message.`,
|
||||
actions: [{ label: 'Retry', run: () => connect(state.session) }, pickerAction],
|
||||
tone: 'warn',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 1000: the attach process exited normally (detached, session ended, picker
|
||||
// closed). Anything else is the network or ttyd going away: reconnect.
|
||||
if (code === 1000) {
|
||||
const actions = state.session
|
||||
? [{ label: `Reattach ${state.session}`, primary: true, run: () => connect(state.session) }, pickerAction]
|
||||
: [{ label: 'Open picker', primary: true, run: () => connect(null) }];
|
||||
showOverlay({
|
||||
title: 'Detached',
|
||||
text: 'The terminal closed. Any session you left keeps running on the server.',
|
||||
actions,
|
||||
});
|
||||
return;
|
||||
}
|
||||
scheduleReconnect();
|
||||
}
|
||||
|
||||
function onFailure(error) {
|
||||
if (error instanceof AuthExpiredError) {
|
||||
setSso('expired');
|
||||
setWsState('disconnected', 'signed out');
|
||||
showOverlay({
|
||||
title: 'Sign-in expired',
|
||||
text: 'Your Authelia session has expired. Claude keeps running on the server; sign in again to reattach.',
|
||||
actions: [{ label: 'Re-authenticate', primary: true, run: () => window.location.reload() }],
|
||||
tone: 'warn',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (error instanceof UnauthorizedError) {
|
||||
setWsState('disconnected', 'denied');
|
||||
showOverlay({
|
||||
title: 'Not authorized',
|
||||
text: 'ttyd rejected the request. Open DevDesk through https://dev.lotusguild.org, where the proxy adds the credential.',
|
||||
actions: [{ label: 'Retry', run: () => connect(state.session) }],
|
||||
tone: 'danger',
|
||||
});
|
||||
return;
|
||||
}
|
||||
scheduleReconnect();
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
clearTimeout(state.retryTimer);
|
||||
state.attempts += 1;
|
||||
const delay = Math.min(30000, 1000 * 1.6 ** (state.attempts - 1)) * (0.8 + Math.random() * 0.4);
|
||||
const seconds = Math.ceil(delay / 1000);
|
||||
setWsState('connecting', `retry ${seconds}s`);
|
||||
showOverlay({
|
||||
title: 'Reconnecting',
|
||||
text: `Connection lost; Claude keeps running on the server. Retrying in ${seconds}s (attempt ${state.attempts}).`,
|
||||
actions: [{ label: 'Retry now', primary: true, run: () => connect(state.session) }, pickerAction],
|
||||
});
|
||||
state.retryTimer = setTimeout(() => connect(state.session), delay);
|
||||
}
|
||||
|
||||
function retrySoon() {
|
||||
if (state.retryTimer && !state.conn?.isOpen) connect(state.session);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- titles & bell
|
||||
|
||||
let titleFrame = 0;
|
||||
let pendingTitle = '';
|
||||
|
||||
function applyTitle() {
|
||||
titleFrame = 0;
|
||||
const parsed = parseTitle(pendingTitle);
|
||||
if (!parsed) {
|
||||
ui.paneTitle.textContent = pendingTitle;
|
||||
return;
|
||||
}
|
||||
if (parsed.session === 'picker') {
|
||||
ui.sessionChip.textContent = 'picker';
|
||||
ui.paneTitle.textContent = 'choose a session';
|
||||
notify.setBaseTitle('DevDesk · picker');
|
||||
return;
|
||||
}
|
||||
if (parsed.session && parsed.session !== state.session) {
|
||||
// Chosen in the picker or switched inside tmux: follow it.
|
||||
state.session = parsed.session;
|
||||
updateUrl(parsed.session);
|
||||
rememberSession(parsed.session);
|
||||
sessionsPanel.setCurrent(parsed.session);
|
||||
}
|
||||
ui.sessionChip.textContent = parsed.session;
|
||||
ui.paneTitle.textContent = parsed.pane;
|
||||
notify.setBaseTitle(`${parsed.session} · DevDesk`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- chrome controls
|
||||
|
||||
function setFontSize(size) {
|
||||
const clamped = Math.min(24, Math.max(10, size));
|
||||
term.options.fontSize = clamped;
|
||||
store.set('fontSize', clamped);
|
||||
fit.fit();
|
||||
}
|
||||
|
||||
function renderNotifyButton() {
|
||||
const labels = { granted: 'Alerts on', denied: 'Alerts blocked', default: 'Alerts off', unsupported: 'No alerts' };
|
||||
const current = notify.notificationsState();
|
||||
ui.notifyButton.textContent = labels[current];
|
||||
ui.notifyButton.dataset.state = current;
|
||||
}
|
||||
|
||||
function setSidebar(open) {
|
||||
ui.body.classList.toggle('dd-sidebar-open', open);
|
||||
ui.sidebarButton.setAttribute('aria-expanded', String(open));
|
||||
store.set('sidebar', open ? 'open' : 'closed');
|
||||
if (open) sessionsPanel?.refresh();
|
||||
}
|
||||
|
||||
function wireChrome() {
|
||||
$('font-down').addEventListener('click', () => setFontSize(term.options.fontSize - 1));
|
||||
$('font-up').addEventListener('click', () => setFontSize(term.options.fontSize + 1));
|
||||
ui.notifyButton.addEventListener('click', async () => {
|
||||
await notify.requestNotifications();
|
||||
renderNotifyButton();
|
||||
});
|
||||
renderNotifyButton();
|
||||
$('fullscreen-toggle').addEventListener('click', async () => {
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
return;
|
||||
}
|
||||
await document.documentElement.requestFullscreen();
|
||||
// Chromium: capture Ctrl+W/Ctrl+T/Esc etc. for the terminal while fullscreen.
|
||||
navigator.keyboard?.lock?.().catch(() => {});
|
||||
});
|
||||
ui.sidebarButton.addEventListener('click', () => setSidebar(!ui.body.classList.contains('dd-sidebar-open')));
|
||||
const wide = window.matchMedia('(min-width: 1100px)').matches;
|
||||
setSidebar(store.get('sidebar', wide ? 'open' : 'closed') === 'open');
|
||||
|
||||
ui.sso.addEventListener('click', () => {
|
||||
if (ui.sso.dataset.state === 'expired') window.location.reload();
|
||||
});
|
||||
|
||||
// Ctrl+W closes the tab before the terminal sees it; ask first.
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (state.conn?.isOpen && state.session) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
});
|
||||
|
||||
for (const type of ['keydown', 'pointerdown']) {
|
||||
document.addEventListener(type, () => { state.lastActivity = Date.now(); }, { capture: true, passive: true });
|
||||
}
|
||||
// While you're active, touch the Authelia gate so its inactivity timer doesn't lapse.
|
||||
setInterval(async () => {
|
||||
if (Date.now() - state.lastActivity > 10 * 60 * 1000) return;
|
||||
try {
|
||||
await fetchToken();
|
||||
setSso('ok');
|
||||
} catch (error) {
|
||||
if (error instanceof AuthExpiredError) setSso('expired');
|
||||
}
|
||||
}, 3 * 60 * 1000);
|
||||
|
||||
window.addEventListener('online', retrySoon);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) retrySoon();
|
||||
});
|
||||
setInterval(() => {
|
||||
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) sessionsPanel.refresh();
|
||||
}, 30 * 1000);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- boot
|
||||
|
||||
async function boot() {
|
||||
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
|
||||
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
|
||||
await loadFonts(fontSize);
|
||||
|
||||
({ term, fit } = createTerminal($('terminal'), {
|
||||
fontSize,
|
||||
onShiftEnter: () => state.conn?.sendInput('\n'),
|
||||
}));
|
||||
term.onData((data) => state.conn?.sendInput(data));
|
||||
term.onBinary((data) => state.conn?.sendBinary(data));
|
||||
term.onResize(({ cols, rows }) => state.conn?.resize(cols, rows));
|
||||
term.onTitleChange((title) => {
|
||||
pendingTitle = title;
|
||||
if (!titleFrame) titleFrame = requestAnimationFrame(applyTitle);
|
||||
});
|
||||
term.onBell(() => notify.ring(state.session, ui.paneTitle.textContent));
|
||||
|
||||
let fitFrame = 0;
|
||||
new ResizeObserver(() => {
|
||||
if (!fitFrame) {
|
||||
fitFrame = requestAnimationFrame(() => {
|
||||
fitFrame = 0;
|
||||
fit.fit();
|
||||
});
|
||||
}
|
||||
}).observe($('terminal'));
|
||||
fit.fit();
|
||||
|
||||
sessionsPanel = new SessionsPanel({
|
||||
list: $('sessions-list'),
|
||||
select: $('project-select'),
|
||||
openButton: $('project-open'),
|
||||
refreshButton: $('sessions-refresh'),
|
||||
pickerButton: $('open-picker'),
|
||||
status: $('sessions-status'),
|
||||
onOpen: (name) => connect(name),
|
||||
onPicker: () => connect(null),
|
||||
});
|
||||
bindAskClaude($('sidebar'), (text) => {
|
||||
state.conn?.sendInput(text);
|
||||
term.focus();
|
||||
});
|
||||
wireChrome();
|
||||
connect(state.session);
|
||||
}
|
||||
|
||||
boot();
|
||||
@@ -0,0 +1,60 @@
|
||||
// Bell → attention: desktop notification, favicon badge and a title prefix while
|
||||
// the tab is in the background. Claude rings the bell (preferredNotifChannel:
|
||||
// terminal_bell) when it finishes a turn or needs input.
|
||||
|
||||
import { FAVICON, FAVICON_BADGE } from './icon.js';
|
||||
|
||||
let unread = 0;
|
||||
let baseTitle = document.title;
|
||||
|
||||
function attentive() {
|
||||
return !document.hidden && document.hasFocus();
|
||||
}
|
||||
|
||||
function render() {
|
||||
document.title = unread ? `(${unread}) ${baseTitle}` : baseTitle;
|
||||
const link = document.getElementById('favicon');
|
||||
if (link) link.href = unread ? FAVICON_BADGE : FAVICON;
|
||||
}
|
||||
|
||||
export function setBaseTitle(title) {
|
||||
baseTitle = title;
|
||||
render();
|
||||
}
|
||||
|
||||
export function notificationsState() {
|
||||
if (!('Notification' in window)) return 'unsupported';
|
||||
return Notification.permission; // granted | denied | default
|
||||
}
|
||||
|
||||
export async function requestNotifications() {
|
||||
if (notificationsState() !== 'default') return notificationsState();
|
||||
return Notification.requestPermission();
|
||||
}
|
||||
|
||||
export function ring(session, detail) {
|
||||
if (attentive()) return;
|
||||
unread += 1;
|
||||
render();
|
||||
if (notificationsState() === 'granted') {
|
||||
const note = new Notification(`Claude · ${session || 'devdesk'}`, {
|
||||
body: detail || 'Claude needs your attention',
|
||||
tag: `devdesk-${session}`,
|
||||
renotify: true,
|
||||
icon: FAVICON,
|
||||
});
|
||||
note.onclick = () => {
|
||||
window.focus();
|
||||
note.close();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (!unread || !attentive()) return;
|
||||
unread = 0;
|
||||
render();
|
||||
}
|
||||
|
||||
window.addEventListener('focus', clear);
|
||||
document.addEventListener('visibilitychange', clear);
|
||||
@@ -0,0 +1,106 @@
|
||||
// ttyd 1.7.x WebSocket protocol (see ttyd src/server.h and src/protocol.c).
|
||||
//
|
||||
// Frames are binary. The first message is a bare JSON object; every later
|
||||
// message starts with a one-character command.
|
||||
|
||||
export const Client = Object.freeze({ INPUT: '0', RESIZE: '1', PAUSE: '2', RESUME: '3' });
|
||||
export const Server = Object.freeze({ OUTPUT: '0', SET_WINDOW_TITLE: '1', SET_PREFERENCES: '2' });
|
||||
|
||||
export const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
||||
export const LIST_ARG = '--list';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function encodeInit(token, columns, rows) {
|
||||
return encoder.encode(JSON.stringify({ AuthToken: token, columns, rows }));
|
||||
}
|
||||
|
||||
export function encodeInput(text) {
|
||||
const body = encoder.encode(text);
|
||||
const frame = new Uint8Array(body.length + 1);
|
||||
frame[0] = Client.INPUT.charCodeAt(0);
|
||||
frame.set(body, 1);
|
||||
return frame;
|
||||
}
|
||||
|
||||
// xterm.js onBinary delivers a "binary string" (one char per byte).
|
||||
export function encodeBinaryInput(binary) {
|
||||
const frame = new Uint8Array(binary.length + 1);
|
||||
frame[0] = Client.INPUT.charCodeAt(0);
|
||||
for (let i = 0; i < binary.length; i++) frame[i + 1] = binary.charCodeAt(i) & 0xff;
|
||||
return frame;
|
||||
}
|
||||
|
||||
export function encodeResize(columns, rows) {
|
||||
return encoder.encode(Client.RESIZE + JSON.stringify({ columns, rows }));
|
||||
}
|
||||
|
||||
export function encodeCommand(command) {
|
||||
return encoder.encode(command);
|
||||
}
|
||||
|
||||
export function decodeServer(data) {
|
||||
const bytes = new Uint8Array(data);
|
||||
return { type: String.fromCharCode(bytes[0]), payload: bytes.subarray(1) };
|
||||
}
|
||||
|
||||
// URLs are relative to the page so DevDesk also works under a path prefix.
|
||||
function basePath(location) {
|
||||
return location.pathname.replace(/[^/]*$/, '');
|
||||
}
|
||||
|
||||
export function tokenUrl(location) {
|
||||
return `${location.protocol}//${location.host}${basePath(location)}token`;
|
||||
}
|
||||
|
||||
export function wsUrl(location, arg) {
|
||||
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const query = arg ? `?arg=${encodeURIComponent(arg)}` : '';
|
||||
return `${scheme}//${location.host}${basePath(location)}ws${query}`;
|
||||
}
|
||||
|
||||
export function sessionFromSearch(search) {
|
||||
const value = new URLSearchParams(search).get('arg');
|
||||
return value && SESSION_RE.test(value) ? value : null;
|
||||
}
|
||||
|
||||
// tmux set-titles-string is "devdesk:#S|#T"; the picker prints "devdesk:picker|…".
|
||||
export function parseTitle(title) {
|
||||
const match = /^devdesk:([^|]*)\|([\s\S]*)$/.exec(title || '');
|
||||
if (!match) return null;
|
||||
return { session: match[1], pane: match[2] };
|
||||
}
|
||||
|
||||
// Output of `devdesk attach --list` arrives through a pty: CRLF line endings and
|
||||
// possibly split across frames. Pull the JSON object out of the joined text.
|
||||
export function parseListOutput(raw) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const text = raw.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, ''); // tools may colorize on a tty
|
||||
const start = text.indexOf('{');
|
||||
const end = text.lastIndexOf('}');
|
||||
if (start < 0 || end < start) throw new Error('no session list in output');
|
||||
return JSON.parse(text.slice(start, end + 1));
|
||||
}
|
||||
|
||||
// Same back-pressure scheme as ttyd's own client: pause the pty when xterm.js
|
||||
// falls behind on rendering, resume once it catches up.
|
||||
export class FlowControl {
|
||||
constructor({ limit = 100000, highWater = 10, lowWater = 4 } = {}) {
|
||||
Object.assign(this, { limit, highWater, lowWater, written: 0, pending: 0 });
|
||||
}
|
||||
|
||||
// Returns { command, track }: command is PAUSE when the writer should stop;
|
||||
// track says whether to pass a callback to term.write (and call release()).
|
||||
onData(length) {
|
||||
this.written += length;
|
||||
if (this.written <= this.limit) return { command: null, track: false };
|
||||
this.written = 0;
|
||||
this.pending += 1;
|
||||
return { command: this.pending > this.highWater ? Client.PAUSE : null, track: true };
|
||||
}
|
||||
|
||||
release() {
|
||||
this.pending = Math.max(this.pending - 1, 0);
|
||||
return this.pending < this.lowWater ? Client.RESUME : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Sessions panel: server state (via `devdesk attach --list`) plus recently used
|
||||
// sessions remembered in this browser.
|
||||
|
||||
import { fetchSessionList } from './connection.js';
|
||||
|
||||
const RECENTS_KEY = 'devdesk.recents';
|
||||
const MAX_RECENTS = 12;
|
||||
|
||||
function readRecents() {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(RECENTS_KEY) || '[]');
|
||||
return Array.isArray(value) ? value : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberSession(name) {
|
||||
if (!name) return;
|
||||
try {
|
||||
const recents = readRecents().filter((r) => r.name !== name);
|
||||
recents.unshift({ name, at: Date.now() });
|
||||
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, MAX_RECENTS)));
|
||||
} catch {
|
||||
// Storage unavailable (private mode): recents are a convenience only.
|
||||
}
|
||||
}
|
||||
|
||||
function ago(epochSeconds) {
|
||||
if (!epochSeconds) return '';
|
||||
const s = Math.max(0, Date.now() / 1000 - epochSeconds);
|
||||
if (s < 90) return 'now';
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
||||
return `${Math.floor(s / 86400)}d`;
|
||||
}
|
||||
|
||||
function el(tag, attrs = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (key === 'class') node.className = value;
|
||||
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
|
||||
else node.setAttribute(key, value);
|
||||
}
|
||||
node.append(...children.filter((c) => c !== null && c !== undefined));
|
||||
return node;
|
||||
}
|
||||
|
||||
export class SessionsPanel {
|
||||
constructor({ list, select, openButton, refreshButton, pickerButton, status, onOpen, onPicker }) {
|
||||
Object.assign(this, { list, select, status, onOpen });
|
||||
this.current = null;
|
||||
this.data = null;
|
||||
refreshButton.addEventListener('click', () => this.refresh());
|
||||
pickerButton.addEventListener('click', () => onPicker());
|
||||
openButton.addEventListener('click', () => {
|
||||
if (select.value) onOpen(select.value);
|
||||
});
|
||||
}
|
||||
|
||||
setCurrent(name) {
|
||||
this.current = name;
|
||||
if (this.data) this.render();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.status.textContent = 'refreshing…';
|
||||
try {
|
||||
this.data = await fetchSessionList();
|
||||
this.status.textContent = '';
|
||||
} catch (err) {
|
||||
this.status.textContent = `list unavailable: ${err.message}`;
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
const sessions = this.data?.sessions ?? [];
|
||||
const known = new Set(sessions.map((s) => s.name));
|
||||
const recentsOnly = readRecents().filter((r) => !known.has(r.name)).slice(0, 5);
|
||||
this.list.replaceChildren();
|
||||
|
||||
const ordered = [...sessions].sort((a, b) =>
|
||||
(b.running - a.running) || ((b.activity || 0) - (a.activity || 0)));
|
||||
for (const s of ordered) {
|
||||
const isCurrent = s.name === this.current;
|
||||
const meta = s.running
|
||||
? `${s.attached} attached · ${ago(s.activity)}`
|
||||
: 'dormant · resumes Claude';
|
||||
this.list.append(el('li', { class: `dd-session-item${isCurrent ? ' is-current' : ''}` },
|
||||
el('button', {
|
||||
class: 'dd-session-open', type: 'button', title: s.dir || '',
|
||||
onclick: () => this.onOpen(s.name),
|
||||
},
|
||||
el('span', { class: `lt-dot ${s.running ? 'lt-dot-up' : 'lt-dot-idle'}` }),
|
||||
el('span', { class: 'dd-session-name' }, s.name),
|
||||
el('span', { class: 'dd-session-meta' }, meta)),
|
||||
el('a', {
|
||||
class: 'dd-session-tab', href: `?arg=${encodeURIComponent(s.name)}`,
|
||||
target: '_blank', rel: 'noopener', title: 'Open in a new tab', 'aria-label': `Open ${s.name} in a new tab`,
|
||||
}, '↗')));
|
||||
}
|
||||
for (const r of recentsOnly) {
|
||||
this.list.append(el('li', { class: 'dd-session-item is-recent' },
|
||||
el('button', { class: 'dd-session-open', type: 'button', onclick: () => this.onOpen(r.name) },
|
||||
el('span', { class: 'lt-dot lt-dot-idle' }),
|
||||
el('span', { class: 'dd-session-name' }, r.name),
|
||||
el('span', { class: 'dd-session-meta' }, 'recent'))));
|
||||
}
|
||||
if (!this.list.children.length) {
|
||||
this.list.append(el('li', { class: 'dd-empty' }, 'No sessions yet.'));
|
||||
}
|
||||
|
||||
const projects = (this.data?.projects ?? []).filter((p) => !known.has(p.name));
|
||||
this.select.replaceChildren(el('option', { value: '' }, projects.length ? 'New session in…' : 'No other projects'));
|
||||
for (const p of projects) this.select.append(el('option', { value: p.name, title: p.dir }, p.name));
|
||||
}
|
||||
}
|
||||
+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 };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Sidebar widgets. In v1 only Sessions is live; Repo/CI and Claude Context get
|
||||
// data from devdesk-api in v1.1, Ops Alerts in v2. The "Ask Claude" pattern is
|
||||
// already wired: any [data-ask-claude] button types its prompt into the current
|
||||
// session without pressing Enter, so you can review it before sending.
|
||||
|
||||
export function bindAskClaude(root, send) {
|
||||
root.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-ask-claude]');
|
||||
if (!button || button.disabled) return;
|
||||
send(button.dataset.askClaude);
|
||||
});
|
||||
}
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
/* DevDesk layer on top of web_template base.css. App classes use the dd- prefix;
|
||||
everything visual comes from the TDS tokens defined in base.css :root. */
|
||||
|
||||
:root {
|
||||
--dd-footer-height: 30px;
|
||||
--dd-sidebar-width: 320px;
|
||||
--dd-gap: 8px;
|
||||
}
|
||||
|
||||
html,
|
||||
body.dd-app {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.dd-app {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* The CRT scanline + vignette overlays sit at z 9999 over the whole page. Keep
|
||||
them off the terminal (readability) and give the chrome its own scanlines. */
|
||||
body.dd-app::before,
|
||||
body.dd-app::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dd-sidebar,
|
||||
.dd-footer {
|
||||
background-image: repeating-linear-gradient(
|
||||
0deg, rgba(0, 0, 0, 0.07) 0, rgba(0, 0, 0, 0.07) 1px, transparent 1px, transparent 3px);
|
||||
}
|
||||
|
||||
/* ---- header ---------------------------------------------------------------- */
|
||||
.dd-header {
|
||||
padding: 0 var(--space-md);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.dd-header .lt-header-left {
|
||||
min-width: 0;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.dd-session {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dd-pane-title {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 38vw;
|
||||
}
|
||||
|
||||
.dd-sso {
|
||||
cursor: default;
|
||||
background: transparent;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.dd-sso[data-state="ok"] { color: var(--accent-green); background: var(--accent-green-dim); }
|
||||
.dd-sso[data-state="unknown"] { color: var(--text-muted); }
|
||||
.dd-sso[data-state="expired"] {
|
||||
color: var(--accent-red);
|
||||
background: var(--accent-red-dim);
|
||||
text-shadow: var(--glow-red);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dd-tools .lt-btn[data-state="granted"] { color: var(--accent-green); border-color: var(--accent-green-border); }
|
||||
.dd-tools .lt-btn[data-state="denied"] { color: var(--accent-red); }
|
||||
#sidebar-toggle[aria-expanded="true"] {
|
||||
color: var(--accent-orange);
|
||||
border-color: var(--accent-orange-border);
|
||||
}
|
||||
|
||||
/* ---- layout ---------------------------------------------------------------- */
|
||||
.dd-main {
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
bottom: var(--dd-footer-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: var(--dd-gap);
|
||||
padding: var(--dd-gap);
|
||||
}
|
||||
|
||||
.dd-terminal-frame {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
background: var(--bg-terminal);
|
||||
padding: 6px 6px 10px 10px;
|
||||
}
|
||||
|
||||
.dd-terminal {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dd-terminal .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dd-terminal .xterm-viewport {
|
||||
background-color: var(--bg-terminal) !important;
|
||||
}
|
||||
|
||||
.dd-sidebar {
|
||||
display: none;
|
||||
flex: 0 0 var(--dd-sidebar-width);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--bg-tertiary) transparent;
|
||||
}
|
||||
|
||||
body.dd-sidebar-open .dd-sidebar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dd-widget {
|
||||
margin-bottom: var(--dd-gap);
|
||||
}
|
||||
|
||||
.dd-widget .lt-section-header {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.dd-soon {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.dd-header-action {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--accent-cyan);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.dd-header-action:hover { color: var(--accent-orange); text-shadow: var(--glow-orange); }
|
||||
|
||||
.dd-muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.dd-stub-note {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
/* ---- sessions panel ---------------------------------------------------------- */
|
||||
.dd-session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.dd-session-item {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.dd-session-item.is-current {
|
||||
border-color: var(--accent-orange-border);
|
||||
background: var(--accent-orange-dim);
|
||||
}
|
||||
|
||||
.dd-session-open {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: var(--space-sm);
|
||||
align-items: center;
|
||||
padding: 4px 6px;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
.dd-session-open:hover { background: var(--accent-cyan-dim); }
|
||||
.dd-session-open .lt-dot { grid-row: 1 / span 2; }
|
||||
|
||||
.dd-session-name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dd-session-meta {
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dd-session-item.is-recent .dd-session-name { color: var(--text-secondary); font-weight: 400; }
|
||||
|
||||
.dd-session-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.dd-session-tab:hover { color: var(--accent-orange); }
|
||||
|
||||
.dd-empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.dd-new-session {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
.dd-new-session .lt-select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.dd-widget-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* ---- stub widgets ------------------------------------------------------------ */
|
||||
.dd-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.dd-alert-text { flex: 1 1 auto; }
|
||||
|
||||
.dd-context-grid { margin-top: var(--space-sm); }
|
||||
|
||||
[data-state="stub"] .lt-section-body { opacity: 0.75; }
|
||||
|
||||
/* ---- overlay ----------------------------------------------------------------- */
|
||||
.dd-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(1, 3, 4, 0.72);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.dd-overlay[hidden] { display: none; }
|
||||
|
||||
.dd-overlay-card {
|
||||
width: min(480px, 92%);
|
||||
margin: 0;
|
||||
box-shadow: var(--box-glow-cyan);
|
||||
}
|
||||
|
||||
.dd-overlay-text {
|
||||
margin-bottom: var(--space-md);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dd-overlay[data-tone="warn"] .lt-section-header { color: var(--accent-amber); text-shadow: none; }
|
||||
.dd-overlay[data-tone="danger"] .lt-section-header { color: var(--accent-red); text-shadow: var(--glow-red); }
|
||||
|
||||
/* ---- footer ------------------------------------------------------------------ */
|
||||
.dd-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: var(--dd-footer-height);
|
||||
margin: 0;
|
||||
padding: 0 var(--space-md);
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dd-footer .lt-footer-hints {
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dd-footer .lt-footer-key { margin-right: 4px; }
|
||||
|
||||
/* ---- small screens ----------------------------------------------------------- */
|
||||
@media (max-width: 1099px) {
|
||||
.dd-sidebar {
|
||||
position: absolute;
|
||||
top: var(--dd-gap);
|
||||
right: var(--dd-gap);
|
||||
bottom: var(--dd-gap);
|
||||
width: min(var(--dd-sidebar-width), calc(100vw - 2 * var(--dd-gap)));
|
||||
z-index: 20;
|
||||
background-color: var(--bg-secondary);
|
||||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.6);
|
||||
padding: var(--dd-gap);
|
||||
}
|
||||
.dd-pane-title { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 719px) {
|
||||
:root { --dd-gap: 4px; }
|
||||
.lt-brand-subtitle,
|
||||
#font-down,
|
||||
#font-up,
|
||||
#fullscreen-toggle,
|
||||
.dd-sso { display: none; }
|
||||
.dd-header .lt-header-left { gap: var(--space-sm); }
|
||||
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 5),
|
||||
.dd-footer .lt-footer-sep:nth-child(n + 4) { display: none; }
|
||||
}
|
||||
Reference in New Issue
Block a user