Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e109aec36 | ||
|
|
05057f695d | ||
|
|
0d86f89957 |
@@ -46,6 +46,7 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
|
||||
- Configure `DEVDESK_GRAFANA_URL`, plus a Viewer token in `DEVDESK_GRAFANA_TOKEN_FILE`.
|
||||
- **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. When CI fails, **Ask Claude to fix CI** types a prompt to read the failed job's log.
|
||||
- **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use and cost to `/var/lib/devdesk/context/<session>.json`. The Sessions panel shows it as a bar (cost in the tooltip), and Claude's own footer shows a one-line summary.
|
||||
- **Light mode:** the terminal switches palettes with the page. DevDesk starts Claude with its `dark-ansi` theme so Claude draws with the terminal's 16 colours, and the light palette maps bright white to ink; tmux and the status line use ANSI colours too. A session picks up Claude's theme the next time Claude starts in it (`/exit`, then ⏎ resumes the same conversation).
|
||||
- **Phone:** a key bar replaces the shortcut hints (Esc, Tab, ⇧Tab, ^C, ↑, ↓, ⏎, Claude/Shell window), and the panels open as a drawer.
|
||||
- **SSH:** `devdesk attach <name>`, `devdesk new <name> [project]`, `devdesk rename <old> <new>`, `devdesk end <name>`, `devdesk ls`, `devdesk pick`, `devdesk repo <name>`, `devdesk alerts`.
|
||||
|
||||
@@ -59,8 +60,9 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
|
||||
| Ctrl-b d | Detach (the session keeps running) |
|
||||
| Shift+drag | Native browser selection (plain drag goes to tmux copy-mode, which copies via OSC 52) |
|
||||
| Ctrl+Shift+C / V | Copy selection / paste |
|
||||
| Ctrl+Shift+P | Command palette (also the ⌕ button) |
|
||||
|
||||
Text size, desktop notifications (with a mute) and fullscreen are in the **⋯** menu. In Chromium, fullscreen also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves.
|
||||
The **☀/◐** button switches light and dark mode (saved per browser, shared with the other web_template apps' `lt_theme` preference). Text size, desktop notifications (with a mute), the keyboard shortcuts sheet and fullscreen are in the **⋯** menu; everything is also in the command palette. In Chromium, fullscreen also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves.
|
||||
|
||||
## Configuration
|
||||
- **Put your changes in `/etc/devdesk/env.local`.** It's read after the packaged `env` and never shipped, so `apt upgrade` never asks about it. Example: `DEVDESK_REMOTE_CONTROL=1`.
|
||||
|
||||
@@ -296,20 +296,24 @@ cmd_attach() {
|
||||
--list)
|
||||
[ $# -eq 1 ] || dd_die "--list takes no arguments"
|
||||
cmd_list
|
||||
dd_linger
|
||||
return
|
||||
;;
|
||||
--repo)
|
||||
shift
|
||||
cmd_repo "$@"
|
||||
dd_linger
|
||||
return
|
||||
;;
|
||||
--alerts)
|
||||
cmd_alerts
|
||||
dd_linger
|
||||
return
|
||||
;;
|
||||
--upload)
|
||||
shift
|
||||
cmd_upload "$@"
|
||||
dd_linger
|
||||
return
|
||||
;;
|
||||
--new | --rename | --end)
|
||||
@@ -317,6 +321,7 @@ cmd_attach() {
|
||||
shift
|
||||
"cmd_$action" "$@"
|
||||
ok_json
|
||||
dd_linger
|
||||
return
|
||||
;;
|
||||
--existing)
|
||||
|
||||
Vendored
+33
@@ -1,3 +1,36 @@
|
||||
devdesk (0.6.0) bookworm; urgency=medium
|
||||
|
||||
* Light mode, like the other web_template apps (#31): the ☀/◐ button in the
|
||||
header, shared lt_theme preference (OS setting until you choose), applied
|
||||
before first paint and synced across tabs. The terminal has a light
|
||||
palette; Claude runs with its dark-ansi theme so its colours come from
|
||||
that palette, and the light palette maps bright white to ink, so Claude's
|
||||
text, scrollback included, stays readable in both modes. tmux and the
|
||||
statusLine use ANSI colours so they follow the theme too. Sessions pick
|
||||
up Claude's theme the next time Claude starts in them.
|
||||
* Command palette (#32): ⌕ button or Ctrl+Shift+P. Switch, create, rename
|
||||
and end sessions, Claude/Shell window, scroll back, detach, theme, panels,
|
||||
text size, fullscreen, notifications, copy link, open the repo / PRs / CI
|
||||
run, show alerts, keyboard help.
|
||||
* Dialogs instead of browser prompt/confirm for rename, end and new
|
||||
session (name check inline, project folder picker), and a keyboard
|
||||
shortcuts sheet in the ⋯ menu, the palette and the footer (#33).
|
||||
* Toasts for results: created, renamed, ended, uploads, link copied, CI
|
||||
finished, errors (#34).
|
||||
* Fix: renames, ends and new sessions often reported "no response" although
|
||||
they worked; ttyd dropped the reply of commands that exit right after
|
||||
printing. Replies now wait for the browser to hang up (#35).
|
||||
* Fix: the Sessions panel showed stale names right after a rename or end.
|
||||
|
||||
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 00:32:11 -0400
|
||||
|
||||
devdesk (0.5.1) bookworm; urgency=medium
|
||||
|
||||
* A session created by opening its URL shows up in the Sessions panel and
|
||||
the switcher right away instead of at the next poll.
|
||||
|
||||
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 23:42:01 -0400
|
||||
|
||||
devdesk (0.5.0) bookworm; urgency=medium
|
||||
|
||||
* Security (from the 2026-10-07 review):
|
||||
|
||||
@@ -43,8 +43,19 @@ DEVDESK_CONTEXT_DIR="$DEVDESK_STATE_DIR/context"
|
||||
DEVDESK_STATUS_DIR="$DEVDESK_STATE_DIR/state"
|
||||
DEVDESK_NAME_RE='^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$'
|
||||
|
||||
# Under ttyd, a command that exits right after printing sometimes loses its last
|
||||
# output: ttyd sees the exit and closes the WebSocket before reading the pty
|
||||
# (devdesk#35). One-shot replies and errors therefore wait, at most
|
||||
# DEVDESK_LINGER seconds, for the browser to hang up once it has the answer
|
||||
# (ttyd then sends SIGHUP). devdesk-serve sets DEVDESK_VIA_TTYD=1.
|
||||
dd_linger() {
|
||||
[ "${DEVDESK_VIA_TTYD:-}" = 1 ] && [ -t 0 ] || return 0
|
||||
read -r -t "${DEVDESK_LINGER:-1}" _ 2>/dev/null || true
|
||||
}
|
||||
|
||||
dd_die() {
|
||||
printf 'devdesk: %s\n' "$*" >&2
|
||||
dd_linger
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
@@ -22,4 +22,7 @@ if [ -n "${DEVDESK_BIND:-}" ]; then
|
||||
args+=(--interface "$DEVDESK_BIND")
|
||||
fi
|
||||
|
||||
# Tells `devdesk attach` it talks to a browser through ttyd (see dd_linger).
|
||||
export DEVDESK_VIA_TTYD=1
|
||||
|
||||
exec "${DEVDESK_TTYD:-/usr/lib/devdesk/bin/ttyd}" "${args[@]}" "${DEVDESK_BIN:-/usr/bin/devdesk}" attach
|
||||
|
||||
@@ -38,12 +38,13 @@ if [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Footer line, in TDS colours: session · model · context % · cost
|
||||
# Footer line: session · model · context % · cost. ANSI colour numbers, not RGB,
|
||||
# so the browser's dark or light palette applies (devdesk#31).
|
||||
jq -rM --arg s "$session" '
|
||||
def c($rgb; $t): "\u001b[38;2;\($rgb)m\($t)\u001b[0m";
|
||||
def c($sgr; $t): "\u001b[\($sgr)m\($t)\u001b[0m";
|
||||
def pct: if . == null then "—" else "\(. | floor)%" end;
|
||||
[ (if $s != "" then c("255;107;0"; "◈ \($s)") else empty end),
|
||||
c("0;212;255"; .model.display_name // "Claude"),
|
||||
c("127;163;191"; "ctx \(.context_window.used_percentage | pct)"),
|
||||
c("62;96;122"; "$\((.cost.total_cost_usd // 0) * 100 | round / 100)")
|
||||
] | join(c("62;96;122"; " · "))' <<<"$input" 2>/dev/null || true
|
||||
[ (if $s != "" then c("33"; "◈ \($s)") else empty end),
|
||||
c("36"; .model.display_name // "Claude"),
|
||||
c("37"; "ctx \(.context_window.used_percentage | pct)"),
|
||||
c("37"; "$\((.cost.total_cost_usd // 0) * 100 | round / 100)")
|
||||
] | join(c("90"; " · "))' <<<"$input" 2>/dev/null || true
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"preferredNotifChannel": "terminal_bell",
|
||||
"theme": "dark-ansi",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
|
||||
+12
-9
@@ -24,18 +24,21 @@ set -g set-titles-string "devdesk:#S|#T"
|
||||
# --- every new session gets a spare shell next to Claude ---------------------
|
||||
set-hook -g session-created "new-window -d -n shell"
|
||||
|
||||
# --- LotusGuild TDS colours ---------------------------------------------------
|
||||
# --- colours ---------------------------------------------------------------------
|
||||
# ANSI colour names, not hex: the browser maps them through its dark or light
|
||||
# palette, so the status bar follows DevDesk's theme (devdesk#31). black is the
|
||||
# bar, white muted text, cyan the accent, yellow the session name.
|
||||
set -g status-position bottom
|
||||
set -g status-style "bg=#060c14,fg=#7fa3bf"
|
||||
set -g status-left "#[fg=#FF6B00,bold] #S #[fg=#3e607a,nobold]│ "
|
||||
set -g status-style "bg=black,fg=white"
|
||||
set -g status-left "#[fg=yellow,bold] #S #[fg=brightblack,nobold]│ "
|
||||
set -g status-left-length 40
|
||||
set -g status-right ""
|
||||
set -g window-status-format " #I:#W "
|
||||
set -g window-status-current-format "#[fg=#030508,bg=#00D4FF,bold] #I:#W "
|
||||
set -g window-status-bell-style "fg=#FFB300,bold"
|
||||
set -g message-style "bg=#0d1520,fg=#00D4FF"
|
||||
set -g mode-style "bg=#00D4FF,fg=#030508"
|
||||
set -g pane-border-style "fg=#0d1520"
|
||||
set -g pane-active-border-style "fg=#00D4FF"
|
||||
set -g window-status-current-format "#[fg=cyan,reverse,bold] #I:#W "
|
||||
set -g window-status-bell-style "fg=yellow,bold"
|
||||
set -g message-style "bg=black,fg=cyan"
|
||||
set -g mode-style "fg=cyan,reverse"
|
||||
set -g pane-border-style "fg=brightblack"
|
||||
set -g pane-active-border-style "fg=cyan"
|
||||
|
||||
source-file -q /etc/devdesk/tmux.local.conf
|
||||
|
||||
@@ -327,6 +327,25 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
|
||||
kill "$RECEIVER"
|
||||
}
|
||||
|
||||
@test "under ttyd, one-shot replies wait for the browser to hang up (#35)" {
|
||||
start_server
|
||||
# No tty (CLI, tests): no waiting.
|
||||
start=$(date +%s%N)
|
||||
DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=2 devdesk attach --list >/dev/null
|
||||
[ $(( ($(date +%s%N) - start) / 1000000 )) -lt 1500 ]
|
||||
# On a tty, as under ttyd: the reply is printed, then it waits. script's
|
||||
# stdin stays open (like a browser that hasn't hung up); a closed stdin
|
||||
# would hand the command EOF at once.
|
||||
start=$(date +%s%N)
|
||||
run bash -c "sleep 2 | script -qec \"DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=0.6 '$REPO/bin/devdesk' attach --list\" /dev/null"
|
||||
elapsed=$(( ($(date +%s%N) - start) / 1000000 ))
|
||||
[[ $output == *'"sessions"'* ]]
|
||||
[ "$elapsed" -ge 500 ]
|
||||
# Errors are printed before the wait, too.
|
||||
run bash -c "sleep 1 | script -qec \"DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=0.3 '$REPO/bin/devdesk' attach --rename nosuch x\" /dev/null"
|
||||
[[ $output == *"no session named"* ]]
|
||||
}
|
||||
|
||||
@test "alerts reports unconfigured and unreachable Grafana cleanly" {
|
||||
run env DEVDESK_GRAFANA_URL= devdesk alerts
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
+16
-1
@@ -3,11 +3,20 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<meta name="theme-color" content="#030508">
|
||||
<meta name="generator" content="devdesk">
|
||||
<title>DevDesk</title>
|
||||
<link rel="icon" id="favicon" href="__FAVICON__">
|
||||
<script>
|
||||
// Theme before first paint (same key as web_template's lt.theme), no dark flash.
|
||||
try {
|
||||
var t = localStorage.getItem('lt_theme');
|
||||
if (t !== 'light' && t !== 'dark') t = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.documentElement.style.colorScheme = t;
|
||||
} catch (e) { /* storage blocked: stay dark */ }
|
||||
</script>
|
||||
<!--STYLES-->
|
||||
</head>
|
||||
<body class="dd-app">
|
||||
@@ -31,6 +40,8 @@
|
||||
<span class="lt-ws-status dd-ws" 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-btn lt-btn-ghost lt-btn-sm dd-cmd-btn" id="cmd-toggle" title="Command palette (Ctrl+Shift+P)" aria-label="Open command palette">⌕<span class="dd-cmd-kbd"> ⌃⇧P</span></button>
|
||||
<button type="button" class="lt-theme-btn" id="theme-toggle" aria-label="Switch to light mode" title="Switch to light mode">☀</button>
|
||||
<div class="dd-more">
|
||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm dd-more-toggle" id="more-toggle" aria-haspopup="menu" aria-expanded="false" title="Settings" aria-label="Settings">⋯</button>
|
||||
<div class="dd-menu dd-more-menu" id="more-menu" role="menu" hidden>
|
||||
@@ -43,6 +54,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" class="dd-menu-item" id="notify-toggle" role="menuitem">Desktop notifications</button>
|
||||
<button type="button" class="dd-menu-item" id="help-open" role="menuitem">Keyboard shortcuts</button>
|
||||
<button type="button" class="dd-menu-item" id="fullscreen-toggle" role="menuitem" title="In Chromium, fullscreen also captures Ctrl+W/Ctrl+T for the terminal">Fullscreen</button>
|
||||
<div class="dd-menu-foot" id="version">devdesk</div>
|
||||
</div>
|
||||
@@ -128,6 +140,9 @@
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">⇧drag</span>select</span>
|
||||
<span class="lt-footer-sep">│</span>
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">drop / paste</span>attach files</span>
|
||||
<span class="lt-footer-sep">│</span>
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">^⇧P</span>commands</span>
|
||||
<button type="button" class="dd-footer-help" id="footer-help">all keys</button>
|
||||
</div>
|
||||
<div class="dd-keybar" id="keybar" role="toolbar" aria-label="Terminal keys" hidden>
|
||||
<button type="button" data-keys="esc">Esc</button>
|
||||
|
||||
+1
-1
@@ -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
@@ -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 = () => {};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+195
-21
@@ -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 {
|
||||
@@ -318,6 +329,7 @@ function connect(session, { reconnect = false } = {}) {
|
||||
state.attempts = 0;
|
||||
setWsState('connected', 'live');
|
||||
if (session) rememberSession(session);
|
||||
refreshSoon(800); // opening ?arg=<new name> just created this session
|
||||
refreshRepo();
|
||||
fit.fit();
|
||||
term.focus();
|
||||
@@ -496,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'),
|
||||
);
|
||||
@@ -508,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);
|
||||
@@ -515,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 = {
|
||||
@@ -577,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();
|
||||
@@ -607,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;
|
||||
@@ -679,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);
|
||||
@@ -686,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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
+110
@@ -569,3 +569,113 @@ body.dd-touch .dd-footer {
|
||||
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 5),
|
||||
.dd-footer .lt-footer-sep:nth-child(n + 4) { display: none; }
|
||||
}
|
||||
|
||||
/* ---- light theme (devdesk#31) ---------------------------------------------------
|
||||
base.css restyles every lt- component; these cover DevDesk's own pieces. */
|
||||
html[data-theme="light"] .dd-sidebar,
|
||||
html[data-theme="light"] .dd-footer { background-image: none; }
|
||||
html[data-theme="light"] .dd-overlay { background: rgba(237, 240, 245, 0.82); }
|
||||
html[data-theme="light"] .dd-backdrop { background: rgba(17, 24, 39, 0.28); }
|
||||
html[data-theme="light"] .dd-menu { box-shadow: 0 8px 28px rgba(50, 80, 130, 0.22), var(--box-glow-cyan); }
|
||||
html[data-theme="light"] .dd-drop-overlay { background: var(--accent-cyan-dim); }
|
||||
html[data-theme="light"] .dd-upload-status { box-shadow: 0 4px 16px rgba(50, 80, 130, 0.18); }
|
||||
html[data-theme="light"] .dd-session-item.is-waiting,
|
||||
html[data-theme="light"] .dd-session-chip[data-state="waiting"],
|
||||
html[data-theme="light"] .dd-upload-status.is-error { border-color: rgba(181, 0, 31, 0.35); }
|
||||
html[data-theme="light"] .dd-badge { color: #ffffff; }
|
||||
html[data-theme="light"] .dd-session-chip[data-state="working"] .dd-state-dot,
|
||||
html[data-theme="light"] .dd-session-chip[data-state="waiting"] .dd-state-dot { box-shadow: none; }
|
||||
@media (max-width: 1099px) {
|
||||
html[data-theme="light"] .dd-sidebar { box-shadow: -8px 0 28px rgba(50, 80, 130, 0.22); }
|
||||
}
|
||||
|
||||
.dd-header .lt-theme-btn { flex: 0 0 auto; }
|
||||
|
||||
/* ---- command palette, dialogs, toasts (devdesk#32-#34) ---------------------------- */
|
||||
.dd-cmd-btn { font-size: 0.8rem; letter-spacing: 0.02em; }
|
||||
.dd-cmd-kbd { font-size: 0.62rem; opacity: 0.7; }
|
||||
.lt-cmd-item.is-selected .dd-cmd-hint { color: inherit; }
|
||||
.dd-cmd-hint { color: var(--text-muted); font-size: 0.66rem; white-space: nowrap; }
|
||||
html[data-theme="light"] .lt-cmd-item.is-selected { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
|
||||
.lt-cmd-results { max-height: min(420px, 60vh); }
|
||||
.lt-cmd-footer span { display: inline-flex; align-items: center; gap: 2px; }
|
||||
|
||||
.dd-dialog-body { display: flex; flex-direction: column; gap: var(--space-sm); }
|
||||
.dd-dialog-fields { display: flex; flex-direction: column; gap: var(--space-md); }
|
||||
.dd-field { display: flex; flex-direction: column; gap: 4px; font-size: 0.72rem; color: var(--text-secondary); }
|
||||
.dd-field .lt-input,
|
||||
.dd-field .lt-select { width: 100%; font-size: 0.8rem; }
|
||||
.dd-dialog-text { color: var(--text-secondary); font-size: 0.8rem; line-height: 1.5; }
|
||||
.dd-dialog-hint { color: var(--text-muted); font-size: 0.7rem; }
|
||||
.dd-dialog-hint.is-error { color: var(--accent-red); }
|
||||
|
||||
.dd-keys { display: grid; gap: var(--space-lg); }
|
||||
.dd-keys-title {
|
||||
margin: 0 0 var(--space-sm);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
.dd-keys-list { display: grid; grid-template-columns: 10.5rem 1fr; gap: 6px var(--space-md); margin: 0; font-size: 0.76rem; }
|
||||
.dd-keys-list dt { white-space: nowrap; }
|
||||
.dd-keys-list dd { margin: 0; color: var(--text-secondary); }
|
||||
.dd-keys kbd {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.dd-footer-help {
|
||||
margin-left: var(--space-sm);
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: none;
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.64rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dd-footer-help:hover { border-color: var(--accent-cyan); }
|
||||
|
||||
/* Toasts sit above the footer (and the phone key bar); names keep their case. */
|
||||
#lt-toast-container {
|
||||
right: var(--space-md);
|
||||
bottom: calc(var(--dd-footer-height) + var(--dd-kb, 0px) + env(safe-area-inset-bottom, 0px) + var(--space-md));
|
||||
max-width: calc(100vw - 2 * var(--space-md));
|
||||
}
|
||||
.lt-toast {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.02em;
|
||||
font-size: 0.74rem;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.lt-toast.is-leaving { opacity: 0; }
|
||||
.lt-toast-msg { flex: 1; overflow-wrap: anywhere; }
|
||||
.lt-toast-icon { flex: 0 0 auto; }
|
||||
.lt-toast-close {
|
||||
flex: 0 0 auto;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.lt-toast-close:hover { opacity: 1; }
|
||||
|
||||
@media (max-width: 719px) {
|
||||
.dd-cmd-kbd { display: none; }
|
||||
#lt-toast-container { left: var(--space-sm); right: var(--space-sm); }
|
||||
.lt-toast { min-width: 0; max-width: none; }
|
||||
.dd-keys-list { grid-template-columns: 1fr; gap: 2px; }
|
||||
.dd-keys-list dd { margin-bottom: var(--space-sm); }
|
||||
}
|
||||
/* One highlight only: the row under a resting pointer isn't "selected" until the
|
||||
pointer moves (mousemove updates the selection). */
|
||||
.lt-cmd-item:hover:not(.is-selected) { background: none; color: inherit; }
|
||||
|
||||
Reference in New Issue
Block a user