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
+75
View File
@@ -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);
});
+137
View File
@@ -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>
+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);
});
}
+331
View File
@@ -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; }
}