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,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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user