feat: DevDesk v0.1.0, a persistent browser terminal for Claude Code
build-deb / Lint, test, package (push) Canceled after 1m13s
build-deb / Notify on failure (push) Canceled after 0s

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:
2026-10-06 23:11:07 -04:00
co-authored by Claude Opus 5.5
commit 07a41bd625
52 changed files with 10769 additions and 0 deletions
+135
View File
@@ -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;
}
+12
View File
@@ -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
View File
@@ -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();
+60
View File
@@ -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);
+106
View File
@@ -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;
}
}
+118
View File
@@ -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
View File
@@ -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 };
}
+12
View File
@@ -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);
});
}