feat: v0.6.0 light mode, command palette, dialogs, toasts
build-deb / Lint, test, package (push) Failing after 1m59s
build-deb / Deploy to CT 126 (push) Skipped
build-deb / Notify on failure (push) Successful in 5s

- Light mode with the house ☀/◐ toggle and lt_theme preference; light
  terminal palette; Claude runs with dark-ansi so its colours follow the
  palette; tmux and the statusLine use ANSI colours.
- Command palette (⌕, Ctrl+Shift+P) with session, terminal, view, link and
  help commands.
- .lt-modal dialogs for new/rename/end and a keyboard shortcuts sheet.
- .lt-toast results for actions, uploads, copy and CI.
- ttyd dropped the reply of fast one-shot commands; replies now wait for the
  browser to hang up, and the client hangs up as soon as it has one.
- Sessions panel re-fetches after an action instead of reusing a stale poll.

Closes #31
Closes #32
Closes #33
Closes #34
Closes #35

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 00:32:23 -04:00
co-authored by Claude Opus 5.5
parent 0d86f89957
commit 05057f695d
19 changed files with 975 additions and 112 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
// each with an "Ask Claude" button that types an investigation prompt into the
// current session (without pressing Enter).
import { el } from './sessions.js';
import { el } from './dom.js';
const SEVERITY = {
critical: 'lt-chip-critical',
+32 -6
View File
@@ -121,6 +121,19 @@ export function runCommand(args, timeoutMs = 8000) {
return limited(() => runCommandNow(args, timeoutMs));
}
// A full JSON reply, or devdesk's one-line error.
function isComplete(text) {
const clean = stripAnsi(text);
if (/^devdesk: .*\n/m.test(clean)) return true;
if (!/\}\s*$/.test(clean)) return false;
try {
parseListOutput(clean);
return true;
} catch {
return false;
}
}
async function runCommandNow(args, timeoutMs) {
const token = await fetchToken();
return new Promise((resolve, reject) => {
@@ -132,15 +145,26 @@ async function runCommandNow(args, timeoutMs) {
reject(new Error('devdesk did not answer'));
}, 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 = () => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(new TextDecoder().decode(concat(chunks)));
};
socket.onmessage = (event) => {
const { type, payload } = decodeServer(event.data);
if (type !== Server.OUTPUT) return;
chunks.push(payload.slice());
// Hang up as soon as the answer is complete: devdesk waits for that
// before exiting, so ttyd can't drop its last output (devdesk#35).
if (isComplete(new TextDecoder().decode(concat(chunks)))) {
finish();
socket.close();
}
};
socket.onerror = () => {};
socket.onclose = finish;
});
}
@@ -211,6 +235,8 @@ async function uploadNow(session, file, onProgress) {
if (!sending && output.includes('DEVDESK-UPLOAD-READY')) {
sending = true;
send();
} else if (sending && isComplete(output.replace(/^[\s\S]*DEVDESK-UPLOAD-READY/, ''))) {
socket.close(); // the reply is in; let devdesk exit (devdesk#35)
}
};
socket.onerror = () => {};
+13
View File
@@ -0,0 +1,13 @@
// Tiny DOM builder: el('a', { class, href, onclick }, ...children). Attributes
// that are null/undefined/false are skipped; on* keys become event listeners.
export function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (value === undefined || value === null || value === false) continue;
if (key === 'class') node.className = value;
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else node.setAttribute(key, value === true ? '' : value);
}
node.append(...children.filter((c) => c !== null && c !== undefined));
return node;
}
+194 -21
View File
@@ -1,14 +1,17 @@
/* global __DEVDESK_VERSION__ */
import { createTerminal, loadFonts } from './term.js';
import { THEMES, createTerminal, loadFonts } from './term.js';
import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
import {
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
} from './connection.js';
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
import { SessionsPanel, el, recentNames, rememberSession, sessionStatus } from './sessions.js';
import { SessionsPanel, recentNames, rememberSession, sessionStatus } from './sessions.js';
import { renderAlerts } from './alerts.js';
import { renderRepo } from './repo.js';
import { bindAskClaude } from './widgets.js';
import { heardWithin, onShared, share } from './shared.js';
import { CommandPalette, dialogOpen, openDialog, toast } from './ui.js';
import { el } from './dom.js';
import * as notify from './notify.js';
const $ = (id) => document.getElementById(id);
@@ -70,6 +73,8 @@ const isNarrow = () => !window.matchMedia('(min-width: 1100px)').matches;
let term;
let fit;
let sessionsPanel;
let palette;
let lastRepo = null;
let lastAlerts = null;
let waitingOthers = [];
let paneTitleRaw = '';
@@ -193,7 +198,7 @@ function uploadMessage(text, isError = false, sticky = false) {
async function attachFiles(files) {
if (!files.length) return;
if (!state.session || !state.conn?.isOpen) {
uploadMessage('Open a session first: files are attached to the current session.', true);
toast.warning('Open a session first: files are attached to the current session.');
return;
}
const session = state.session;
@@ -204,15 +209,17 @@ async function attachFiles(files) {
uploadMessage(`Uploading ${file.name || 'image'}… ${Math.round(p * 100)}%`, false, true));
paths.push(path);
} catch (error) {
uploadMessage(`${file.name || 'file'}: ${error.message}`, true);
uploadUi.hidden = true;
toast.error(`${file.name || 'file'}: ${error.message}`);
return;
}
}
uploadUi.hidden = true;
if (session !== state.session) return;
// A pasted image path makes Claude attach the image itself.
term.paste(`${paths.join(' ')} `);
term.focus();
uploadMessage(paths.length === 1 ? `Attached ${paths[0]}` : `Attached ${paths.length} files`);
toast.success(paths.length === 1 ? `Attached ${paths[0].split('/').pop()}` : `Attached ${paths.length} files`);
}
function wireUploads() {
@@ -257,6 +264,7 @@ let repoBusy = false;
async function refreshRepo() {
const session = state.session;
if (!session) {
lastRepo = null;
renderRepo($('widget-repo'), null);
return;
}
@@ -266,10 +274,13 @@ async function refreshRepo() {
const repo = await fetchRepo(session);
if (session !== state.session) return;
renderRepo($('widget-repo'), repo);
lastRepo = repo;
const key = `${session}@${repo.sha}`;
const ci = repo.ci?.total ? repo.ci.state : '';
if (key === lastCi.key && lastCi.state === 'pending' && ['success', 'failure', 'error'].includes(ci)) {
notify.ring(session, `CI ${ci === 'success' ? 'passed' : 'failed'} on ${repo.branch} @ ${repo.sha}`);
const text = `CI ${ci === 'success' ? 'passed' : 'failed'} on ${repo.branch} @ ${repo.sha}`;
notify.ring(session, text);
toast[ci === 'success' ? 'success' : 'error'](text);
}
lastCi = { key, state: ci };
} catch {
@@ -497,10 +508,7 @@ function renderSwitcher() {
el('div', { class: 'dd-menu-sep' }),
el('button', {
type: 'button', role: 'menuitem', class: 'dd-menu-item',
onclick: choose(() => {
setSidebar(true);
sessionsPanel.toggleForm(true);
}),
onclick: choose(() => sessionsPanel.newDialog()),
}, '+ New session…'),
el('button', { type: 'button', role: 'menuitem', class: 'dd-menu-item', onclick: choose(() => connect(null)) }, 'Open picker'),
);
@@ -509,6 +517,7 @@ function renderSwitcher() {
// ---------------------------------------------------------------- chrome controls
function setFontSize(size) {
if (!Number.isFinite(size)) return;
const clamped = Math.min(24, Math.max(10, size));
term.options.fontSize = clamped;
store.set('fontSize', clamped);
@@ -516,6 +525,157 @@ function setFontSize(size) {
fit.fit();
}
async function toggleNotifications() {
const current = notify.notificationsState();
if (current === 'default') {
const result = await notify.requestNotifications();
if (result === 'granted') toast.success('Desktop notifications on');
else if (result === 'denied') toast.warning('The browser blocked notifications for this site');
} else if (current === 'granted') {
notify.setMuted(!notify.isMuted());
toast.info(notify.isMuted() ? 'Desktop notifications muted in this browser' : 'Desktop notifications unmuted');
} else {
toast.warning(current === 'denied' ? 'Notifications are blocked in the browser\'s site settings' : 'This browser has no notifications');
}
}
// ---------------------------------------------------------------- palette & help
const sendKeys = (keys) => state.conn?.sendInput(keys);
async function copyLink() {
const url = new URL(window.location.href);
url.search = state.session ? `?arg=${encodeURIComponent(state.session)}` : '';
try {
await navigator.clipboard.writeText(url.href);
toast.success('Link copied');
} catch {
toast.error('Could not copy: the browser refused clipboard access');
}
}
const openUrl = (url) => window.open(url, '_blank', 'noopener');
// Commands for the palette (devdesk#32), rebuilt on every open.
function buildCommands() {
const sessions = [...(sessionsPanel?.data?.sessions ?? [])].sort((a, b) =>
(b.running - a.running) || ((b.activity || 0) - (a.activity || 0)) || a.name.localeCompare(b.name));
const current = sessions.find((s) => s.name === state.session);
const panelsOpen = ui.body.classList.contains('dd-sidebar-open');
const notifyState = notify.notificationsState();
const commands = [];
const add = (command) => commands.push(command);
for (const s of sessions) {
if (s.name === state.session) continue;
const status = sessionStatus(s);
add({
id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: status.label, keywords: s.dir,
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name),
});
}
add({ id: 'new', group: 'Sessions', icon: '+', label: 'New session…', keywords: 'create open project', run: () => sessionsPanel.newDialog() });
if (state.session) {
add({ id: 'rename', group: 'Sessions', icon: '✎', label: `Rename ${state.session}…`, run: () => sessionsPanel.rename(state.session) });
add({
id: 'end', group: 'Sessions', icon: '×', label: `End ${state.session}…`, keywords: 'close kill stop',
run: () => sessionsPanel.end(current ?? { name: state.session, running: true }),
});
add({ id: 'copy-link', group: 'Sessions', icon: '⧉', label: 'Copy link to this session', keywords: 'url share', run: copyLink });
}
add({ id: 'picker', group: 'Sessions', icon: '≡', label: 'Open the session picker', keywords: 'fzf list', run: () => connect(null) });
if (state.session && state.conn?.isOpen) {
add({ id: 'win-claude', group: 'Terminal', icon: '0', label: 'Claude window', kbd: '^b 0', run: () => sendKeys('\x020') });
add({ id: 'win-shell', group: 'Terminal', icon: '1', label: 'Shell window', kbd: '^b 1', run: () => sendKeys('\x021') });
add({ id: 'scroll', group: 'Terminal', icon: '⇞', label: 'Scroll back (tmux copy mode, q to leave)', kbd: '^b [', keywords: 'history scrollback', run: () => sendKeys('\x02[') });
add({ id: 'detach', group: 'Terminal', icon: '⏏', label: 'Detach (the session keeps running)', kbd: '^b d', run: () => sendKeys('\x02d') });
}
add({
id: 'theme', group: 'View', icon: getTheme() === 'light' ? '◐' : '☀', keywords: 'theme light dark mode colour color',
label: getTheme() === 'light' ? 'Switch to dark mode' : 'Switch to light mode', run: toggleTheme,
});
add({ id: 'panels', group: 'View', icon: '▤', label: panelsOpen ? 'Hide panels' : 'Show panels', keywords: 'sidebar', run: () => setSidebar(!panelsOpen) });
add({ id: 'font-up', group: 'View', icon: 'A+', label: 'Larger text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize + 1) });
add({ id: 'font-down', group: 'View', icon: 'A−', label: 'Smaller text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize - 1) });
add({ id: 'font-reset', group: 'View', icon: 'A', label: 'Reset text size', keywords: 'font zoom', run: () => setFontSize(14) });
add({ id: 'fullscreen', group: 'View', icon: '⛶', label: document.fullscreenElement ? 'Exit fullscreen' : 'Fullscreen', run: toggleFullscreen });
if (notifyState !== 'unsupported') {
add({
id: 'notify', group: 'View', icon: '🔔', keywords: 'notifications alerts bell',
label: { default: 'Enable desktop notifications', denied: 'Desktop notifications (blocked)', granted: notify.isMuted() ? 'Unmute desktop notifications' : 'Mute desktop notifications' }[notifyState],
run: () => toggleNotifications().then(renderNotifyButton),
});
}
if (lastRepo?.url) {
add({ id: 'open-repo', group: 'Links', icon: '↗', label: `Open ${lastRepo.repo} in Gitea`, run: () => openUrl(lastRepo.url) });
add({ id: 'open-prs', group: 'Links', icon: '↗', label: 'Open pull requests', run: () => openUrl(`${lastRepo.url}/pulls`) });
if (lastRepo.ci?.url) add({ id: 'open-ci', group: 'Links', icon: '↗', label: `Open CI run (${lastRepo.ci.state})`, run: () => openUrl(lastRepo.ci.url) });
}
if (lastAlerts?.alerts?.length) {
add({
id: 'alerts', group: 'Links', icon: '!', label: `Show ${lastAlerts.alerts.length} ops alert(s)`, keywords: 'grafana',
run: () => {
setSidebar(true);
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
},
});
}
add({ id: 'help', group: 'Help', icon: '?', label: 'Keyboard shortcuts', keywords: 'keys help', run: showHelp });
// After a command, typing goes back to the terminal unless it opened a dialog.
return commands.map((c) => ({
...c,
run: () => {
c.run();
if (!dialogOpen()) term.focus();
},
}));
}
const KEY_HELP = [
['Claude', [
['⇧ Enter', 'New line in the prompt'],
['Esc', 'Interrupt Claude'],
['⇧ Tab', 'Cycle mode (auto-accept, plan…)'],
['↑ / ↓', 'Previous / next prompt'],
['Ctrl+C', 'Cancel; twice to quit (the menu offers resume)'],
]],
['Windows & sessions (tmux)', [
['Ctrl-b 0 / 1', 'Claude window / shell window'],
['Ctrl-b s', 'Switch session (the URL follows)'],
['Ctrl-b [', 'Scroll back; q to leave'],
['Ctrl-b d', 'Detach (the session keeps running)'],
]],
['DevDesk', [
['Ctrl+⇧ P', 'Command palette'],
['Ctrl+⇧ C / V', 'Copy selection / paste'],
['⇧ drag', 'Select text in the browser'],
['Drop or paste a file', 'Upload it and attach it for Claude'],
['Ctrl+W', 'Asks before closing; Chromium fullscreen keeps it for the terminal'],
]],
];
function showHelp() {
const body = el('div', { class: 'dd-keys' }, ...KEY_HELP.map(([title, rows]) => el('section', {},
el('h3', { class: 'dd-keys-title' }, title),
el('dl', { class: 'dd-keys-list' }, ...rows.flatMap(([keys, what]) => [el('dt', {}, el('kbd', {}, keys)), el('dd', {}, what)])))));
if (touch) body.append(el('p', { class: 'dd-dialog-hint' }, 'On a phone, the key bar under the terminal sends Esc, Tab, ⇧Tab, Ctrl+C, arrows, Enter and the window switches.'));
openDialog({ title: 'Keyboard shortcuts', body, actions: [{ label: 'Close', primary: true }] }).then(() => term.focus());
}
async function toggleFullscreen() {
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(() => {});
}
function renderNotifyButton() {
const current = notify.notificationsState();
const labels = {
@@ -578,20 +738,29 @@ 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 () => {
if (notify.notificationsState() === 'default') await notify.requestNotifications();
else if (notify.notificationsState() === 'granted') notify.setMuted(!notify.isMuted());
await toggleNotifications();
renderNotifyButton();
});
$('fullscreen-toggle').addEventListener('click', async () => {
$('fullscreen-toggle').addEventListener('click', () => {
closeMenus();
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(() => {});
toggleFullscreen();
});
$('help-open').addEventListener('click', () => {
closeMenus();
showHelp();
});
$('footer-help').addEventListener('click', showHelp);
palette = new CommandPalette(buildCommands);
$('cmd-toggle').addEventListener('click', () => palette.open());
// Ctrl+Shift+P (⌘⇧P): Ctrl+K belongs to the terminal (kill-line in Claude and bash).
document.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyP') {
event.preventDefault();
event.stopPropagation();
if (palette.isOpen) palette.close();
else if (!dialogOpen()) palette.open();
}
}, true);
wireMenu(ui.moreToggle, ui.moreMenu, renderNotifyButton);
wireMenu(ui.sessionChip, ui.switcher, () => {
renderSwitcher();
@@ -608,7 +777,7 @@ function wireChrome() {
// Esc closes menus and the narrow-screen drawer before the terminal sees it.
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
if (event.key !== 'Escape' || dialogOpen()) return;
const menuOpen = menus.some(({ menu }) => !menu.hidden);
const drawerOpen = isNarrow() && ui.body.classList.contains('dd-sidebar-open');
if (!menuOpen && !drawerOpen) return;
@@ -680,6 +849,7 @@ function defaultSession(data) {
// ---------------------------------------------------------------- boot
async function boot() {
initTheme();
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
$('font-size').textContent = String(fontSize);
@@ -687,8 +857,11 @@ async function boot() {
({ term, fit } = createTerminal($('terminal'), {
fontSize,
theme: getTheme(),
onShiftEnter: () => state.conn?.sendInput('\n'),
}));
onThemeChange((theme) => { term.options.theme = THEMES[theme]; });
$('theme-toggle').addEventListener('click', toggleTheme);
term.onData((data) => state.conn?.sendInput(data));
term.onBinary((data) => state.conn?.sendBinary(data));
term.onResize(({ cols, rows }) => state.conn?.resize(cols, rows));
+105 -40
View File
@@ -4,6 +4,10 @@
import { fetchSessionList, sessionAction } from './connection.js';
import { slugify } from './protocol.js';
import { share } from './shared.js';
import { el } from './dom.js';
import { confirmDialog, openDialog, toast } from './ui.js';
export { el };
const RECENTS_KEY = 'devdesk.recents';
const MAX_RECENTS = 12;
@@ -51,17 +55,6 @@ export function ago(epochSeconds) {
return `${Math.floor(s / 86400)}d`;
}
export function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (value === undefined || value === null || value === false) continue;
if (key === 'class') node.className = value;
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else node.setAttribute(key, value === true ? '' : value);
}
node.append(...children.filter((c) => c !== null && c !== undefined));
return node;
}
// What Claude is doing in a session, from the devdesk-state-hook snapshot.
// Returns { key, label, dot } for display; key is one of
@@ -114,6 +107,12 @@ export class SessionsPanel {
return this.pending;
}
// After an action: a poll already in flight may predate it, so fetch again.
async reload() {
await this.pending;
return this.refresh();
}
async load() {
let fetched = false;
try {
@@ -135,14 +134,13 @@ export class SessionsPanel {
this.hooks.onData?.(this.data, { fetched: false });
}
async act(label, action, ...args) {
this.setStatus(`${label}…`);
// Side-channel action; failures become an error toast (devdesk#34).
async act(action, ...args) {
try {
await sessionAction(action, ...args);
this.setStatus('');
return true;
} catch (err) {
this.setStatus(err.message, true);
toast.error(err.message);
return false;
}
}
@@ -151,43 +149,116 @@ export class SessionsPanel {
const project = this.select.value;
const name = slugify(this.nameInput.value) || project;
if (!name) {
this.setStatus('Type a session name or pick a project.', true);
toast.warning('Type a session name or pick a project.');
this.nameInput.focus();
return;
}
// With a project the session starts in that folder; without one, in the default folder.
if (project && !(await this.act('creating', 'new', name, project))) return;
if (!(await this.create(name, project))) return;
this.nameInput.value = '';
this.select.value = '';
this.toggleForm(false);
}
// With a project the session starts in that folder; without one, opening it
// creates it in the default folder (or its own project folder, by name).
async create(name, project) {
if (project && !(await this.act('new', name, project))) return false;
this.hooks.onOpen(name);
return true;
}
projectOptions(select) {
const projects = this.data?.projects ?? [];
const chosen = select.value;
select.replaceChildren(
el('option', { value: '' }, `No project (${this.data?.root ?? 'default folder'})`),
...projects.filter((p) => p.name !== this.data?.default)
.map((p) => el('option', { value: p.name, title: p.dir }, p.name)));
select.value = chosen;
}
// "+ New session…" from the switcher or the command palette (devdesk#33).
async newDialog() {
const name = el('input', { class: 'lt-input', maxlength: '64', placeholder: 'e.g. pbs-upgrade', spellcheck: 'false', autocomplete: 'off', 'aria-describedby': 'dd-new-hint' });
const project = el('select', { class: 'lt-select' });
this.projectOptions(project);
const hint = el('p', { class: 'dd-dialog-hint', id: 'dd-new-hint' }, 'Letters, numbers, - and _. Any name works; pick a project to start in its folder.');
project.addEventListener('change', () => {
if (!name.value.trim()) name.placeholder = project.value || 'e.g. pbs-upgrade';
});
const result = await openDialog({
title: 'New session',
body: el('div', { class: 'dd-dialog-fields' },
el('label', { class: 'dd-field' }, el('span', {}, 'Name'), name),
el('label', { class: 'dd-field' }, el('span', {}, 'Project folder'), project),
hint),
actions: [{ label: 'Cancel' }, { label: 'Open', primary: true }],
submit: () => {
const value = slugify(name.value) || project.value;
if (!value) {
hint.textContent = 'Type a name or pick a project.';
hint.classList.add('is-error');
name.focus();
return null;
}
return { name: value, project: project.value };
},
});
if (result) await this.create(result.name, result.project);
}
async rename(name) {
const answer = window.prompt(`Rename session "${name}" to:`, name);
if (answer === null) return;
const next = slugify(answer);
if (!next || next === name) {
if (!next) this.setStatus('Names use letters, numbers, - and _.', true);
return;
}
if (await this.act('renaming', 'rename', name, next)) {
const input = el('input', { class: 'lt-input', maxlength: '64', value: name, spellcheck: 'false', autocomplete: 'off', 'aria-describedby': 'dd-rename-hint' });
const hint = el('p', { class: 'dd-dialog-hint', id: 'dd-rename-hint' }, 'Claude keeps running; the conversation moves with the session.');
const next = await openDialog({
title: `Rename ${name}`,
size: 'sm',
body: el('div', { class: 'dd-dialog-fields' }, el('label', { class: 'dd-field' }, el('span', {}, 'New name'), input), hint),
actions: [{ label: 'Cancel' }, { label: 'Rename', primary: true }],
onOpen: () => {
input.select();
return input;
},
submit: () => {
const value = slugify(input.value);
if (!value) {
hint.textContent = 'Names use letters, numbers, - and _.';
hint.classList.add('is-error');
return null;
}
if (value !== name && this.data?.sessions?.some((x) => x.name === value)) {
hint.textContent = `A session called ${value} already exists.`;
hint.classList.add('is-error');
return null;
}
return value;
},
});
if (!next || next === name) return;
if (await this.act('rename', name, next)) {
renameRecent(name, next);
this.hooks.onRenamed(name, next);
await this.refresh();
toast.success(`Renamed ${name} → ${next}`);
await this.reload();
}
}
async end(session) {
const question = session.running
? `End session "${session.name}"?\n\nClaude in this session stops. The conversation stays in Claude's history (claude --resume).`
: `Forget dormant session "${session.name}"?`;
if (!window.confirm(question)) return;
const ok = await confirmDialog({
title: session.running ? `End ${session.name}?` : `Forget ${session.name}?`,
text: session.running
? 'Claude in this session stops and the session closes. The conversation stays in Claude\'s history (claude --resume).'
: 'This dormant session is removed from the list. Its conversation stays in Claude\'s history.',
confirmLabel: session.running ? 'End session' : 'Forget',
danger: true,
});
if (!ok) return;
if (session.name === this.current) this.hooks.onEnding?.(session.name);
if (await this.act(session.running ? 'ending' : 'forgetting', 'end', session.name)) {
if (await this.act('end', session.name)) {
forgetRecent(session.name);
this.hooks.onEnded(session.name);
await this.refresh();
toast.success(session.running ? `Ended ${session.name}` : `Forgot ${session.name}`);
await this.reload();
} else if (session.name === this.current) {
this.hooks.onEndAborted?.();
}
@@ -250,12 +321,6 @@ export class SessionsPanel {
if (!items.length) items.push(el('li', { class: 'dd-empty' }, 'No sessions yet.'));
this.list.replaceChildren(...items);
const projects = this.data?.projects ?? [];
const chosen = this.select.value;
this.select.replaceChildren(
el('option', { value: '' }, `No project (${this.data?.root ?? 'default folder'})`),
...projects.filter((p) => p.name !== this.data?.default)
.map((p) => el('option', { value: p.name, title: p.dir }, p.name)));
this.select.value = chosen;
this.projectOptions(this.select);
}
}
+61 -26
View File
@@ -15,30 +15,65 @@ const writeOnlyClipboard = {
},
};
// 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',
// Terminal palettes from the LotusGuild TDS tokens (devdesk#31).
//
// DevDesk runs Claude with its `dark-ansi` theme, so Claude draws with these 16
// colours instead of fixed RGB: body text is bright white, secondary text is
// white, the band behind your messages is bright black, and text on coloured
// backgrounds is black. Claude's theme is per process and printed text keeps its
// colours, so instead of switching Claude, the light palette inverts the grey
// ramp: bright white is ink, white is muted ink, bright black a light band and
// black the page. Anything written for dark terminals, Claude's scrollback
// included, stays readable, and toggling is instant.
export const THEMES = Object.freeze({
dark: 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: '#9fb6cc',
brightBlack: '#3e607a',
brightRed: '#FF5C7A',
brightGreen: '#33FFAA',
brightYellow: '#FFD700',
brightBlue: '#6AA8FF',
brightMagenta: '#D08CFF',
brightCyan: '#33DFFF',
brightWhite: '#FFFFFF',
}),
light: Object.freeze({
background: '#f4f6fa',
foreground: '#16202e',
cursor: '#c44e00',
cursorAccent: '#f4f6fa',
selectionBackground: 'rgba(0, 98, 184, 0.22)',
selectionInactiveBackground: 'rgba(0, 98, 184, 0.12)',
black: '#eef1f6',
red: '#b5001f',
green: '#006d35',
yellow: '#8a5a00',
blue: '#1f4fbf',
magenta: '#6b2fb8',
cyan: '#006b86',
white: '#556578',
brightBlack: '#c9d1dc',
brightRed: '#cc1f3d',
brightGreen: '#00803e',
brightYellow: '#9a6400',
brightBlue: '#0062b8',
brightMagenta: '#7e3fd0',
brightCyan: '#007f9e',
brightWhite: '#16202e',
}),
});
export const FONT_FAMILY = '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace';
@@ -55,7 +90,7 @@ export async function loadFonts(size) {
}
}
export function createTerminal(container, { fontSize, onShiftEnter }) {
export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnter }) {
const term = new Terminal({
fontFamily: FONT_FAMILY,
fontSize,
@@ -65,7 +100,7 @@ export function createTerminal(container, { fontSize, onShiftEnter }) {
allowProposedApi: true, // unicode11
macOptionIsMeta: true,
drawBoldTextInBrightColors: false,
theme: THEME,
theme: THEMES[theme],
});
const fit = new FitAddon();
+62
View File
@@ -0,0 +1,62 @@
// Dark / light theme, interchangeable with web_template's lt.theme (devdesk#31):
// same localStorage key (lt_theme), same data-theme attribute on <html>, the OS
// preference while nothing is saved, and tabs follow each other via `storage`.
// The inline script in index.template.html applies it before first paint.
const KEY = 'lt_theme';
const prefersLight = window.matchMedia('(prefers-color-scheme: light)');
const listeners = new Set();
function saved() {
try {
const value = localStorage.getItem(KEY);
return value === 'light' || value === 'dark' ? value : null;
} catch {
return null;
}
}
export function getTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
}
function apply(theme) {
const root = document.documentElement;
root.setAttribute('data-theme', theme);
root.style.colorScheme = theme;
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme === 'light' ? '#edf0f5' : '#030508');
const next = theme === 'light' ? 'dark' : 'light';
for (const button of document.querySelectorAll('.lt-theme-btn')) {
button.textContent = theme === 'light' ? '◐' : '☀';
button.setAttribute('aria-label', `Switch to ${next} mode`);
button.title = `Switch to ${next} mode`;
}
for (const listener of listeners) listener(theme);
}
export function setTheme(theme) {
try {
localStorage.setItem(KEY, theme);
} catch {
// Private mode: the choice lasts until the tab closes.
}
apply(theme);
}
export function toggleTheme() {
setTheme(getTheme() === 'light' ? 'dark' : 'light');
}
export function onThemeChange(listener) {
listeners.add(listener);
}
export function initTheme() {
apply(saved() ?? (prefersLight.matches ? 'light' : 'dark'));
prefersLight.addEventListener('change', (event) => {
if (!saved()) apply(event.matches ? 'light' : 'dark');
});
window.addEventListener('storage', (event) => {
if (event.key === KEY) apply(saved() ?? (prefersLight.matches ? 'light' : 'dark'));
});
}
+295
View File
@@ -0,0 +1,295 @@
// House UI pieces built on web_template's base.css classes, without base.js
// (its global key handler swallows Esc, which interrupts Claude, and every toast
// beeps): toasts (devdesk#34), dialogs (#33) and the command palette (#32).
import { el } from './dom.js';
// ---------------------------------------------------------------- toasts
const ICONS = { success: '✓', error: '✗', warning: '!', info: 'i' };
function toastContainer() {
let box = document.getElementById('lt-toast-container');
if (!box) {
box = el('div', { id: 'lt-toast-container', 'aria-live': 'polite' });
document.body.append(box);
}
return box;
}
function dismiss(node) {
if (!node.isConnected) return;
clearTimeout(node.dismissTimer);
node.classList.add('is-leaving');
setTimeout(() => node.remove(), 250);
}
export function toast(message, type = 'info', duration = 4000) {
const box = toastContainer();
// At most four on screen: the oldest goes first.
while (box.children.length >= 4) box.firstElementChild.remove();
const node = el('div', { class: `lt-toast lt-toast-${type}`, role: type === 'error' ? 'alert' : 'status' },
el('span', { class: 'lt-toast-icon', 'aria-hidden': 'true' }, `[${ICONS[type] || 'i'}]`),
el('span', { class: 'lt-toast-msg' }, message),
el('button', { type: 'button', class: 'lt-toast-close', 'aria-label': 'Dismiss', onclick: () => dismiss(node) }, '✕'),
el('div', { class: 'lt-toast-progress', style: `animation-duration:${duration}ms` }));
box.append(node);
node.dismissTimer = setTimeout(() => dismiss(node), duration);
node.addEventListener('mouseenter', () => clearTimeout(node.dismissTimer));
node.addEventListener('mouseleave', () => { node.dismissTimer = setTimeout(() => dismiss(node), 1500); });
}
toast.success = (message, duration) => toast(message, 'success', duration);
toast.error = (message, duration = 8000) => toast(message, 'error', duration);
toast.warning = (message, duration) => toast(message, 'warning', duration);
toast.info = (message, duration) => toast(message, 'info', duration);
// ---------------------------------------------------------------- dialogs
let openDialogs = 0;
export const dialogOpen = () => openDialogs > 0 || document.getElementById('lt-cmd-overlay')?.classList.contains('is-open');
// A .lt-modal dialog. `body` is a node; `actions` are { label, value, primary,
// danger }. Resolves with the chosen action's value (or `submit()`'s value when
// a form is submitted), or null when dismissed (✕, Esc, backdrop).
export function openDialog({ title, body, actions = [], danger = false, size = '', onOpen, submit }) {
return new Promise((resolve) => {
const opener = document.activeElement;
let done = false;
const close = (value) => {
if (done) return;
done = true;
openDialogs -= 1;
overlay.remove();
if (opener?.isConnected) opener.focus();
resolve(value);
};
const titleId = `dd-dialog-${Date.now()}`;
const form = el('form', { class: 'lt-modal-body dd-dialog-body', novalidate: '' }, body);
form.addEventListener('submit', (event) => {
event.preventDefault();
const value = submit ? submit() : true;
if (value !== undefined && value !== null && value !== false) close(value);
});
const buttons = actions.map((action) => el('button', {
type: action.primary ? 'submit' : 'button',
class: `lt-btn lt-btn-sm${action.primary ? (action.danger ? ' lt-btn-danger' : ' lt-btn-primary') : ' lt-btn-ghost'}`,
onclick: action.primary ? null : () => close(action.value ?? null),
}, action.label));
// The primary button submits the form, so Enter in a field does the same.
for (const button of buttons) {
if (button.type === 'submit') button.setAttribute('form', titleId);
}
form.id = titleId;
const overlay = el('div', { class: 'lt-modal-overlay is-open dd-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': `${titleId}-title` },
el('div', { class: `lt-modal${size ? ` lt-modal-${size}` : ''}` },
el('div', { class: `lt-modal-header${danger ? ' lt-modal-header--danger' : ''}` },
el('h2', { class: 'lt-modal-title', id: `${titleId}-title` }, title),
el('button', { type: 'button', class: 'lt-modal-close', 'aria-label': 'Close', onclick: () => close(null) }, '✕')),
form,
buttons.length ? el('div', { class: 'lt-modal-footer' }, ...buttons) : null));
overlay.addEventListener('mousedown', (event) => {
if (event.target === overlay) close(null);
});
overlay.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
close(null);
return;
}
if (event.key === 'Tab') trapFocus(overlay, event);
});
openDialogs += 1;
document.body.append(overlay);
(onOpen?.(overlay) ?? overlay.querySelector('input, select, textarea, .lt-btn-primary, .lt-btn-danger, .lt-modal-close'))?.focus?.();
});
}
function trapFocus(root, event) {
const items = [...root.querySelectorAll('button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])')]
.filter((node) => !node.disabled && node.offsetParent !== null);
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
export function confirmDialog({ title, text, confirmLabel = 'OK', danger = false }) {
return openDialog({
title,
danger,
size: 'sm',
body: el('p', { class: 'dd-dialog-text' }, text),
actions: [{ label: 'Cancel', value: false }, { label: confirmLabel, primary: true, danger }],
}).then(Boolean);
}
// ---------------------------------------------------------------- command palette
const RECENT_KEY = 'lt_cmd_recent';
function readRecent() {
try {
const list = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
return Array.isArray(list) ? list : [];
} catch {
return [];
}
}
function pushRecent(id) {
try {
localStorage.setItem(RECENT_KEY, JSON.stringify([id, ...readRecent().filter((x) => x !== id)].slice(0, 5)));
} catch {
// Private mode: no recents.
}
}
// Commands: { id, label, group, icon?, hint?, kbd?, keywords?, run }. `getCommands`
// is called on every open, so the list reflects the current sessions and state.
export class CommandPalette {
constructor(getCommands) {
this.getCommands = getCommands;
this.commands = [];
this.visible = [];
this.selected = 0;
this.opener = null;
this.input = el('input', {
class: 'lt-cmd-input', type: 'text', placeholder: 'Type a command or a session name…',
autocomplete: 'off', spellcheck: 'false', 'aria-label': 'Command', role: 'combobox',
'aria-controls': 'dd-cmd-results', 'aria-expanded': 'true',
});
this.results = el('div', { class: 'lt-cmd-results', id: 'dd-cmd-results', role: 'listbox' });
this.overlay = el('div', { class: 'lt-cmd-overlay', id: 'lt-cmd-overlay', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Command palette' },
el('div', { class: 'lt-cmd-palette', id: 'lt-cmd-palette' },
el('div', { class: 'lt-cmd-input-wrap' }, el('span', { class: 'lt-cmd-prompt', 'aria-hidden': 'true' }, '>'), this.input),
this.results,
el('div', { class: 'lt-cmd-footer' },
el('span', {}, el('kbd', {}, '↑'), el('kbd', {}, '↓'), ' move'),
el('span', {}, el('kbd', {}, '⏎'), ' run'),
el('span', {}, el('kbd', {}, 'Esc'), ' close'))));
document.body.append(this.overlay);
this.input.addEventListener('input', () => this.render());
this.input.addEventListener('keydown', (event) => this.onKey(event));
this.overlay.addEventListener('mousedown', (event) => {
if (event.target === this.overlay) this.close();
});
}
get isOpen() {
return this.overlay.classList.contains('is-open');
}
open(query = '') {
this.opener = document.activeElement;
this.commands = this.getCommands();
this.overlay.classList.add('is-open');
this.input.value = query;
this.render();
this.input.focus();
}
close({ restoreFocus = true } = {}) {
if (!this.isOpen) return;
this.overlay.classList.remove('is-open');
if (restoreFocus && this.opener?.isConnected) this.opener.focus();
}
onKey(event) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
this.move(event.key === 'ArrowDown' ? 1 : -1);
} else if (event.key === 'Enter') {
event.preventDefault();
this.run(this.visible[this.selected]);
} else if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.close();
} else if (event.key === 'Tab') {
event.preventDefault();
}
}
move(step) {
if (!this.visible.length) return;
this.selected = (this.selected + step + this.visible.length) % this.visible.length;
this.highlight();
}
highlight() {
const items = this.results.querySelectorAll('.lt-cmd-item');
items.forEach((item, i) => {
item.classList.toggle('is-selected', i === this.selected);
item.setAttribute('aria-selected', String(i === this.selected));
});
const current = items[this.selected];
if (current) {
current.scrollIntoView({ block: 'nearest' });
this.input.setAttribute('aria-activedescendant', current.id);
}
}
run(command) {
if (!command) return;
pushRecent(command.id);
// The command decides where focus goes (usually the terminal).
this.close({ restoreFocus: false });
command.run();
}
render() {
const words = this.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
const matches = this.commands.filter((c) => {
const text = `${c.label} ${c.group} ${c.keywords ?? ''} ${c.hint ?? ''}`.toLowerCase();
return words.every((w) => text.includes(w));
});
const groups = new Map();
if (!words.length) {
const recent = readRecent().map((id) => matches.find((c) => c.id === id)).filter(Boolean).slice(0, 3);
if (recent.length) groups.set('Recent', recent);
}
for (const command of matches) {
if (!groups.has(command.group)) groups.set(command.group, []);
groups.get(command.group).push(command);
}
this.visible = [...groups.values()].flat();
this.selected = 0;
if (!this.visible.length) {
this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, `No command matches “${this.input.value.trim()}”`));
return;
}
let index = 0;
const nodes = [];
for (const [group, commands] of groups) {
nodes.push(el('div', { class: 'lt-cmd-group-label', role: 'presentation' }, group));
for (const command of commands) {
const i = index;
nodes.push(el('div', {
class: 'lt-cmd-item', id: `dd-cmd-${i}`, role: 'option',
onclick: () => this.run(command),
onmousemove: () => {
if (this.selected !== i) {
this.selected = i;
this.highlight();
}
},
},
el('span', { class: 'lt-cmd-item-icon', 'aria-hidden': 'true' }, command.icon || '◦'),
el('span', { class: 'lt-cmd-item-label' }, command.label),
command.hint ? el('span', { class: 'dd-cmd-hint' }, command.hint) : null,
command.kbd ? el('span', { class: 'lt-cmd-item-kbd' }, command.kbd) : null));
index += 1;
}
}
this.results.replaceChildren(...nodes);
this.highlight();
}
}