2026-10-08 00:32:23 -04:00
|
|
|
// 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;
|
2026-10-08 01:40:47 -04:00
|
|
|
export const dialogOpen = () => openDialogs > 0 || !!document.querySelector('.lt-cmd-overlay.is-open');
|
2026-10-08 00:32:23 -04:00
|
|
|
|
|
|
|
|
// 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).
|
2026-10-10 20:10:45 -04:00
|
|
|
export function openDialog({ title, body, actions = [], danger = false, size = '', className = '', onOpen, submit }) {
|
2026-10-08 00:32:23 -04:00
|
|
|
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;
|
2026-10-10 20:10:45 -04:00
|
|
|
const overlay = el('div', { class: `lt-modal-overlay is-open dd-dialog${className ? ` ${className}` : ''}`, role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': `${titleId}-title` },
|
2026-10-08 00:32:23 -04:00
|
|
|
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`
|
2026-10-08 01:40:47 -04:00
|
|
|
// (may be async) is called on every open, so the list reflects the current state.
|
|
|
|
|
// Options: id (DOM prefix), placeholder, search(commands, query) -> ranked list
|
|
|
|
|
// (replaces the default word filter, recents and grouping; used by Go to file).
|
2026-10-08 00:32:23 -04:00
|
|
|
export class CommandPalette {
|
2026-10-08 01:40:47 -04:00
|
|
|
constructor(getCommands, options = {}) {
|
|
|
|
|
this.options = { id: 'lt-cmd', placeholder: 'Type a command or a session name…', ...options };
|
|
|
|
|
const id = this.options.id;
|
2026-10-08 00:32:23 -04:00
|
|
|
this.getCommands = getCommands;
|
|
|
|
|
this.commands = [];
|
|
|
|
|
this.visible = [];
|
|
|
|
|
this.selected = 0;
|
|
|
|
|
this.opener = null;
|
|
|
|
|
this.input = el('input', {
|
2026-10-08 01:40:47 -04:00
|
|
|
class: 'lt-cmd-input', type: 'text', placeholder: this.options.placeholder,
|
|
|
|
|
autocomplete: 'off', spellcheck: 'false', 'aria-label': this.options.placeholder, role: 'combobox',
|
|
|
|
|
'aria-controls': `${id}-results`, 'aria-expanded': 'true',
|
2026-10-08 00:32:23 -04:00
|
|
|
});
|
2026-10-08 01:40:47 -04:00
|
|
|
this.results = el('div', { class: 'lt-cmd-results', id: `${id}-results`, role: 'listbox' });
|
|
|
|
|
this.overlay = el('div', { class: 'lt-cmd-overlay', id: `${id}-overlay`, role: 'dialog', 'aria-modal': 'true', 'aria-label': this.options.placeholder },
|
|
|
|
|
el('div', { class: 'lt-cmd-palette', id: `${id}-palette` },
|
2026-10-08 00:32:23 -04:00
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-08 01:40:47 -04:00
|
|
|
async open(query = '') {
|
2026-10-08 00:32:23 -04:00
|
|
|
this.opener = document.activeElement;
|
|
|
|
|
this.overlay.classList.add('is-open');
|
|
|
|
|
this.input.value = query;
|
|
|
|
|
this.input.focus();
|
2026-10-08 01:40:47 -04:00
|
|
|
const pending = this.getCommands();
|
|
|
|
|
if (pending instanceof Promise) {
|
|
|
|
|
this.commands = [];
|
|
|
|
|
this.loading = true;
|
|
|
|
|
this.render();
|
|
|
|
|
try {
|
|
|
|
|
this.commands = await pending;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.commands = [];
|
|
|
|
|
this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, error.message));
|
|
|
|
|
return;
|
|
|
|
|
} finally {
|
|
|
|
|
this.loading = false;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
this.commands = pending;
|
|
|
|
|
}
|
|
|
|
|
if (this.isOpen) this.render();
|
2026-10-08 00:32:23 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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() {
|
2026-10-08 01:40:47 -04:00
|
|
|
if (this.loading) {
|
|
|
|
|
this.visible = [];
|
|
|
|
|
this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, 'Loading…'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (this.options.search) {
|
|
|
|
|
this.renderList(this.options.search(this.commands, this.input.value.trim()));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-08 00:32:23 -04:00
|
|
|
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;
|
|
|
|
|
}
|
2026-10-08 01:40:47 -04:00
|
|
|
this.renderGroups(groups);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A ranked list without group headers (custom search).
|
|
|
|
|
renderList(list) {
|
|
|
|
|
this.visible = list;
|
|
|
|
|
this.selected = 0;
|
|
|
|
|
if (!list.length) {
|
|
|
|
|
this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, this.input.value.trim() ? `Nothing matches “${this.input.value.trim()}”` : 'Nothing here'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.renderGroups(new Map([['', list]]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderGroups(groups) {
|
2026-10-08 00:32:23 -04:00
|
|
|
let index = 0;
|
|
|
|
|
const nodes = [];
|
|
|
|
|
for (const [group, commands] of groups) {
|
2026-10-08 01:40:47 -04:00
|
|
|
if (group) nodes.push(el('div', { class: 'lt-cmd-group-label', role: 'presentation' }, group));
|
2026-10-08 00:32:23 -04:00
|
|
|
for (const command of commands) {
|
|
|
|
|
const i = index;
|
|
|
|
|
nodes.push(el('div', {
|
2026-10-08 01:40:47 -04:00
|
|
|
class: 'lt-cmd-item', id: `${this.options.id}-item-${i}`, role: 'option',
|
2026-10-08 00:32:23 -04:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|