Compare commits

..
14 Commits
Author SHA1 Message Date
jaredandClaude Opus 5.5 dee1843986 fix: go to file in big non-git folders; linear reply handling
build-deb / Lint, test, package (push) Successful in 3m0s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- find/git producers piped into head tolerate SIGPIPE, so hitting a cap no
  longer fails the command (and lets ttyd truncate its reply).
- fs-find outside git skips build/cache folders and caps at 10000.
- runCommand checks completion on the stream's tail instead of re-decoding
  the whole reply on every frame.

Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:50:50 -04:00
jaredandClaude Opus 5.5 e345bf4640 feat: files pane like VS Code: tree, tabs, go to file
build-deb / Lint, test, package (push) Successful in 2m28s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- Explorer tree with inline expand/collapse and keyboard navigation, git
  colours, change dots, dimmed gitignored entries (fs-list now flags them),
  file-type badges, resizable explorer.
- Editor tabs: preview tab semantics, instant switching with scroll kept,
  in-place reload when Claude changes an open file.
- Go to file: devdesk attach --fs-find (git ls-files or capped find) and a
  fuzzy-ranked palette (CommandPalette gained custom search/async lists).
- Per-session memory of folders and tabs; empty state with recents.

Closes #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:40:47 -04:00
jaredandClaude Opus 5.5 73f8525db2 feat: v0.7.0 files pane: browse, preview, diff, Claude's edits
build-deb / Lint, test, package (push) Successful in 2m38s
build-deb / Deploy to CT 126 (push) Successful in 5s
build-deb / Notify on failure (push) Skipped
- devdesk attach --fs-list/--fs-read/--git-status/--git-show/--asset
  (libexec/devdesk-files.sh), confined to the session folder.
- The state hook records files Claude edits (Edit/Write/MultiEdit/
  NotebookEdit) for the Changes tab's "C" badge; rename/end carry it.
- Files pane: folders, CodeMirror read-only view, sanitized markdown preview
  in a sandboxed iframe with inlined relative images, images, Changes tab with
  line counts and diff against HEAD, "→ Claude" @path, resizable split.
- The editor bundle ships as /usr/share/devdesk/assets/editor.js, is fetched
  over ttyd on first use, verified against the page's SHA-256 and cached in
  IndexedDB.
- Fix: the state hook's field split collapsed empty fields.

Closes #19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:16:37 -04:00
jaredandClaude Opus 5.5 390ce61f5b feat: inbox bell for waiting sessions, alerts and missed events
build-deb / Lint, test, package (push) Successful in 3m7s
build-deb / Deploy to CT 126 (push) Successful in 2s
build-deb / Notify on failure (push) Skipped
Closes #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:51:01 -04:00
jaredandClaude Opus 5.5 6e109aec36 test: keep script's stdin open in the linger test (CI has no stdin)
build-deb / Lint, test, package (push) Successful in 2m10s
build-deb / Deploy to CT 126 (push) Successful in 2s
build-deb / Notify on failure (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:36:50 -04:00
jaredandClaude Opus 5.5 05057f695d 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>
2026-10-08 00:32:23 -04:00
jaredandClaude Opus 5.5 0d86f89957 fix: list a just-created session immediately
build-deb / Lint, test, package (push) Successful in 1m55s
build-deb / Deploy to CT 126 (push) Successful in 2s
build-deb / Notify on failure (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:42:03 -04:00
jaredandClaude Opus 5.5 be9b026a01 feat: v0.5.0 header, switcher, phone key bar, shared polling, Matrix "needs you"
build-deb / Lint, test, package (push) Successful in 2m12s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- Header: no SSO chip ("Sign in again" only on expiry), state dot on the
  session chip, LIVE as a dot, ⋯ menu for text size/notifications/fullscreen,
  Panels badge for waiting sessions and alerts.
- Session switcher on the chip; bare URL reattaches the last session; + New
  opens the panel form with project folders.
- Notifications when other sessions start waiting; alerts polled with the
  panels closed; one tab polls for all tabs over BroadcastChannel.
- Sidebar: context bar per session instead of the Context panel; compact
  Repo/CI with "Ask Claude to fix CI"; legible labels.
- Phone: key bar, drawer with backdrop, visualViewport-aware layout, DOM
  renderer on touch, compact connection dot.
- devdesk-notify-matrix: one Matrix message per wait that lasts
  DEVDESK_MATRIX_DELAY seconds with nobody attached.

Closes #8
Closes #13
Closes #14
Closes #15
Closes #16
Closes #17
Closes #28

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:34:59 -04:00
jaredandClaude Opus 5.5 7aa624bc13 fix: v0.5.0 security and robustness fixes from the 2026-10-07 review
build-deb / Lint, test, package (push) Successful in 1m49s
build-deb / Deploy to CT 126 (push) Skipped
build-deb / Notify on failure (push) Skipped
- Ask Claude: strip control characters, send as bracketed paste (closes #2)
- end: forget before kill, so ending from inside a session leaves no ghost (closes #3)
- reconnects use `attach --existing` and never resurrect ended/renamed sessions (closes #4)
- repo: git --no-optional-locks, no safe.directory override, refresh on connect/bell/60s (closes #5)
- uploads: unique names via mktemp, cleanup trap on hangup (closes #6)
- clipboard: write-only OSC 52 provider, tmux allow-passthrough off (closes #7)
- uploads: size check up front, streamed slices, idle timeout (closes #9)
- --list: path last in TSV rows, tolerant numbers (closes #10)
- tokens via curl config on stdin; userinfo stripped from remote URLs (closes #11)
- ttyd max clients 32 and at most 2 concurrent side-channel calls (#8; BroadcastChannel polling still open)
- tmux: fixed terminal-features index (no duplicates on re-source), no status-right clock

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:15:47 -04:00
jaredandClaude Opus 5.5 364bd9b66f docs: README for 0.4.0 (status, file drop, Ops Alerts)
build-deb / Lint, test, package (push) Successful in 1m45s
build-deb / Deploy to CT 126 (push) Skipped
build-deb / Notify on failure (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:31:01 -04:00
jaredandClaude Opus 5.5 03e4a49e78 feat: session status at a glance, file & image drop, live Ops Alerts (0.4.0)
build-deb / Lint, test, package (push) Successful in 1m51s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- devdesk-state-hook (Claude UserPromptSubmit/PostToolUse/Notification/Stop/
  SessionStart/SessionEnd) records whether each session is working, waiting on
  you or idle; --list carries it. The sidebar shows it, the header shows the
  current session's state, and a pulsing "needs you" chip jumps to sessions
  waiting for input. Session refresh no longer depends on the sidebar being open.
- `devdesk attach --upload SESSION FILE BYTES` receives a file as base64
  terminal input (raw mode, exact length, size-checked, sanitised name) into
  /var/lib/devdesk/uploads/<session>/. The UI uploads dropped or pasted files and
  pastes the path into Claude, which attaches images itself.
- `devdesk alerts` / `--alerts`: firing and pending Grafana alert rules with
  severity, instances, summary and a link. The panel's "Ask Claude" types an
  investigation prompt into the current session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:26:06 -04:00
jaredandClaude Opus 5.5 531b640ae9 feat: auto-deploy tagged releases to CT 126 via signed webhook (0.3.1)
build-deb / Lint, test, package (push) Successful in 2m16s
build-deb / Deploy to CT 126 (push) Successful in 3m4s
build-deb / Notify on failure (push) Skipped
CI's new deploy job (tags only) sends the house HMAC webhook to CT 126; the
listener runs /usr/lib/devdesk/devdesk-deploy, which waits for the version in
the apt registry and upgrades devdesk with --force-confold. Running sessions
are untouched. Hook setup is documented in docs/DEPLOY.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:21:11 -04:00
jaredandClaude Opus 5.5 9026238024 feat: live Repo/CI panel, env.local overrides (0.3.0)
build-deb / Lint, test, package (push) Successful in 1m46s
build-deb / Notify on failure (push) Skipped
- `devdesk attach --repo NAME` / `devdesk repo NAME`: git state of the session's
  folder (branch, ahead/behind, uncommitted changes) plus the Gitea commit
  status of HEAD and open PR count. Uses the anonymous API for public repos,
  or /etc/devdesk/gitea-token when present. Runs over the ttyd channel, so no
  sidecar is needed. The panel links to the repo, the CI run and PRs, and a
  notification fires when HEAD's CI finishes while the tab is in the background.
- /etc/devdesk/env.local is read after the packaged env and never shipped, so
  upgrades no longer stop at a conffile prompt.
- Hooks and status line only record for panes on DevDesk's own tmux server; the
  Claude wrapper re-reads env before every launch (so the Remote Control toggle
  applies on the next Claude restart).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:09:32 -04:00
jaredandClaude Opus 5.5 ae8b5d843f fix: scope hooks to DevDesk's tmux and reload env per Claude launch (0.2.1)
build-deb / Lint, test, package (push) Successful in 2m6s
build-deb / Notify on failure (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:54:28 -04:00
45 changed files with 5547 additions and 381 deletions
+23 -1
View File
@@ -149,10 +149,32 @@ jobs:
"$api/releases/$id/assets?name=$name" >/dev/null
echo "release $TAG: $GITEA_URL/$REPO/releases/tag/$TAG"
deploy:
name: Deploy to CT 126
runs-on: ubuntu-latest
needs: [build]
if: startsWith(github.ref, 'refs/tags/v')
steps:
# CT 126's webhook listener runs /usr/lib/devdesk/devdesk-deploy, which
# waits for this version in the apt registry and upgrades the package.
- name: Trigger devdesk-deploy webhook
env:
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
GIT_REF: ${{ github.ref }}
run: |
PAYLOAD="{\"ref\":\"${GIT_REF}\"}"
SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
curl -sf --connect-timeout 10 \
-X POST \
-H "Content-Type: application/json" \
-H "X-Gitea-Signature: ${SIG}" \
-d "$PAYLOAD" \
"http://10.10.10.58:9000/hooks/devdesk-deploy"
notify-failure:
name: Notify on failure
runs-on: ubuntu-latest
needs: [build]
needs: [build, deploy]
if: failure() && github.event_name == 'push'
steps:
- name: Send Matrix alert
+7 -3
View File
@@ -8,7 +8,7 @@ SHAREDIR := $(PREFIX)/share/devdesk
TTYD_ARCH ?= x86_64
SHELL_SCRIPTS := bin/devdesk $(wildcard libexec/devdesk-*) scripts/fetch-ttyd.sh debian/devdesk.postinst debian/devdesk.postrm
WEB_SOURCES := web/build.mjs web/index.template.html web/styles.css $(wildcard web/src/*.js) \
WEB_SOURCES := web/build.mjs web/index.template.html web/styles.css $(wildcard web/src/*.js) $(wildcard web/editor/*.js) \
vendor/web_template/base.css debian/changelog
.PHONY: all lint test install clean sync-template
@@ -26,7 +26,7 @@ build/ttyd: vendor/ttyd/VERSION vendor/ttyd/SHA256SUMS scripts/fetch-ttyd.sh
@touch $@
lint: node_modules/.package-lock.json
npx eslint --max-warnings 0 --ext .js,.mjs web/src web/build.mjs tests/protocol.test.mjs
npx eslint --max-warnings 0 --ext .js,.mjs web/src web/editor web/build.mjs tests/protocol.test.mjs
shellcheck -x $(SHELL_SCRIPTS)
jq empty share/claude-settings.json
@@ -39,9 +39,13 @@ install: all
install -m 0755 bin/devdesk $(DESTDIR)$(PREFIX)/bin/devdesk
install -m 0644 libexec/devdesk-lib.sh $(DESTDIR)$(LIBDIR)/devdesk-lib.sh
install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \
libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline $(DESTDIR)$(LIBDIR)/
libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \
libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix $(DESTDIR)$(LIBDIR)/
install -m 0644 libexec/devdesk-files.sh $(DESTDIR)$(LIBDIR)/devdesk-files.sh
install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd
install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/
install -d $(DESTDIR)$(SHAREDIR)/assets
install -m 0644 dist/assets/editor.js $(DESTDIR)$(SHAREDIR)/assets/editor.js
install -m 0644 etc/env $(DESTDIR)$(SYSCONFDIR)/devdesk/env
clean:
+29 -9
View File
@@ -28,13 +28,29 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
- `main` is the projects root.
- **Opening one:**
- `https://dev.lotusguild.org/?arg=<name>` opens or creates that session.
- With no `?arg=`, an fzf picker opens, listing running (●), dormant (○) and new (+) sessions.
- The **Sessions** panel's form takes any name plus an optional project folder.
- With no `?arg=`, DevDesk reattaches the session you used last in this browser (or the most recently active one). The fzf picker, listing running (●), dormant (○) and new (+) sessions, is under the session chip's menu.
- **Session chip** (header): click to switch sessions; its dot shows Claude's state.
- **+ New** in the Sessions panel takes any name plus an optional project folder.
- **Rename / end:** the ✎ and × buttons on each session in the panel. Renaming keeps the session, its running Claude and its recorded conversation. Ending stops Claude; the conversation stays in Claude's history.
- **Windows:** each session has a `claude` window (0) and a `shell` window (1).
- **Conversation tracking:** a Claude `SessionStart` hook records each session's conversation ID in `/var/lib/devdesk/registry.json`. After a reboot the session resumes *that* conversation, even when other Claude instances, such as code-server, use the same directory.
- **Claude Context panel:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use, cost and edits to `/var/lib/devdesk/context/<session>.json`. The panel shows the current session's numbers, and Claude's own footer shows a one-line summary. Sessions started before 0.2.0 need Claude restarted (`/exit`, then ⏎ in the menu) before they report.
- **SSH:** `devdesk attach <name>`, `devdesk new <name> [project]`, `devdesk rename <old> <new>`, `devdesk end <name>`, `devdesk ls`, `devdesk pick`.
- **Session status:** Claude hooks (`devdesk-state-hook`) mark each session **working**, **waiting on you** (with Claude's message) or **idle**.
- The sidebar shows it with context % and a context bar; the session chip's dot shows the current session's state.
- A "… needs you" chip jumps to other sessions that are waiting, and a desktop notification fires when one starts waiting.
- The **🔔 inbox** lists what needs you (waiting sessions, firing Grafana alerts) and what happened while you were away (a background session finished or started waiting, CI passed or failed); its badge counts them.
- **Matrix "Claude needs you":** when a session has waited for `DEVDESK_MATRIX_DELAY` seconds (30) and no browser tab or SSH client is attached to it, `devdesk-notify-matrix` posts one message per wait, with a link, to the hookshot webhook URL in `/etc/devdesk/matrix-webhook` (mode 600; no file = off).
- **File & image drop:** drag files onto the terminal or paste a screenshot.
- They're uploaded through ttyd to `/var/lib/devdesk/uploads/<session>/` (25 MB max, pruned after 7 days), and the path is pasted into Claude.
- For images, Claude attaches them itself.
- **Ops Alerts panel:** firing and pending Grafana alert rules, each with a link to the rule.
- **Ask Claude** types an investigation prompt into the current session without sending it.
- 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.
- **Files** (Ctrl+Shift+E), laid out like VS Code: an explorer tree (git colours, gitignored entries dimmed) and an editor with tabs (a click opens a preview tab, a double-click keeps it), **Go to file** (Ctrl+P in the pane), markdown previews (sanitized, with relative images), images, and a Changes view of uncommitted files with line counts, a **C** badge on files Claude edited, and diffs against HEAD. **@Claude** types `@path` into the prompt. Tabs and open folders are remembered per session. Read-only for now. Paths can't leave the session folder. The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser.
- **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`.
## Keys
| Key | Action |
@@ -46,14 +62,19 @@ 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) |
| Ctrl+Shift+E | Files pane (Esc: back, then close) |
Use the **Full** button for fullscreen. In Chromium it 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
- `/etc/devdesk/env` (conffile):
- **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`.
- `/etc/devdesk/env` holds the packaged defaults; don't edit it. Its keys:
- port, bind address, projects root and `DEVDESK_DEFAULT_DIR`
- `DEVDESK_GITEA_URL` / `DEVDESK_GITEA_ALIASES` for the Repo/CI panel. Public repos need no token; for private ones put a `read:repository` token in `/etc/devdesk/gitea-token`
- `CLAUDE_BIN` and `CLAUDE_ARGS`
- `DEVDESK_REMOTE_CONTROL=1`, which also starts each session with Claude Remote Control so it can be continued from the Claude app
- `DEVDESK_PUBLIC_URL`, `DEVDESK_MATRIX_WEBHOOK_FILE` and `DEVDESK_MATRIX_DELAY` for the Matrix notifications
- `/etc/devdesk/secret.env` is generated on install. It holds the ttyd credential that NPM must inject; see [docs/DEPLOY.md](docs/DEPLOY.md).
- `/etc/devdesk/tmux.local.conf` is optional and is sourced after the packaged `tmux.conf`.
@@ -69,8 +90,7 @@ apt install ../devdesk_*_amd64.deb
CI (`.gitea/workflows/build-deb.yml`):
- Every push lints, tests, builds and smoke-tests the package and uploads it as an artifact.
- A `v*` tag also publishes to `https://code.lotusguild.org/api/packages/LotusGuild/debian` and creates a release.
- A `v*` tag also publishes to `https://code.lotusguild.org/api/packages/LotusGuild/debian`, creates a release, and auto-deploys to CT 126. A signed webhook runs `devdesk-deploy`, which upgrades the package; see [docs/DEPLOY.md](docs/DEPLOY.md).
## Roadmap
- **v1.1:** `devdesk-api` sidecar for the Repo/CI panel, Matrix notifications when you're away, and auto-deploy.
- **v2:** Ops Alerts (Grafana) with "Ask Claude" prompts, and file drop.
Tracked in [Gitea issues](https://code.lotusguild.org/LotusGuild/devdesk/issues) by milestone: v0.6.0 file viewer, v0.7.0 editor + inbox, backlog.
+194 -15
View File
@@ -5,24 +5,30 @@
# devdesk new NAME [PROJECT] create session NAME (detached) in PROJECT's folder, or the default folder
# devdesk rename OLD NEW rename a session (running or dormant)
# devdesk end NAME stop session NAME and forget it
# devdesk repo NAME git + Gitea CI status of the session's folder (JSON)
# devdesk alerts firing/pending Grafana alerts (JSON)
# devdesk ls list sessions
# devdesk pick interactive picker
#
# The web UI calls `devdesk attach --list | --new NAME [PROJECT] | --rename OLD NEW | --end NAME`
# The web UI calls `devdesk attach --list | --repo NAME | --alerts | --upload SESSION FILE BYTES |
# --new NAME [PROJECT] | --rename OLD NEW | --end NAME | --existing NAME`
# through ttyd; those print JSON.
set -euo pipefail
# shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
# shellcheck source=libexec/devdesk-files.sh
. "$DEVDESK_LIBEXEC/devdesk-files.sh"
usage() {
sed -n '2,10s/^# \{0,1\}//p' "$0"
sed -n '2,12s/^# \{0,1\}//p' "$0"
}
# Tab-separated running sessions: name, dir, attached clients, last activity, windows.
running_tsv() {
dd_server_up || return 0
dd_tmux list-sessions -F '#{session_name} #{session_path} #{session_attached} #{session_activity} #{session_windows}' 2>/dev/null || true
# Path last: it may contain anything (tabs included) and is re-joined below.
dd_tmux list-sessions -F '#{session_name} #{session_attached} #{session_activity} #{session_windows} #{session_path}' 2>/dev/null || true
}
# Tab-separated top-level project directories: slug, dir.
@@ -40,11 +46,12 @@ projects_tsv() {
done
}
# {"<session>": {model, used_pct, ...}} from the status-line snapshots.
context_json() {
local f
if [ -d "$DEVDESK_CONTEXT_DIR" ]; then
for f in "$DEVDESK_CONTEXT_DIR"/*.json; do
# {"<session>": {...}} merged from the per-session JSON files in DIR
# (status-line snapshots, hook states).
per_session_json() {
local dir=$1 f
if [ -d "$dir" ]; then
for f in "$dir"/*.json; do
[ -f "$f" ] || continue
jq -cM --arg n "$(basename "$f" .json)" '{($n): .}' "$f" 2>/dev/null || true
done
@@ -58,14 +65,15 @@ cmd_list() {
--argjson reg "$reg" \
--arg run "$(running_tsv)" \
--arg proj "$(projects_tsv)" \
--argjson ctx "$(context_json)" \
--argjson ctx "$(per_session_json "$DEVDESK_CONTEXT_DIR")" \
--argjson st "$(per_session_json "$DEVDESK_STATUS_DIR")" \
--arg dflt "$DEVDESK_DEFAULT_SESSION" \
--arg root "$DEVDESK_PROJECTS_ROOT" '
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
($reg.sessions // {}) as $known
| [rows($run)[] | {name: .[0], dir: .[1], running: true,
attached: (.[2] | tonumber), activity: (.[3] | tonumber),
windows: (.[4] | tonumber)}] as $live
| [rows($run)[] | {name: .[0], dir: (.[4:] | join("\t")), running: true,
attached: (.[1] | tonumber? // 0), activity: (.[2] | tonumber? // 0),
windows: (.[3] | tonumber? // 0)}] as $live
| ($live | map(.name)) as $liveNames
| {default: $dflt, root: $root,
sessions: (
@@ -75,9 +83,9 @@ cmd_list() {
| map({name: .key, dir: .value.dir, running: false, attached: 0,
activity: null, windows: 0,
claude_session_id: (.value.claude_session_id // null)}))
) | map(. + {context: ($ctx[.name] // null)}),
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null)}),
projects: ([{name: $dflt, dir: $root}]
+ [rows($proj)[] | {name: .[0], dir: .[1]}
+ [rows($proj)[] | {name: .[0], dir: (.[1:] | join("\t"))}
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
}
@@ -138,10 +146,144 @@ cmd_rename() {
cmd_end() {
[ $# -eq 1 ] || dd_die "usage: devdesk end NAME"
dd_valid_name "$1" || dd_die "invalid session name: $1"
# Forget first: killing the session SIGHUPs us when run from inside it.
dd_registry_remove "$1"
rm -rf "${DEVDESK_STATE_DIR:?}/uploads/$1"
if dd_server_up; then
dd_tmux kill-session -t "=$1" 2>/dev/null || true
fi
dd_registry_remove "$1"
}
# GET $DEVDESK_GITEA_URL/api/v1<path>; token optional (private repos only).
gitea_get() {
# The token goes in via a curl config on stdin, never on the command line (ps).
{
if [ -r "$DEVDESK_GITEA_TOKEN_FILE" ]; then
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the token
printf 'header = "Authorization: token %s"\n' "$(tr -d '[:space:]"\\' <"$DEVDESK_GITEA_TOKEN_FILE")"
fi
} | curl -fsS --max-time 4 -K - "$DEVDESK_GITEA_URL/api/v1$1" 2>/dev/null
}
# owner/repo when REMOTE points at our Gitea (https or ssh, any configured host), else nothing.
gitea_slug() {
local remote=$1 host hosts
hosts="${DEVDESK_GITEA_URL#*://} $DEVDESK_GITEA_ALIASES"
for host in $hosts; do
host=${host%%/*}
host=${host%%:*}
if [[ $remote =~ [@/]${host//./\.}(:[0-9]+)?[:/]+([^/]+/[^/]+)$ ]]; then
printf '%s\n' "${BASH_REMATCH[2]%.git}"
return
fi
done
}
cmd_repo() {
[ $# -eq 1 ] || dd_die "usage: devdesk repo NAME"
dd_valid_name "$1" || dd_die "invalid session name: $1"
local dir g branch sha="" dirty ahead=null behind=null upstream=false remote slug="" ci=null prs=null
dir=$(dd_resolve_dir "$1")
# --no-optional-locks: never take .git/index.lock (Claude's own git would collide).
# No safe.directory override: repos owned by other users stay untrusted.
g=(git --no-optional-locks -C "$dir")
if ! "${g[@]}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
jq -cMn --arg s "$1" --arg dir "$dir" '{session: $s, dir: $dir, git: false}'
return
fi
branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)")
sha=$("${g[@]}" rev-parse -q --verify HEAD 2>/dev/null || true)
dirty=$("${g[@]}" status --porcelain 2>/dev/null | wc -l)
if "${g[@]}" rev-parse -q --verify '@{u}' >/dev/null 2>&1; then
upstream=true
read -r behind ahead < <("${g[@]}" rev-list --left-right --count '@{u}...HEAD')
fi
remote=$("${g[@]}" remote get-url origin 2>/dev/null | sed -E 's#^([a-z+]+://)[^/@]*@#\1#' || true)
[ -n "$remote" ] && slug=$(gitea_slug "$remote")
if [ -n "$slug" ] && [ -n "$sha" ]; then
ci=$(gitea_get "/repos/$slug/commits/$sha/status" |
jq -cM '{state, total: .total_count,
url: (.statuses[0]?.target_url | if . then sub("/jobs/[0-9]+$"; "") else null end),
jobs: [.statuses[]? | {name: .context, status}]}' 2>/dev/null) || ci=null
prs=$(gitea_get "/repos/$slug/pulls?state=open&limit=50" | jq -M 'length' 2>/dev/null) || prs=null
fi
jq -cMn --arg s "$1" --arg dir "$dir" --arg branch "$branch" --arg sha "${sha:0:7}" \
--argjson dirty "$dirty" --argjson ahead "$ahead" --argjson behind "$behind" \
--argjson upstream "$upstream" --arg remote "$remote" --arg slug "$slug" \
--arg web "$DEVDESK_GITEA_URL" --argjson ci "${ci:-null}" --argjson prs "${prs:-null}" '
{session: $s, dir: $dir, git: true, branch: $branch, sha: $sha, dirty: $dirty,
ahead: $ahead, behind: $behind, upstream: $upstream, remote: $remote,
repo: (if $slug == "" then null else $slug end),
url: (if $slug == "" then null else "\($web)/\($slug)" end),
ci: (if $ci == null then null else $ci + {url: (if $ci.url then $web + $ci.url else null end)} end),
prs: $prs}'
}
# Firing and pending Grafana alert rules, most severe first.
cmd_alerts() {
if [ -z "$DEVDESK_GRAFANA_URL" ]; then
jq -cMn '{configured: false, alerts: []}'
return
fi
local raw
if ! raw=$({
if [ -r "$DEVDESK_GRAFANA_TOKEN_FILE" ]; then
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the token
printf 'header = "Authorization: Bearer %s"\n' "$(tr -d '[:space:]"\\' <"$DEVDESK_GRAFANA_TOKEN_FILE")"
fi
} | curl -fsS --max-time 6 -K - "$DEVDESK_GRAFANA_URL/api/prometheus/grafana/api/v1/rules" 2>&1); then
jq -cMn --arg e "${raw:-no response}" '{configured: true, error: $e, alerts: []}'
return
fi
jq -cM --arg pub "$DEVDESK_GRAFANA_PUBLIC_URL" '
def num: (tonumber? // .) | tostring;
{configured: true, checked: (now | floor), alerts: [
.data.groups[] | .name as $group | .file as $folder | .rules[]
| select(.state == "firing" or .state == "pending")
| . as $r
| ([.alerts[]? | select(.state == "Alerting" or .state == "Pending")]) as $active
| {name, state, severity: (.labels.severity // null), group: $group, folder: $folder,
since: (.activeAt // $active[0].activeAt // null),
summary: ((.annotations.summary // .annotations.description // "")
| gsub("\\{\\{ *\\$value *\\}\\}"; ($active[0].value // "?" | num))),
instances: [$active[] | .labels
| del(.__alert_rule_uid__, .__grafana_origin__, .alertname, .grafana_folder, .__name__, .severity)
| to_entries | map("\(.key)=\(.value)") | join(" ")
| select(. != "")],
url: "\($pub)/alerting/grafana/\(.uid)/view"}
] | sort_by(if .severity == "critical" then 0 elif .severity == "warning" then 1 else 2 end)}' <<<"$raw"
}
# Receive a file for SESSION over the terminal: the browser waits for the ready
# line, then sends exactly ceil(BYTES/3)*4 bytes of base64 as input.
cmd_upload() {
[ $# -eq 3 ] || dd_die "usage: devdesk attach --upload SESSION FILENAME BYTES"
local session=$1 name size=$3 dir dest b64len actual
dd_valid_name "$session" || dd_die "invalid session name: $session"
{ [[ $size =~ ^[0-9]+$ ]] && [ "$size" -gt 0 ] && [ "$size" -le "$DEVDESK_UPLOAD_MAX" ]; } ||
dd_die "file is empty or larger than $((DEVDESK_UPLOAD_MAX / 1048576)) MB"
name=$(dd_safe_filename "$2")
dir="$DEVDESK_STATE_DIR/uploads/$session"
mkdir -p "$dir"
find "$DEVDESK_STATE_DIR/uploads" -type f -mtime +7 -delete 2>/dev/null || true
# Unique even for same-second uploads of the same name.
dest=$(mktemp --tmpdir="$dir" --suffix="-$name" "$(date +%Y%m%d-%H%M%S)-XXXX")
# A dropped connection SIGHUPs us mid-transfer: never leave a partial file.
trap 'rm -f "$dest"' HUP INT TERM
# shellcheck disable=SC2017 # integer ceil(size/3)*4: the base64 length, on purpose
b64len=$(((size + 2) / 3 * 4))
stty raw -echo 2>/dev/null || true
printf 'DEVDESK-UPLOAD-READY\r\n'
head -c "$b64len" | base64 -d >"$dest" 2>/dev/null || true
stty sane 2>/dev/null || true
actual=$(stat -c %s "$dest" 2>/dev/null || echo 0)
if [ "$actual" != "$size" ]; then
rm -f "$dest"
dd_die "upload incomplete ($actual of $size bytes)"
fi
trap - HUP INT TERM
chmod 0644 "$dest"
jq -cMn --arg p "$dest" '{ok: true, path: $p}'
}
ok_json() {
@@ -156,6 +298,31 @@ 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
;;
--fs-list | --fs-find | --fs-read | --git-status | --git-show | --asset)
local action=${1#--}
shift
"cmd_${action//-/_}" "$@"
dd_linger
return
;;
--new | --rename | --end)
@@ -163,8 +330,18 @@ cmd_attach() {
shift
"cmd_$action" "$@"
ok_json
dd_linger
return
;;
--existing)
# Reconnects: never re-create a session that was ended or renamed meanwhile.
shift
[ $# -eq 1 ] || dd_die "attach --existing takes exactly one session name"
dd_valid_name "$1" || dd_die "invalid session name: $1"
if ! { dd_server_up && dd_tmux has-session -t "=$1" 2>/dev/null; } && [ -z "$(dd_registry_get "$1" dir)" ]; then
dd_die "session '$1' no longer exists (it was ended or renamed)"
fi
;;
-*) dd_die "unknown option: $1" ;;
esac
[ $# -eq 1 ] || dd_die "attach takes exactly one session name"
@@ -195,6 +372,8 @@ case $cmd in
attach | a) cmd_attach "$@" ;;
new) cmd_new "$@" ;;
rename | mv) cmd_rename "$@" ;;
repo) cmd_repo "$@" ;;
alerts) cmd_alerts ;;
ls | list) cmd_ls ;;
--list) cmd_list ;;
end) cmd_end "$@" ;;
+189
View File
@@ -1,3 +1,192 @@
devdesk (0.7.2) bookworm; urgency=medium
* Fix: Go to file failed in big non-git folders such as /root/code: the
file walk was cut off at its cap with SIGPIPE, which failed the command
and truncated its reply. Producers piped into a cap now tolerate it (also
for very large folders and git status). Outside git, Go to file skips
build/cache folders and stops at 10,000 files.
* Large side-channel replies no longer cost quadratic time in the browser.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 01:50:50 -0400
devdesk (0.7.1) bookworm; urgency=medium
* Files pane reworked like VS Code (#37):
- Explorer tree beside the editor: folders expand in place (also with
the keyboard: arrows, Enter), the open file is highlighted and can be
revealed, git colours on names, change dots on folders, gitignored
entries dimmed, file-type badges. A drag handle sizes the explorer.
- Tabs: a click opens a preview tab (italic) that the next click
replaces; double-click or Enter keeps it. Switching tabs is instant and
keeps the scroll position. Files Claude changes reload in place.
- Go to file (Ctrl+P in the pane, the ⌕ button, the palette): fuzzy
search over git ls-files, or a capped find outside git.
- Explorer and Changes are two views of the left column; diffs open as
tabs.
- Expanded folders, open tabs and the active tab are remembered per
session; an empty editor offers recent files and the change count.
- Narrow panes and phones: the explorer slides over the editor.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 01:40:47 -0400
devdesk (0.7.0) bookworm; urgency=medium
* Files pane (#19): a Files button (Ctrl+Shift+E) next to Panels opens the
session's folder beside the terminal (a drag-resizable split on wide
screens, an overlay on narrow ones and phones).
- Browse folders; read files with syntax highlighting (CodeMirror,
read-only, Ctrl+F search, line wrap toggle), markdown rendered as a
sanitized preview with its relative images, images shown inline.
- Changes tab: uncommitted git changes with line counts and a "C" badge
on files Claude edited (recorded by the state hook from Edit/Write);
diff against HEAD for any changed file. Non-git sessions list the files
Claude edited there.
- "→ Claude" types @path into the prompt; Copy path.
- After Claude's turn the list refreshes, and an open file that changed
on disk offers a reload.
- Paths stay inside the session folder (no absolute paths, no symlink
escapes). Read-only for now; editing is v0.8.0.
- The editor (~600 KB) is not in the page: it ships in
/usr/share/devdesk/assets, is fetched over ttyd the first time Files
opens, checked against the page's SHA-256 and cached in the browser.
* Fix: the state hook mis-read hook fields when Claude's message was empty.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 01:16:26 -0400
devdesk (0.6.1) bookworm; urgency=medium
* Inbox bell (#36): sessions waiting on you, firing alerts, and what
happened while you were away (a background session finished or started
waiting, CI passed or failed). Click to jump there; shared by all tabs.
Replaces the count on the Panels button.
* Phone header: theme in the ⋯ menu, Panels as an icon, so the bell fits.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 00:50:56 -0400
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):
- ttyd :7681 is firewalled to NPM in CT 126 and its credential was
rotated (#1).
- "Ask Claude" strips control characters from Grafana text and pastes it
as bracketed paste, so alert text can never run a command (#2).
- OSC 52 clipboard is write-only: terminal output can no longer read the
browser clipboard; tmux allow-passthrough is off (#7).
- Gitea/Grafana tokens reach curl on stdin, never on its command line;
remote URLs lose any userinfo (#11).
* Fixes:
- Ending the session you are in no longer leaves a ghost entry (#3).
- Reconnecting after a drop never re-creates a session that was ended or
renamed meanwhile (`attach --existing`) (#4).
- Repo panel uses git --no-optional-locks and drops the safe.directory
override (#5); uploads get unique names and are cleaned up on hangup
(#6); uploads are size-checked first and streamed in slices with an
idle timeout (#9); paths with tabs no longer break the session list
(#10).
- ttyd allows 32 clients; side-channel calls run two at a time and one
tab polls for all open tabs (#8).
* Header: the SSO chip is gone ("Sign in again" appears only when the
session expires); the session chip shows Claude's state as a dot and
opens a session switcher; LIVE is a dot; text size, notifications (with
mute) and fullscreen move into a ⋯ menu; the Panels button shows a badge
for waiting sessions and alerts (#13, #16).
* A bare URL reattaches your last session instead of opening the picker
(#16).
* Notifications when *other* sessions start waiting, and alerts are polled
with the panels closed (#15).
* Sidebar: the Claude Context panel is folded into a context bar in each
session row; Repo/CI collapses for non-git folders and offers "Ask Claude
to fix CI"; the new-session form hides behind "+ New"; more legible
labels (#17).
* Phone: key bar (Esc, Tab, ⇧Tab, ^C, arrows, ⏎, Claude/Shell), drawer
with backdrop, layout that follows the on-screen keyboard, DOM renderer
on touch devices, no clock in the tmux status line (#14).
* Matrix "Claude needs you": a wait that lasts 30 s with nobody attached
posts one message with a link to the Devdesk hookshot webhook (#28).
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 23:34:48 -0400
devdesk (0.4.0) bookworm; urgency=medium
* Session status at a glance: Claude hooks record whether each session is
working, waiting on you (with Claude's message) or idle. The sidebar shows
it with context %, the header shows the current session's state, and a
pulsing "needs you" chip jumps to sessions waiting for input.
* File & image drop: drag files onto the terminal or paste a screenshot.
They're uploaded through ttyd to /var/lib/devdesk/uploads/<session>/ (25 MB
max, pruned after 7 days) and the path is pasted into Claude, which
attaches images itself.
* Ops Alerts panel is live: firing/pending Grafana alert rules with
severity, instances, summary and a link, plus an "Ask Claude" button that
types an investigation prompt into the current session.
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 19:24:15 -0400
devdesk (0.3.1) bookworm; urgency=medium
* Auto-deploy: tagged releases trigger CT 126's webhook listener (house HMAC
pattern), which runs /usr/lib/devdesk/devdesk-deploy to upgrade the
package from the apt registry. Running sessions are untouched.
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 13:20:58 -0400
devdesk (0.3.0) bookworm; urgency=medium
* Repo / CI panel is live: branch, ahead/behind, uncommitted changes, the
Gitea CI status of HEAD (linked to the run) and open PRs for the current
session's folder, via `devdesk attach --repo` over the ttyd channel. No
sidecar needed; public repos need no token (optional
/etc/devdesk/gitea-token for private ones). A notification fires when CI
for HEAD finishes while the tab is in the background.
* Remote Control verified with resume (same claude.ai session link).
* Local settings now go in /etc/devdesk/env.local (read after the packaged
env, never shipped), so upgrades no longer stop at a conffile prompt.
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 12:57:04 -0400
devdesk (0.2.1) bookworm; urgency=medium
* Session hook and status line only record for panes on DevDesk's own
tmux server (Claude run with these settings elsewhere no longer writes
into DevDesk's state).
* The Claude wrapper re-reads /etc/devdesk/env before every Claude launch,
so Remote Control and CLAUDE_ARGS changes apply on the next restart.
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 12:54:06 -0400
devdesk (0.2.0) bookworm; urgency=medium
* Sessions no longer need a project folder: any name works and starts in
+5
View File
@@ -35,6 +35,11 @@ Comment: Bundled into index.html at build time:
2014-2016 SourceLair Private Company, 2012-2013 Christopher Jeffrey);
JetBrains Mono via @fontsource/jetbrains-mono (OFL-1.1,
Copyright 2020 The JetBrains Mono Project Authors).
Bundled into assets/editor.js (the file viewer) at build time:
CodeMirror 6 and @lezer/highlight (MIT, Copyright 2018-2021 Marijn Haverbeke
and others); marked (MIT, Copyright 2018+ MarkedJS, 2011-2018 Christopher
Jeffrey); DOMPurify (Apache-2.0 or MPL-2.0 at the recipient's choice, here
Apache-2.0, Copyright 2025 Dr.-Ing. Mario Heiderich, Cure53).
License: MIT
Permission is hereby granted, free of charge, to any person obtaining a copy
+1
View File
@@ -11,6 +11,7 @@ User=root
Environment=HOME=/root USER=root LOGNAME=root SHELL=/bin/bash LANG=C.UTF-8
Environment=PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
EnvironmentFile=-/etc/devdesk/env
EnvironmentFile=-/etc/devdesk/env.local
WorkingDirectory=-/root
RuntimeDirectory=devdesk
RuntimeDirectoryMode=0700
+1 -1
View File
@@ -2,7 +2,7 @@
set -e
if [ "$1" = purge ]; then
rm -f /etc/devdesk/secret.env
rm -f /etc/devdesk/secret.env /etc/devdesk/env.local
rm -rf /var/lib/devdesk
fi
+1
View File
@@ -10,6 +10,7 @@ User=root
Environment=HOME=/root LANG=C.UTF-8
Environment=PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
EnvironmentFile=/etc/devdesk/env
EnvironmentFile=-/etc/devdesk/env.local
EnvironmentFile=/etc/devdesk/secret.env
ExecStart=/usr/lib/devdesk/devdesk-serve
Restart=always
+43 -2
View File
@@ -36,8 +36,49 @@ Then run `authelia validate-config --config /etc/authelia/configuration.yml && s
- **Cloudflare:** add a CNAME `dev` → apex, DNS-only (grey cloud).
- **Pi-hole:** add `"10.10.10.27 dev.lotusguild.org"` to `dns.hosts` in `pve-infra/containers/163-pihole/etc/pihole/pihole.toml`. A push auto-deploys it, and LAN clients then reach NPM directly instead of hairpinning.
## 5. Optional hardening
Enable the Proxmox firewall on CT 126 (`firewall=1` on net0) and allow tcp/7681 only from 10.10.10.27. The same rule suits code-server on :8680.
## 5. Auto-deploy on release
A `v*` tag makes CI publish the package, then send a signed webhook (the house HMAC pattern) to CT 126's existing `webhook` listener on :9000. The listener runs `/usr/lib/devdesk/devdesk-deploy <ref>`, which waits for that version in the apt registry and upgrades only `devdesk` with `--force-confold`. Running Claude sessions are untouched. The log is `/var/log/devdesk-deploy.log`.
**Setup, done once:**
1. Generate a secret.
2. Add this hook to `/etc/webhook/hooks.json`, next to `126-config-deploy`. The file is local and untracked because it holds secrets.
```json
{
"id": "devdesk-deploy",
"execute-command": "/usr/lib/devdesk/devdesk-deploy",
"command-working-directory": "/",
"pass-arguments-to-command": [{"source": "payload", "name": "ref"}],
"trigger-rule": {"and": [
{"match": {"type": "payload-hash-sha256", "secret": "<WEBHOOK_SECRET>",
"parameter": {"source": "header", "name": "X-Gitea-Signature"}}},
{"match": {"type": "regex", "regex": "^refs/tags/v[0-9]+\\.[0-9]+\\.[0-9]+$",
"parameter": {"source": "payload", "name": "ref"}}}
]}
}
```
3. Run `systemctl restart webhook`.
4. Store the same secret as the devdesk repo's Actions secret `WEBHOOK_SECRET`.
## 6. Firewall (required)
The ttyd credential is the only thing between the LAN and a root shell, so only NPM may reach :7681. CT 126 ships this in pve-infra (`containers/126-codingenvironment/etc/nftables.conf`, installed by its `deploy.sh`):
```
table inet devdesk {
chain input {
type filter hook input priority filter - 10; policy accept;
tcp dport 7681 ip saddr { 127.0.0.1, 10.10.10.27 } accept
tcp dport 7681 ip6 saddr ::1 accept
tcp dport 7681 counter reject with tcp reset
}
}
```
Check from another LAN host: `curl -m3 http://10.10.10.58:7681/` must fail with "connection refused". To rotate the credential: `rm /etc/devdesk/secret.env && dpkg-reconfigure devdesk`, put the new `Authorization` value in NPM proxy host 58's Advanced tab, then `systemctl restart devdesk`.
## 7. Matrix "Claude needs you" (optional)
1. Deploy a hookshot transform named `Devdesk` (`hookshot/devdesk.js` in the matrix repo; pushing it to `main` auto-deploys it on LXC 151). Look up the hook UUID as described in that repo's README.
2. `umask 077; echo https://matrix.lotusguild.org/webhook/<uuid> > /etc/devdesk/matrix-webhook`.
3. Nothing to restart: the next wait that lasts `DEVDESK_MATRIX_DELAY` seconds with nobody attached posts a message.
## Operations
| Task | Command |
+25 -2
View File
@@ -1,5 +1,8 @@
# DevDesk configuration. Read by systemd (EnvironmentFile=) and by the devdesk
# scripts. Plain KEY=value lines only: systemd does not support inline comments.
# DevDesk defaults, shipped by the package. Don't edit: put your changes in
# /etc/devdesk/env.local (same KEY=value format, read after this file), so
# package upgrades never prompt about this file.
# Read by systemd (EnvironmentFile=) and by the devdesk scripts. Plain KEY=value
# lines only: systemd does not support inline comments.
# The ttyd credential lives in /etc/devdesk/secret.env (generated on install).
# Port ttyd listens on (NPM proxies dev.lotusguild.org here).
@@ -15,9 +18,29 @@ DEVDESK_DEFAULT_SESSION=main
# Where sessions that are not a project folder start (e.g. ops sessions); empty = projects root.
DEVDESK_DEFAULT_DIR=
# Gitea for the Repo/CI panel. Public repos need no token; for private ones put a
# read:repository token in /etc/devdesk/gitea-token (mode 600).
DEVDESK_GITEA_URL=https://code.lotusguild.org
DEVDESK_GITEA_ALIASES=10.10.10.63
# Grafana for the Ops Alerts panel (firing/pending alert rules). A Viewer
# service-account token goes in DEVDESK_GRAFANA_TOKEN_FILE (mode 600).
DEVDESK_GRAFANA_URL=http://10.10.10.49:3000
DEVDESK_GRAFANA_PUBLIC_URL=https://dashboard.lotusguild.org
DEVDESK_GRAFANA_TOKEN_FILE=/etc/devdesk/grafana-token
# Claude Code binary and extra arguments for every session.
CLAUDE_BIN=/root/.local/bin/claude
CLAUDE_ARGS=
# Where DevDesk is reached in a browser; used for links in Matrix notifications.
DEVDESK_PUBLIC_URL=https://dev.lotusguild.org
# "Claude needs you" in Matrix when a session waits and no tab or SSH client is
# attached: put a hookshot webhook URL in this file (mode 600). No file = off.
# DEVDESK_MATRIX_DELAY: seconds the wait must last before posting.
DEVDESK_MATRIX_WEBHOOK_FILE=/etc/devdesk/matrix-webhook
DEVDESK_MATRIX_DELAY=30
# 1 = also enable Remote Control so sessions can be continued from the Claude app.
DEVDESK_REMOTE_CONTROL=0
+9 -2
View File
@@ -10,9 +10,15 @@ set -uo pipefail
NAME=$(dd_current_session)
[ -n "$NAME" ] || dd_die "not running inside a DevDesk session"
CLAUDE_BIN=${CLAUDE_BIN:-claude}
SETTINGS="$DEVDESK_SHARE/claude-settings.json"
read -r -a EXTRA <<<"${CLAUDE_ARGS:-}"
# Re-read /etc/devdesk/env before every Claude launch, so changes (Remote
# Control, CLAUDE_ARGS, …) apply on the next restart of Claude in a session.
reload_config() {
dd_load_env
CLAUDE_BIN=${CLAUDE_BIN:-claude}
read -r -a EXTRA <<<"${CLAUDE_ARGS:-}"
}
ORANGE=$'\033[38;2;255;107;0m'
CYAN=$'\033[38;2;0;212;255m'
@@ -66,6 +72,7 @@ printf '\033]2;%s\033\134' "$NAME" # pane title (ST = ESC \\)
mode=resume
while :; do
NAME=$(dd_current_session) # follow renames
reload_config
if [ "$mode" = new ]; then run_new; else run_resume; fi
rc=$?
NAME=$(dd_current_session)
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# Upgrade DevDesk from the LotusGuild apt registry. Run by the host's webhook
# listener (hook "devdesk-deploy") when CI publishes a v* tag; the tag's ref is
# passed as $1. Safe for running sessions: package upgrades restart only
# devdesk.service (ttyd), never devdesk-tmux.service where Claude lives.
set -euo pipefail
LOG=${DEVDESK_DEPLOY_LOG:-/var/log/devdesk-deploy.log}
SOURCES=${DEVDESK_APT_SOURCES:-sources.list.d/lotusguild.list}
log() {
printf '%s %s\n' "$(date '+%F %T')" "$*" >>"$LOG"
}
exec 9>/run/devdesk-deploy.lock
flock -w 900 9 || {
log "another deploy is still running; giving up"
exit 1
}
want=${1:-}
want=${want#refs/tags/}
want=${want#v}
before=$(dpkg-query -W -f='${Version}' devdesk 2>/dev/null || echo none)
log "=== deploy: installed $before, release ${want:-latest}"
refresh() {
apt-get update -q -o Dir::Etc::sourcelist="$SOURCES" -o Dir::Etc::sourceparts=- \
-o APT::Get::List-Cleanup=0 >>"$LOG" 2>&1
}
# The registry can lag the release by a few seconds: wait for the tagged version.
candidate=""
for _ in $(seq 1 18); do
refresh || true
candidate=$(apt-cache policy devdesk | awk '/Candidate:/ {print $2}')
if [ -z "$want" ] || [ "$candidate" = "$want" ]; then
break
fi
sleep 10
done
if [ -n "$want" ] && [ "$candidate" != "$want" ]; then
log "registry still offers $candidate, not $want; giving up"
exit 1
fi
if [ "$candidate" = "$before" ]; then
log "already at $before; nothing to do"
exit 0
fi
DEBIAN_FRONTEND=noninteractive apt-get install -y -q --only-upgrade \
-o Dpkg::Options::=--force-confold devdesk </dev/null >>"$LOG" 2>&1
log "devdesk $before -> $(dpkg-query -W -f='${Version}' devdesk)"
+199
View File
@@ -0,0 +1,199 @@
# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables
# File viewer commands (devdesk#19), sourced by bin/devdesk. Every path is
# relative to the session's folder and must stay inside it: no absolute paths,
# no newlines, no symlink escapes. Reached through `devdesk attach --fs-*`.
: "${DEVDESK_FS_MAX:=2097152}" # bytes the viewer opens
: "${DEVDESK_FS_LIST_MAX:=2000}" # entries per directory listing
: "${DEVDESK_ASSETS:=$DEVDESK_SHARE/assets}"
# Producers piped into `head` get SIGPIPE once the cap is reached; under
# pipefail that would fail the command and cut its reply short, so they are
# wrapped as `{ producer || true; }`.
# Blob replies: a JSON header line, then base64 between markers (no body for
# binary files the viewer can't show). The client waits for the END marker.
DD_BLOB_BEGIN=DEVDESK-BLOB-BEGIN
DD_BLOB_END=DEVDESK-BLOB-END
dd_session_root() {
dd_valid_name "$1" || dd_die "invalid session name: $1"
realpath -e -- "$(dd_resolve_dir "$1")" 2>/dev/null || dd_die "the folder of session '$1' is missing"
}
# dd_fs_path ROOT REL -> the real path of REL inside ROOT, or die.
dd_fs_path() {
local root=$1 rel=${2:-.} p
case $rel in
/* | *$'\n'* | *$'\r'*) dd_die "bad path: $rel" ;;
esac
[ -n "$rel" ] || rel=.
p=$(realpath -e -- "$root/$rel" 2>/dev/null) || dd_die "no such file: $rel"
case "$p/" in
"$root"/*) printf '%s\n' "$p" ;;
*) dd_die "outside the session folder: $rel" ;;
esac
}
dd_mime() {
case ${1,,} in
*.png) echo image/png ;;
*.jpg | *.jpeg) echo image/jpeg ;;
*.gif) echo image/gif ;;
*.webp) echo image/webp ;;
*.svg) echo image/svg+xml ;;
*.ico) echo image/x-icon ;;
*.bmp) echo image/bmp ;;
*.pdf) echo application/pdf ;;
*) echo "" ;;
esac
}
# dd_blob FILE LABEL: header + body for one file on disk.
dd_blob() {
local f=$1 label=$2 size mtime sha mime binary=false utf8=true body=true
read -r size mtime < <(stat -c '%s %Y' -- "$f")
mime=$(dd_mime "$f")
if [ "$size" -gt "$DEVDESK_FS_MAX" ]; then
jq -cMn --arg p "$label" --argjson s "$size" --argjson m "$mtime" --argjson max "$DEVDESK_FS_MAX" \
'{path: $p, size: $s, mtime: $m, tooLarge: true, max: $max}'
return
fi
if head -c 8192 -- "$f" | grep -qaP '\x00'; then
binary=true
[ -n "$mime" ] || body=false
elif ! iconv -f UTF-8 -t UTF-8 -- "$f" >/dev/null 2>&1; then
utf8=false
fi
sha=$(sha256sum -- "$f" | cut -d' ' -f1)
jq -cMn --arg p "$label" --argjson s "$size" --argjson m "$mtime" --arg h "$sha" \
--argjson b "$binary" --argjson u "$utf8" --arg mime "$mime" --argjson body "$body" \
'{path: $p, size: $s, mtime: $m, sha: $h, binary: $b, utf8: $u, body: $body,
mime: (if $mime == "" then null else $mime end)}'
printf '%s\n' "$DD_BLOB_BEGIN"
if $body; then base64 -w0 -- "$f"; fi
printf '\n%s\n' "$DD_BLOB_END"
}
# --fs-list SESSION [REL]: one directory level, folders first, .git skipped.
# In a git repository, entries .gitignore covers are flagged (the UI dims them).
cmd_fs_list() {
if [ $# -lt 1 ] || [ $# -gt 2 ]; then dd_die "usage: devdesk attach --fs-list SESSION [PATH]"; fi
local root dir rel=${2:-.} tmp ignored=""
root=$(dd_session_root "$1")
dir=$(dd_fs_path "$root" "$rel")
[ -d "$dir" ] || dd_die "not a folder: $rel"
tmp=$(mktemp)
# %y: type, %Y: type behind a symlink, %s: size, %T@: mtime, %f: name.
{ find "$dir" -mindepth 1 -maxdepth 1 ! -name .git -printf '%y\t%Y\t%s\t%T@\t%f\0' 2>/dev/null || true; } |
head -z -n "$((DEVDESK_FS_LIST_MAX + 1))" >"$tmp"
if git --no-optional-locks -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
ignored=$(tr '\0' '\n' <"$tmp" | cut -f5- | git --no-optional-locks -C "$dir" check-ignore --stdin 2>/dev/null || true)
fi
jq -cMRs --arg root "$root" --arg rel "${dir#"$root"}" --argjson max "$DEVDESK_FS_LIST_MAX" --arg ign "$ignored" '
($ign | split("\n") | map(select(length > 0) | rtrimstr("/")) | INDEX(.)) as $ignored
| (split("\u0000") | map(select(length > 0) | split("\t") |
{t: .[0], to: .[1], s: (.[2] | tonumber), m: (.[3] | tonumber | floor), n: (.[4:] | join("\t"))}
| if $ignored[.n] then . + {ig: true} else . end)) as $all
| {root: $root, dir: ($rel | ltrimstr("/")), truncated: ($all | length > $max),
entries: ($all[:$max] | sort_by([(if .t == "d" or .to == "d" then 0 else 1 end), (.n | ascii_downcase)]))}' <"$tmp"
rm -f "$tmp"
}
# --fs-find SESSION: every file under the session folder, for "go to file".
# git ls-files in a repository (tracked + untracked, .gitignore honoured), else
# find without node_modules, .git and build/cache folders; capped at
# DEVDESK_FS_FIND_MAX (10000).
cmd_fs_find() {
[ $# -eq 1 ] || dd_die "usage: devdesk attach --fs-find SESSION"
local root max=${DEVDESK_FS_FIND_MAX:-10000}
root=$(dd_session_root "$1")
if git --no-optional-locks -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git --no-optional-locks -C "$root" ls-files -co --exclude-standard -z 2>/dev/null || true
else
(cd "$root" && find . -maxdepth 8 \( -name node_modules -o -name .git -o -name __pycache__ -o -name .venv -o -name venv \
-o -name dist -o -name build -o -name target -o -name .cache -o -name .next -o -name coverage \) -prune \
-o -type f -printf '%P\0' 2>/dev/null) || true
fi | head -z -n "$((max + 1))" |
jq -cMRs --argjson max "$max" --arg root "$root" \
'(split("\u0000") | map(select(length > 0))) as $f | {root: $root, truncated: ($f | length > $max), files: $f[:$max]}'
}
# --fs-read SESSION REL
cmd_fs_read() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --fs-read SESSION PATH"
local root f
root=$(dd_session_root "$1")
f=$(dd_fs_path "$root" "$2")
[ -f "$f" ] || dd_die "not a file: $2"
dd_blob "$f" "${f#"$root"/}"
}
# --git-status SESSION: changed files under the session folder, with line counts
# and whether Claude edited them (from devdesk-state-hook's edit log).
cmd_git_status() {
[ $# -eq 1 ] || dd_die "usage: devdesk attach --git-status SESSION"
local root g top prefix branch edits
root=$(dd_session_root "$1")
edits="$DEVDESK_STATE_DIR/edits/$1.txt"
g=(git --no-optional-locks -C "$root")
if ! top=$("${g[@]}" rev-parse --show-toplevel 2>/dev/null); then
# Not a repository: only what Claude edited.
jq -cMn --arg root "$root" --rawfile e <(cat -- "$edits" 2>/dev/null || true) '
{git: false, root: $root, files: [$e | split("\n")[] | select(length > 0) | select(startswith($root + "/"))
| {path: ltrimstr($root + "/"), x: " ", y: " ", claude: true}]}'
return
fi
prefix=$("${g[@]}" rev-parse --show-prefix)
branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)")
{
printf 'S\n'
{ "${g[@]}" status --porcelain=v1 -z --untracked-files=all -- . 2>/dev/null || true; } | head -z -n 1000 | tr '\0' '\n'
printf 'N\n'
{ "${g[@]}" diff --numstat HEAD -- . 2>/dev/null || true; } | head -n 1000
} | jq -cMRs --arg root "$root" --arg top "$top" --arg prefix "$prefix" --arg branch "$branch" \
--rawfile e <(cat -- "$edits" 2>/dev/null || true) '
(split("\nN\n") | .[0] | ltrimstr("S\n") | split("\n") | map(select(length > 2))) as $st
| (split("\nN\n") | .[1] // "" | split("\n") | map(select(length > 0) | split("\t")
| {key: .[2:] | join("\t"), value: {add: (.[0] | tonumber? // null), del: (.[1] | tonumber? // null)}}) | from_entries) as $num
| ($e | split("\n") | map(select(length > 0))) as $claude
# porcelain -z: "XY path", and after a rename/copy entry the next item is the old path.
| reduce range(0; $st | length) as $i ({skip: false, files: []};
if .skip then .skip = false
else ($st[$i]) as $line | ($line[3:]) as $p
| .files += [{repoPath: $p, x: $line[0:1], y: $line[1:2],
from: (if ($line[0:1] | test("[RC]")) then $st[$i + 1] else null end)}]
| .skip = ($line[0:1] | test("[RC]"))
end)
| .files | map(. + {path: (if (.repoPath | startswith($prefix)) then .repoPath | ltrimstr($prefix) else null end),
add: $num[.repoPath].add, del: $num[.repoPath].del,
claude: (($top + "/" + .repoPath) as $abs | any($claude[]; . == $abs))}) as $files
| {git: true, root: $root, branch: $branch,
files: ($files + [$claude[] | select(startswith($root + "/")) | ltrimstr($root + "/") as $p
| select(any($files[]; .path == $p) | not)
| {path: $p, repoPath: null, x: " ", y: " ", claude: true}])}'
}
# --git-show SESSION REL: the HEAD version of a file, for diffs. An empty body
# when HEAD doesn't have it (new file).
cmd_git_show() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --git-show SESSION PATH"
local root g top prefix tmp rel=$2
root=$(dd_session_root "$1")
case $rel in /* | *$'\n'* | *..*) dd_die "bad path: $rel" ;; esac
g=(git --no-optional-locks -C "$root")
top=$("${g[@]}" rev-parse --show-toplevel 2>/dev/null) || dd_die "not a git repository"
prefix=$("${g[@]}" rev-parse --show-prefix)
tmp=$(mktemp)
"${g[@]}" show "HEAD:$prefix$rel" >"$tmp" 2>/dev/null || : >"$tmp"
dd_blob "$tmp" "$rel"
rm -f "$tmp"
}
# --asset NAME: a lazily loaded part of the web UI (the file viewer's editor),
# shipped in $DEVDESK_ASSETS and cached by the browser.
cmd_asset() {
if [ $# -ne 1 ] || ! [[ $1 =~ ^[a-z0-9-]+\.js$ ]]; then dd_die "usage: devdesk attach --asset NAME.js"; fi
[ -f "$DEVDESK_ASSETS/$1" ] || dd_die "no such asset: $1"
dd_blob "$DEVDESK_ASSETS/$1" "$1"
}
+57 -9
View File
@@ -5,10 +5,19 @@
# against a throwaway tmux server and state directory.
DEVDESK_ENV=${DEVDESK_ENV:-/etc/devdesk/env}
if [ -r "$DEVDESK_ENV" ]; then
# shellcheck source=/dev/null
. "$DEVDESK_ENV"
fi
# Local overrides go in env.local: it is never shipped, so upgrades don't prompt about it.
DEVDESK_ENV_LOCAL=${DEVDESK_ENV_LOCAL:-${DEVDESK_ENV}.local}
dd_load_env() {
local f
for f in "$DEVDESK_ENV" "$DEVDESK_ENV_LOCAL"; do
if [ -r "$f" ]; then
# shellcheck source=/dev/null
. "$f"
fi
done
}
dd_load_env
: "${DEVDESK_PROJECTS_ROOT:=/root/code}"
: "${DEVDESK_DEFAULT_DIR:=$DEVDESK_PROJECTS_ROOT}"
@@ -18,13 +27,35 @@ fi
: "${DEVDESK_LIBEXEC:=/usr/lib/devdesk}"
: "${DEVDESK_SHARE:=/usr/share/devdesk}"
: "${DEVDESK_TMUX_UNIT:=devdesk-tmux.service}"
: "${DEVDESK_GITEA_URL:=https://code.lotusguild.org}"
: "${DEVDESK_GITEA_ALIASES:=10.10.10.63}"
: "${DEVDESK_GITEA_TOKEN_FILE:=/etc/devdesk/gitea-token}"
: "${DEVDESK_GRAFANA_URL:=}"
: "${DEVDESK_GRAFANA_PUBLIC_URL:=$DEVDESK_GRAFANA_URL}"
: "${DEVDESK_GRAFANA_TOKEN_FILE:=/etc/devdesk/grafana-token}"
: "${DEVDESK_UPLOAD_MAX:=26214400}"
: "${DEVDESK_PUBLIC_URL:=}"
: "${DEVDESK_MATRIX_WEBHOOK_FILE:=/etc/devdesk/matrix-webhook}"
: "${DEVDESK_MATRIX_DELAY:=30}"
DEVDESK_REGISTRY="$DEVDESK_STATE_DIR/registry.json"
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
}
@@ -101,14 +132,20 @@ dd_registry_set_claude() {
dd_registry_remove() {
dd_registry_update 'del(.sessions[$n])' --arg n "$1"
rm -f "$DEVDESK_CONTEXT_DIR/$1.json"
rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" "$DEVDESK_STATE_DIR/edits/$1.txt"
}
dd_registry_rename() {
dd_registry_update 'if .sessions[$old] then .sessions[$new] = .sessions[$old] | del(.sessions[$old]) else . end' \
--arg old "$1" --arg new "$2"
if [ -f "$DEVDESK_CONTEXT_DIR/$1.json" ]; then
mv -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_CONTEXT_DIR/$2.json"
local d
for d in "$DEVDESK_CONTEXT_DIR" "$DEVDESK_STATUS_DIR"; do
if [ -f "$d/$1.json" ]; then
mv -f "$d/$1.json" "$d/$2.json"
fi
done
if [ -f "$DEVDESK_STATE_DIR/edits/$1.txt" ]; then
mv -f "$DEVDESK_STATE_DIR/edits/$1.txt" "$DEVDESK_STATE_DIR/edits/$2.txt"
fi
}
@@ -135,6 +172,15 @@ dd_project_dir() {
esac
}
# Upload filename -> safe basename ([A-Za-z0-9._-], no leading dots, max 100 chars).
dd_safe_filename() {
local n=${1##*/}
n=${n//[^A-Za-z0-9._-]/_}
while [[ $n == .* ]]; do n=${n:1}; done
n=${n:0:100}
printf '%s' "${n:-upload}"
}
# Session name -> working directory. Sessions don't need a project: the
# registry wins, then a project of the same name, then DEVDESK_DEFAULT_DIR.
dd_resolve_dir() {
@@ -152,8 +198,10 @@ dd_resolve_dir() {
# Name of the DevDesk session this process runs in. Asks tmux, so it stays
# right after a rename; falls back to the DEVDESK_SESSION set at creation.
dd_current_session() {
local name=""
if [ -n "${TMUX:-}" ] && [ -n "${TMUX_PANE:-}" ]; then
local name="" outer=${TMUX:-}
# Only DevDesk's own tmux server: a Claude started with these settings in
# some other tmux must not write into DevDesk's state.
if [ "${outer%%,*}" = "$DEVDESK_SOCKET" ] && [ -n "${TMUX_PANE:-}" ]; then
name=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null)
fi
printf '%s\n' "${name:-${DEVDESK_SESSION:-}}"
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# devdesk-notify-matrix SESSION SINCE
#
# Started (detached) by devdesk-state-hook when a session starts waiting on you.
# After DEVDESK_MATRIX_DELAY seconds, if that same wait is still on and nobody is
# attached to the session (no browser tab, no SSH client), post one "Claude needs
# you" message to the hookshot webhook in DEVDESK_MATRIX_WEBHOOK_FILE (devdesk#28).
set -euo pipefail
# shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
session=${1:-}
since=${2:-}
dd_valid_name "$session" || exit 0
[[ $since =~ ^[0-9]+$ ]] || exit 0
[ -r "$DEVDESK_MATRIX_WEBHOOK_FILE" ] || exit 0
sleep "$DEVDESK_MATRIX_DELAY"
state_file="$DEVDESK_STATUS_DIR/$session.json"
# Still the same wait? (Answering it moves the state on; a new wait has its own notifier.)
[ "$(jq -rM '"\(.state) \(.since)"' "$state_file" 2>/dev/null)" = "waiting $since" ] || exit 0
# Anyone looking at it already?
attached=$(dd_tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null |
awk -v s="$session" '$1 == s { print $2 }')
[ "${attached:-0}" = 0 ] || exit 0
message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null)
url=""
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session"
body=$(jq -cMn --arg s "$session" --arg m "$message" --arg u "$url" --arg h "$(hostname)" \
'{session: $s, message: $m, url: (if $u == "" then null else $u end), host: $h,
text: ("Claude needs you in " + $s + ": " + $m + (if $u == "" then "" else " — " + $u end))}')
# The webhook URL is a secret (anyone holding it can post to the room): pass it
# to curl on stdin, never on the command line.
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the URL
webhook=$(tr -d '[:space:]"\\' <"$DEVDESK_MATRIX_WEBHOOK_FILE")
printf 'url = "%s"\n' "$webhook" |
curl -fsS --max-time 10 --retry 2 -K - -H 'Content-Type: application/json' --data-binary "$body" >/dev/null
+4 -1
View File
@@ -13,7 +13,7 @@ args=(
--writable # ttyd >= 1.7.4 is read-only by default
--url-arg # ?arg=<session> becomes `devdesk attach <session>`
--check-origin # refuse cross-site WebSocket connections
--max-clients "${DEVDESK_MAX_CLIENTS:-8}"
--max-clients "${DEVDESK_MAX_CLIENTS:-32}"
--credential "$DEVDESK_CREDENTIAL" # injected by NPM as an Authorization header
--index "${DEVDESK_INDEX:-/usr/share/devdesk/index.html}"
--debug "${DEVDESK_TTYD_LOG_LEVEL:-3}" # errors+warnings only: notice level would log the credential
@@ -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
+72
View File
@@ -0,0 +1,72 @@
#!/bin/bash
# Claude Code hook (wired in via claude-settings.json) that tracks what Claude is
# doing in each DevDesk session, for the sidebar's at-a-glance status:
# UserPromptSubmit, PostToolUse -> working
# Notification -> waiting (Claude needs input or a permission)
# Stop, SessionStart -> idle
# SessionEnd -> exited
# Fires on every tool call, so it stays tiny and only writes when the state changes.
# Entering "waiting" also starts devdesk-notify-matrix, which posts to Matrix if
# the wait lasts and nobody is attached (devdesk#28).
input=$(cat)
outer=${TMUX:-}
[ "${outer%%,*}" = "${DEVDESK_SOCKET:-/run/devdesk/tmux.sock}" ] && [ -n "${TMUX_PANE:-}" ] || exit 0
session=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null)
[[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]] || exit 0
# Unit separator, not tab: read collapses runs of whitespace delimiters, so an
# empty field would shift the ones after it.
IFS=$'\x1f' read -r event message tool file < <(jq -rM '[.hook_event_name // "", (.message // "" | gsub("[\u001f\n]"; " ")),
.tool_name // "", (.tool_input.file_path // .tool_input.notebook_path // "" | gsub("[\u001f\n]"; " "))] | join("\u001f")' <<<"$input" 2>/dev/null)
# Files Claude edits, for the file viewer's "Claude" badge (devdesk#19).
case $event:$tool in
PostToolUse:Edit | PostToolUse:Write | PostToolUse:MultiEdit | PostToolUse:NotebookEdit)
if [[ $file == /* ]]; then
log="${DEVDESK_STATE_DIR:-/var/lib/devdesk}/edits/$session.txt"
mkdir -p "${log%/*}" 2>/dev/null
if ! grep -qxF -- "$file" "$log" 2>/dev/null && [ "$(wc -l <"$log" 2>/dev/null || echo 0)" -lt 1000 ]; then
printf '%s\n' "$file" >>"$log"
fi
fi
;;
esac
case $event in
UserPromptSubmit | PostToolUse) state=working ;;
Notification) state=waiting ;;
Stop | SessionStart) state=idle ;;
SessionEnd) state=exited ;;
*) exit 0 ;;
esac
dir="${DEVDESK_STATE_DIR:-/var/lib/devdesk}/state"
file="$dir/$session.json"
# Unchanged state (the common case during tool runs): nothing to write.
if [ "$state" != waiting ] && [ -f "$file" ] && grep -q "\"state\":\"$state\"" "$file"; then
exit 0
fi
# A second Notification during the same wait updates the message but keeps
# `since`, so the wait stays one wait (one Matrix message at most).
since=$(date +%s)
was_waiting=0
if [ "$state" = waiting ] && grep -q '"state":"waiting"' "$file" 2>/dev/null; then
was_waiting=1
since=$(jq -rM '.since // empty' "$file" 2>/dev/null) || since=$(date +%s)
[[ $since =~ ^[0-9]+$ ]] || since=$(date +%s)
fi
mkdir -p "$dir" 2>/dev/null || exit 0
tmp=$(mktemp "$dir/.$session.XXXXXX" 2>/dev/null) || exit 0
if jq -cMn --arg s "$state" --arg m "$message" --arg e "$event" --argjson t "$since" \
'{state: $s, since: $t, event: $e, message: (if $m == "" then null else $m end)}' >"$tmp"; then
mv -f "$tmp" "$file"
else
rm -f "$tmp"
exit 0
fi
if [ "$state" = waiting ] && [ "$was_waiting" = 0 ]; then
setsid -f "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-notify-matrix" "$session" "$since" \
</dev/null >/dev/null 2>&1 || true
fi
exit 0
+12 -10
View File
@@ -6,11 +6,12 @@
input=$(cat)
session=""
if [ -n "${TMUX:-}" ] && [ -n "${TMUX_PANE:-}" ]; then
# Only record for panes on DevDesk's own tmux server.
session="" outer=${TMUX:-}
if [ "${outer%%,*}" = "${DEVDESK_SOCKET:-/run/devdesk/tmux.sock}" ] && [ -n "${TMUX_PANE:-}" ]; then
session=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null)
session=${session:-${DEVDESK_SESSION:-}}
fi
session=${session:-${DEVDESK_SESSION:-}}
if [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then
dir="${DEVDESK_STATE_DIR:-/var/lib/devdesk}/context"
@@ -37,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
+329 -1
View File
@@ -7,20 +7,215 @@
"name": "devdesk",
"license": "MIT",
"devDependencies": {
"@codemirror/commands": "6.11.1",
"@codemirror/lang-markdown": "6.5.2",
"@codemirror/language": "6.13.1",
"@codemirror/legacy-modes": "6.5.5",
"@codemirror/merge": "6.12.2",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.6",
"@codemirror/view": "6.43.14",
"@fontsource/jetbrains-mono": "5.3.0",
"@lezer/highlight": "1.2.5",
"@xterm/addon-clipboard": "0.2.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-unicode11": "0.9.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/xterm": "6.0.0",
"dompurify": "3.4.16",
"esbuild": "0.28.2",
"eslint": "8.57.1"
"eslint": "8.57.1",
"marked": "18.1.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.12",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz",
"integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.13.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz",
"integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/streamparser": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.5",
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.5.tgz",
"integrity": "sha512-9ltT214oMb3Y1w5eAZMSigzrjzZnMI2Wa3Feq73rU/i4C56MkwrpfswfQYOr28C5pXr6dWwspNTMyeHXlzWMxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/streamparser": "^6.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/merge": {
"version": "6.12.2",
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/highlight": "^1.0.0",
"style-mod": "^4.1.0"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
"integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.6",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/streamparser": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz",
"integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==",
"dev": true,
"license": "MIT"
},
"node_modules/@codemirror/view": {
"version": "6.43.14",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -574,6 +769,87 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@lezer/common": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
"integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.9",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz",
"integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz",
"integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.11",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz",
"integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.8.0.tgz",
"integrity": "sha512-X6w3A2NpyhdtGqTWRJB8gR4QDXBaFMbTOs780ZdZ15KKCfBXOQTKcB3Vr+W6tqJHVlealt53tLmHRvOqPTQgYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -612,6 +888,14 @@
"node": ">= 8"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/@ungap/structured-clone": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
@@ -812,6 +1096,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -865,6 +1156,16 @@
"node": ">=6.0.0"
}
},
"node_modules/dompurify": {
"version": "3.4.16",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
"integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
"dev": true,
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -1423,6 +1724,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/marked": {
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.1.0.tgz",
"integrity": "sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w==",
"dev": true,
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -1705,6 +2019,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
"dev": true,
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -1761,6 +2082,13 @@
"punycode": "^2.1.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"dev": true,
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+16 -3
View File
@@ -4,21 +4,34 @@
"description": "Persistent browser terminal for Claude Code (ttyd + tmux), LotusGuild TDS styling",
"license": "MIT",
"type": "module",
"engines": { "node": ">=18" },
"engines": {
"node": ">=18"
},
"scripts": {
"build": "node web/build.mjs",
"lint": "eslint --ext .js,.mjs web/src web/build.mjs tests/protocol.test.mjs",
"lint": "eslint --ext .js,.mjs web/src web/editor web/build.mjs tests/protocol.test.mjs",
"test": "node --test tests/*.test.mjs"
},
"devDependencies": {
"@codemirror/commands": "6.11.1",
"@codemirror/lang-markdown": "6.5.2",
"@codemirror/language": "6.13.1",
"@codemirror/legacy-modes": "6.5.5",
"@codemirror/merge": "6.12.2",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.6",
"@codemirror/view": "6.43.14",
"@fontsource/jetbrains-mono": "5.3.0",
"@lezer/highlight": "1.2.5",
"@xterm/addon-clipboard": "0.2.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-unicode11": "0.9.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/xterm": "6.0.0",
"dompurify": "3.4.16",
"esbuild": "0.28.2",
"eslint": "8.57.1"
"eslint": "8.57.1",
"marked": "18.1.0"
}
}
+62
View File
@@ -1,5 +1,6 @@
{
"preferredNotifChannel": "terminal_bell",
"theme": "dark-ansi",
"hooks": {
"SessionStart": [
{
@@ -8,6 +9,67 @@
"type": "command",
"command": "/usr/lib/devdesk/devdesk-session-hook",
"timeout": 10
},
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-state-hook",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-state-hook",
"timeout": 5
}
]
}
],
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-state-hook",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-state-hook",
"timeout": 5
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-state-hook",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-state-hook",
"timeout": 5
}
]
}
+15 -12
View File
@@ -6,10 +6,10 @@ set -s escape-time 10 # Esc reaches Claude (interrupt) immediat
set -s focus-events on
set -s set-clipboard on # tmux copy-mode -> OSC 52 -> browser clipboard
set -s default-terminal "tmux-256color"
set -as terminal-features ",xterm-256color:RGB"
set -s terminal-features[90] "xterm-256color:RGB" # fixed index: re-sourcing must not append
set -g history-limit 50000
set -g mouse on # wheel scrolls history; Shift+drag selects natively
set -g allow-passthrough on
set -g allow-passthrough off # output may not bypass tmux (title/clipboard spoofing)
set -g renumber-windows on
# --- bells reach the browser (Claude's terminal_bell notifications) ----------
@@ -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 "#[fg=#3e607a]devdesk #[fg=#00D4FF]%H:%M "
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
+356
View File
@@ -12,6 +12,7 @@ setup() {
export DEVDESK_LIBEXEC="$REPO/libexec"
export DEVDESK_SHARE="$REPO/share"
export DEVDESK_NO_SYSTEMD=1
export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/no-webhook" # never the real room
export FAKE_LOG="$TMP/claude-args"
export CLAUDE_BIN="$TMP/fake-claude"
export PATH="$REPO/bin:$PATH"
@@ -197,6 +198,232 @@ claude_args() {
[ "$(jq -r '.sessions[] | select(.name=="alpha") | .context.model' <<<"$output")" = Opus ]
}
@test "hooks ignore Claude running in some other tmux server" {
start_server
other="$TMP/other.sock"
tmux -S "$other" new-session -d -s stranger 'sleep 60'
pane=$(tmux -S "$other" display -p -t =stranger '#{pane_id}')
echo '{"session_id":"nope","source":"startup"}' |
TMUX="$other,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-session-hook"
echo '{"model":{"display_name":"Opus"},"context_window":{}}' |
TMUX="$other,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-statusline" >/dev/null
tmux -S "$other" kill-server
[ ! -e "$DEVDESK_STATE_DIR/registry.json" ] || [ "$(jq -r '.sessions | has("stranger")' "$DEVDESK_STATE_DIR/registry.json")" = false ]
[ ! -e "$DEVDESK_STATE_DIR/context/stranger.json" ]
}
@test "repo reports git state and recognises Gitea remotes" {
export DEVDESK_GITEA_URL=http://127.0.0.1:9 # nothing listens: CI lookups fail fast
repo="$DEVDESK_PROJECTS_ROOT/alpha"
git -C "$repo" init -q -b main
git -C "$repo" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init
git -C "$repo" remote add origin gitea@10.10.10.63:LotusGuild/alpha.git
touch "$repo/new-file"
run devdesk attach --repo alpha
[ "$status" -eq 0 ]
[ "$(jq -r '.git' <<<"$output")" = true ]
[ "$(jq -r '.branch' <<<"$output")" = main ]
[ "$(jq -r '.dirty' <<<"$output")" = 1 ]
[ "$(jq -r '.repo' <<<"$output")" = LotusGuild/alpha ]
[ "$(jq -r '.ci' <<<"$output")" = null ]
git -C "$repo" remote set-url origin https://127.0.0.1:9/LotusGuild/alpha-https.git
run devdesk repo alpha
[ "$(jq -r '.repo' <<<"$output")" = LotusGuild/alpha-https ]
git -C "$repo" remote set-url origin https://github.com/someone/else.git
run devdesk repo alpha
[ "$(jq -r '.repo' <<<"$output")" = null ]
run devdesk repo main
[ "$(jq -r '.git' <<<"$output")" = false ]
}
@test "env.local overrides the packaged env" {
mkdir -p "$TMP/etc" "$DEVDESK_PROJECTS_ROOT/alpha/ops"
printf 'DEVDESK_DEFAULT_DIR=%s\n' "$DEVDESK_PROJECTS_ROOT" >"$TMP/etc/env"
printf 'DEVDESK_DEFAULT_DIR=%s\n' "$DEVDESK_PROJECTS_ROOT/alpha/ops" >"$TMP/etc/env.local"
export DEVDESK_ENV="$TMP/etc/env"
start_server
devdesk new adhoc
run tmux -S "$DEVDESK_SOCKET" display -p -t =adhoc:claude '#{pane_current_path}'
[ "$output" = "$DEVDESK_PROJECTS_ROOT/alpha/ops" ]
}
@test "state hook tracks working, waiting and idle per session" {
start_server
devdesk new alpha
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
hook() { TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
state() { jq -r '.sessions[] | select(.name=="alpha") | .status.state // "none"' <<<"$(devdesk attach --list)"; }
echo '{"hook_event_name":"UserPromptSubmit","prompt":"hi"}' | hook
[ "$(state)" = working ]
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}' | hook
[ "$(state)" = waiting ]
[ "$(jq -r '.message' "$DEVDESK_STATE_DIR/state/alpha.json")" = "Claude needs your permission to use Bash" ]
echo '{"hook_event_name":"PostToolUse","tool_name":"Bash"}' | hook
[ "$(state)" = working ]
echo '{"hook_event_name":"Stop"}' | hook
[ "$(state)" = idle ]
echo '{"hook_event_name":"PreCompact"}' | hook
[ "$(state)" = idle ]
devdesk rename alpha beta
[ -f "$DEVDESK_STATE_DIR/state/beta.json" ] && [ ! -e "$DEVDESK_STATE_DIR/state/alpha.json" ]
devdesk end beta
[ ! -e "$DEVDESK_STATE_DIR/state/beta.json" ]
}
# Local stand-in for the hookshot webhook: appends each POST body to $TMP/posts.
start_receiver() {
python3 - "$TMP" <<'PY' &
import http.server, sys
tmp = sys.argv[1]
class H(http.server.BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers['Content-Length']))
with open(f'{tmp}/posts', 'ab') as f:
f.write(body + b'\n')
self.send_response(200); self.end_headers(); self.wfile.write(b'{"ok":true}')
def log_message(self, *a):
pass
srv = http.server.HTTPServer(('127.0.0.1', 0), H)
open(f'{tmp}/port', 'w').write(str(srv.server_port))
srv.serve_forever()
PY
RECEIVER=$!
for _ in $(seq 1 50); do [ -s "$TMP/port" ] && break; sleep 0.1; done
printf 'http://127.0.0.1:%s/webhook/test\n' "$(cat "$TMP/port")" >"$TMP/webhook"
chmod 600 "$TMP/webhook"
export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/webhook"
}
posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
@test "a wait nobody is attached to is posted to Matrix once (#28)" {
start_server
start_receiver
export DEVDESK_MATRIX_DELAY=1 DEVDESK_PUBLIC_URL=https://dev.example.org
devdesk new alpha
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
hook() { TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}' | hook
# a second notification in the same wait: no second message
echo '{"hook_event_name":"Notification","message":"Claude is waiting for your input"}' | hook
sleep 2.5
[ "$(posts)" -eq 1 ]
run jq -r '[.session, .url, (.text | test("Claude needs you in alpha"))] | @tsv' "$TMP/posts"
[ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha\ttrue')" ]
# answered within the delay: nothing is posted
echo '{"hook_event_name":"PostToolUse"}' | hook
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Edit"}' | hook
echo '{"hook_event_name":"PostToolUse"}' | hook
sleep 2
[ "$(posts)" -eq 1 ]
# no webhook file: feature off
rm "$TMP/webhook"
echo '{"hook_event_name":"Notification","message":"again"}' | hook
sleep 2
[ "$(posts)" -eq 1 ]
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 ]
[ "$(jq -r '.configured' <<<"$output")" = false ]
run env DEVDESK_GRAFANA_URL=http://127.0.0.1:9 devdesk attach --alerts
[ "$status" -eq 0 ]
[ "$(jq -r '.configured' <<<"$output")" = true ]
[ "$(jq -r '.error | length > 0' <<<"$output")" = true ]
}
@test "upload stores the exact bytes under a sanitised name" {
head -c 50001 /dev/urandom >"$TMP/in.bin"
run bash -c "base64 -w0 '$TMP/in.bin' | devdesk attach --upload alpha '../../etc/My Shot (1).png' 50001"
[ "$status" -eq 0 ]
path=$(tail -n1 <<<"$output" | jq -r .path)
[[ $path == "$DEVDESK_STATE_DIR/uploads/alpha/"*-My_Shot__1_.png ]]
cmp "$TMP/in.bin" "$path"
run bash -c "printf short | devdesk attach --upload alpha x.bin 100"
[ "$status" -ne 0 ]
[[ $output == *"upload incomplete"* ]]
run devdesk attach --upload alpha x.bin 999999999999
[ "$status" -ne 0 ]
run devdesk attach --upload ../x x.bin 10
[ "$status" -ne 0 ]
}
@test "end run from inside the session leaves no ghost registry entry (#3)" {
start_server
devdesk new alpha
tmux -S "$DEVDESK_SOCKET" send-keys -t =alpha:shell "$REPO/bin/devdesk end alpha" Enter
for _ in $(seq 1 50); do
tmux -S "$DEVDESK_SOCKET" has-session -t =alpha 2>/dev/null || break
sleep 0.1
done
run tmux -S "$DEVDESK_SOCKET" has-session -t =alpha
[ "$status" -ne 0 ]
[ "$(jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json")" = false ]
}
@test "attach --existing refuses sessions that were ended or renamed (#4)" {
start_server
run devdesk attach --existing ghost
[ "$status" -ne 0 ]
[[ $output == *"no longer exists"* ]]
run devdesk attach --existing ../x
[ "$status" -ne 0 ]
}
@test "same-second uploads of the same name get distinct files (#6)" {
printf 'one' | base64 -w0 >"$TMP/a.b64"
printf 'two' | base64 -w0 >"$TMP/b.b64"
p1=$(devdesk attach --upload alpha image.png 3 <"$TMP/a.b64" | tail -n1 | jq -r .path)
p2=$(devdesk attach --upload alpha image.png 3 <"$TMP/b.b64" | tail -n1 | jq -r .path)
[ "$p1" != "$p2" ]
[ "$(cat "$p1")" = one ]
[ "$(cat "$p2")" = two ]
[[ $p1 == *-image.png ]]
}
@test "a tab in a project path does not break --list (#10)" {
mkdir -p "$DEVDESK_PROJECTS_ROOT/odd name"
start_server
run devdesk attach --list
[ "$status" -eq 0 ]
jq -e '.projects | map(.dir) | any(endswith("odd\tname"))' <<<"$output" >/dev/null
}
@test "repo hides credentials embedded in the remote URL (#11)" {
export DEVDESK_GITEA_URL=http://127.0.0.1:9
repo="$DEVDESK_PROJECTS_ROOT/alpha"
git -C "$repo" init -q -b main
git -C "$repo" remote add origin https://jared:hunter2@example.com/x/y.git
run devdesk repo alpha
[ "$status" -eq 0 ]
[ "$(jq -r .remote <<<"$output")" = https://example.com/x/y.git ]
[[ $output != *hunter2* ]]
}
@test "web actions run through attach and answer with JSON" {
start_server
run devdesk attach --new web1 alpha
@@ -264,3 +491,132 @@ claude_args() {
run claude_args alpha
[ "$output" = "-n alpha --settings $REPO/share/claude-settings.json --remote-control devdesk-alpha" ]
}
# Decode a blob reply (header line, base64 between markers) into $TMP/blob.
blob_body() {
sed -n '/^DEVDESK-BLOB-BEGIN$/,/^DEVDESK-BLOB-END$/p' <<<"$1" | sed '1d;$d' | base64 -d >"$TMP/blob"
}
@test "fs-list lists one level, folders first, and stays inside the session folder (#19)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
mkdir -p "$p/src/deep" "$p/.git"
echo hi >"$p/README.md"
echo x >"$p/src/a.sh"
ln -s "$TMP/outside" "$p/escape-link"
ln -s src "$p/src-link"
run devdesk attach --fs-list alpha
[ "$status" -eq 0 ]
# Folders first (a symlink to a folder counts, even one that escapes: opening it fails).
[ "$(jq -r '[.entries[].n] | join(",")' <<<"$output")" = "escape-link,src,src-link,README.md" ]
run devdesk attach --fs-list alpha src
[ "$(jq -r '.dir + ":" + ([.entries[].n] | join(","))' <<<"$output")" = "src:deep,a.sh" ]
for bad in ../ /etc escape-link "src/../../alpha/../../outside"; do
run devdesk attach --fs-list alpha "$bad"
[ "$status" -ne 0 ]
done
run devdesk attach --fs-list alpha src-link
[ "$status" -eq 0 ]
}
@test "fs-read returns text, images and refuses binaries, big files and escapes (#19)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
printf 'line 1\r\nüñí\n' >"$p/notes.md"
printf '\x89PNG\r\n\x1a\n\0\0\0' >"$p/pic.png"
head -c 100 /dev/zero >"$p/blob.bin"
head -c 3000 /dev/zero | tr '\0' a >"$p/big.txt"
run devdesk attach --fs-read alpha notes.md
[ "$status" -eq 0 ]
[ "$(head -n1 <<<"$output" | jq -r '[.path, .binary, .utf8, .body] | @tsv')" = "$(printf 'notes.md\tfalse\ttrue\ttrue')" ]
blob_body "$output"
cmp "$TMP/blob" "$p/notes.md"
run devdesk attach --fs-read alpha pic.png
[ "$(head -n1 <<<"$output" | jq -r '[.mime, .body] | @tsv')" = "$(printf 'image/png\ttrue')" ]
run devdesk attach --fs-read alpha blob.bin
[ "$(head -n1 <<<"$output" | jq -r '[.binary, .body] | @tsv')" = "$(printf 'true\tfalse')" ]
run env DEVDESK_FS_MAX=1000 "$REPO/bin/devdesk" attach --fs-read alpha big.txt
[ "$(head -n1 <<<"$output" | jq -r '.tooLarge')" = true ]
run devdesk attach --fs-read alpha ../alpha/../../outside
[ "$status" -ne 0 ]
}
@test "git-status and git-show report changes, line counts and Claude's edits (#19)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
git -C "$p" init -q -b main
git -C "$p" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init
printf 'one\ntwo\n' >"$p/kept.txt"
printf 'old\n' >"$p/moved.txt"
git -C "$p" add . && git -C "$p" -c user.name=t -c user.email=t@t commit -q -m files
printf 'one\nTWO\nthree\n' >"$p/kept.txt"
git -C "$p" mv moved.txt renamed.txt
echo new >"$p/fresh.txt"
mkdir -p "$DEVDESK_STATE_DIR/edits"
printf '%s\n' "$p/kept.txt" "$p/notes-outside-git.txt" >"$DEVDESK_STATE_DIR/edits/alpha.txt"
run devdesk attach --git-status alpha
[ "$status" -eq 0 ]
[ "$(jq -r '.files | map("\(.x)\(.y) \(.path) +\(.add) -\(.del) \(.claude)") | sort | join("|")' <<<"$output")" = \
" notes-outside-git.txt +null -null true| M kept.txt +2 -1 true|?? fresh.txt +null -null false|R renamed.txt +null -null false" ]
[ "$(jq -r '.files[] | select(.path == "renamed.txt") | .from' <<<"$output")" = moved.txt ]
run devdesk attach --git-show alpha kept.txt
blob_body "$output"
[ "$(cat "$TMP/blob")" = "$(printf 'one\ntwo')" ]
run devdesk attach --git-show alpha fresh.txt
[ "$(head -n1 <<<"$output" | jq -r '.size')" = 0 ]
run devdesk attach --git-status alpha
run env DEVDESK_PROJECTS_ROOT="$TMP/outside" "$REPO/bin/devdesk" attach --git-status alpha
[ "$status" -eq 0 ]
}
@test "the state hook logs Claude's edits; rename moves and end removes the log (#19)" {
start_server
devdesk new alpha
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
hook() { TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
echo '{"hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"/x/a.txt"}}' | hook
echo '{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"/x/a.txt"}}' | hook
echo '{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"ls"}}' | hook
echo '{"hook_event_name":"PostToolUse","tool_name":"NotebookEdit","tool_input":{"notebook_path":"/x/n.ipynb"}}' | hook
[ "$(cat "$DEVDESK_STATE_DIR/edits/alpha.txt")" = "$(printf '/x/a.txt\n/x/n.ipynb')" ]
devdesk rename alpha beta
[ -f "$DEVDESK_STATE_DIR/edits/beta.txt" ] && [ ! -e "$DEVDESK_STATE_DIR/edits/alpha.txt" ]
devdesk end beta
[ ! -e "$DEVDESK_STATE_DIR/edits/beta.txt" ]
}
@test "asset serves only named .js files from the assets folder (#19)" {
mkdir -p "$TMP/assets"
echo 'window.X = 1;' >"$TMP/assets/editor.js"
run env DEVDESK_ASSETS="$TMP/assets" "$REPO/bin/devdesk" attach --asset editor.js
[ "$status" -eq 0 ]
blob_body "$output"
cmp "$TMP/blob" "$TMP/assets/editor.js"
for bad in ../etc/passwd editor.css nope.js; do
run env DEVDESK_ASSETS="$TMP/assets" "$REPO/bin/devdesk" attach --asset "$bad"
[ "$status" -ne 0 ]
done
}
@test "fs-list flags gitignored entries; fs-find lists files for go-to-file (#37)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
git -C "$p" init -q -b main
mkdir -p "$p/src" "$p/build" "$p/node_modules/x"
printf 'build/\n*.log\n' >"$p/.gitignore"
echo a >"$p/src/a.js"
echo b >"$p/debug.log"
echo c >"$p/node_modules/x/i.js"
run devdesk attach --fs-list alpha
[ "$(jq -r '[.entries[] | select(.ig) | .n] | join(",")' <<<"$output")" = "build,debug.log" ]
run devdesk attach --fs-find alpha
[ "$(jq -r '.files | sort | join(",")' <<<"$output")" = ".gitignore,node_modules/x/i.js,src/a.js" ]
# Outside git: find, without node_modules
rm -rf "$p/.git"
run devdesk attach --fs-find alpha
[ "$(jq -r '.files | sort | join(",")' <<<"$output")" = ".gitignore,debug.log,src/a.js" ]
# Hitting the cap must not fail the command (SIGPIPE in the producer).
for i in $(seq 1 3000); do : >"$p/src/f$i.txt"; done
run env DEVDESK_FS_FIND_MAX=1 "$REPO/bin/devdesk" attach --fs-find alpha
[ "$status" -eq 0 ]
[ "$(jq -r '[(.files | length), .truncated] | @tsv' <<<"$output")" = "$(printf '1\ttrue')" ]
run env DEVDESK_FS_LIST_MAX=5 "$REPO/bin/devdesk" attach --fs-list alpha src
[ "$status" -eq 0 ]
[ "$(jq -r '[(.entries | length), .truncated] | @tsv' <<<"$output")" = "$(printf '5\ttrue')" ]
}
+7 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
Client, FlowControl, decodeServer, encodeBinaryInput, encodeInit, encodeInput, encodeResize,
parseListOutput, parseTitle, sessionFromSearch, slugify, stripAnsi, tokenUrl, wsUrl,
parseListOutput, parseTitle, pasteSafe, sessionFromSearch, slugify, stripAnsi, tokenUrl, wsUrl,
} from '../web/src/protocol.js';
const text = (bytes) => new TextDecoder().decode(bytes);
@@ -80,3 +80,9 @@ test('flow control pauses after highWater pending writes and resumes below lowWa
assert.equal(flow.release(), null); // 1
assert.equal(flow.release(), Client.RESUME); // 0
});
test('untrusted text loses control characters before it is typed (#2)', () => {
assert.equal(pasteSafe('ok\nrm -rf /\r\x1b[31mred\x07'), 'ok rm -rf / [31mred ');
assert.equal(pasteSafe('plain text'), 'plain text');
assert.equal(pasteSafe(null), '');
});
+27 -1
View File
@@ -2,6 +2,7 @@
// file and nothing else), with CSS, JS, fonts and the favicon all inlined.
import { build, transform } from 'esbuild';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -33,8 +34,29 @@ async function fontFaces(weights) {
return faces.join('\n');
}
// The file viewer's editor (devdesk#19): its own IIFE, served over the ttyd
// channel by `devdesk attach --asset editor.js` and cached by the browser. The
// page carries its SHA-256, which is both the integrity check and the cache key.
async function editorBundle() {
const out = await build({
entryPoints: [at('web', 'editor', 'index.js')],
bundle: true,
minify: true,
format: 'iife',
globalName: 'DevDeskEditor',
target: ['es2020'],
write: false,
legalComments: 'eof',
});
const code = out.outputFiles[0].text;
await mkdir(at('dist', 'assets'), { recursive: true });
await writeFile(at('dist', 'assets', 'editor.js'), code);
return { sha: createHash('sha256').update(code).digest('hex'), size: Buffer.byteLength(code) };
}
async function main() {
const ver = await version();
const editor = await editorBundle();
const js = await build({
entryPoints: [at('web', 'src', 'main.js')],
@@ -44,7 +66,10 @@ async function main() {
target: ['es2020'],
write: false,
legalComments: 'eof',
define: { __DEVDESK_VERSION__: JSON.stringify(ver) },
define: {
__DEVDESK_VERSION__: JSON.stringify(ver),
__EDITOR_SHA__: JSON.stringify(editor.sha),
},
});
const css = await transform([
@@ -67,6 +92,7 @@ async function main() {
await mkdir(at('dist'), { recursive: true });
await writeFile(at('dist', 'index.html'), html);
console.log(`dist/index.html ${(html.length / 1024).toFixed(0)} KiB (devdesk ${ver})`);
console.log(`dist/assets/editor.js ${(editor.size / 1024).toFixed(0)} KiB sha256 ${editor.sha.slice(0, 12)}`);
}
main().catch((err) => {
+159
View File
@@ -0,0 +1,159 @@
// The file viewer's editor bundle (devdesk#19): CodeMirror 6 with stream-mode
// languages, a unified diff view, and markdown rendering. Built separately to
// dist/assets/editor.js (window.DevDeskEditor) and loaded over the ttyd channel
// the first time the Files pane opens, so the page itself stays small.
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, highlightSpecialChars } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { markdownLanguage } from '@codemirror/lang-markdown';
import { unifiedMergeView } from '@codemirror/merge';
import { tags as t } from '@lezer/highlight';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { python } from '@codemirror/legacy-modes/mode/python';
import { javascript, json, typescript } from '@codemirror/legacy-modes/mode/javascript';
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { properties } from '@codemirror/legacy-modes/mode/properties';
import { nginx } from '@codemirror/legacy-modes/mode/nginx';
import { diff as diffMode } from '@codemirror/legacy-modes/mode/diff';
import { css } from '@codemirror/legacy-modes/mode/css';
import { xml, html } from '@codemirror/legacy-modes/mode/xml';
import { go } from '@codemirror/legacy-modes/mode/go';
import { rust } from '@codemirror/legacy-modes/mode/rust';
import { standardSQL } from '@codemirror/legacy-modes/mode/sql';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { lua } from '@codemirror/legacy-modes/mode/lua';
import { perl } from '@codemirror/legacy-modes/mode/perl';
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
const MODES = {
shell, python, javascript, json, typescript, yaml, toml, properties, nginx, diff: diffMode, css, xml, html,
go, rust, sql: standardSQL, dockerfile: dockerFile, lua, perl, ruby,
};
const BY_EXT = {
md: 'markdown', markdown: 'markdown', mdx: 'markdown',
sh: 'shell', bash: 'shell', zsh: 'shell', ksh: 'shell', env: 'shell', bats: 'shell',
py: 'python', pyi: 'python',
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
ts: 'typescript', tsx: 'typescript', mts: 'typescript',
json: 'json', jsonc: 'json', webmanifest: 'json',
yml: 'yaml', yaml: 'yaml',
toml: 'toml',
ini: 'properties', cfg: 'properties', conf: 'properties', properties: 'properties',
service: 'properties', timer: 'properties', socket: 'properties', mount: 'properties', network: 'properties',
diff: 'diff', patch: 'diff',
css: 'css', scss: 'css',
xml: 'xml', svg: 'xml', plist: 'xml',
html: 'html', htm: 'html', php: 'html', j2: 'html', jinja: 'html',
go: 'go', rs: 'rust', sql: 'sql', lua: 'lua', pl: 'perl', pm: 'perl', rb: 'ruby',
};
// Language for a path: extension, a few well-known names, else none.
export function languageFor(path) {
const name = path.split('/').pop().toLowerCase();
if (name === 'dockerfile' || name.startsWith('dockerfile.') || name.endsWith('.dockerfile')) return 'dockerfile';
if (name === 'makefile' || name === 'gnumakefile') return 'shell';
if (/(^|\/)nginx\//.test(path) || name === 'nginx.conf') return 'nginx';
if (['.bashrc', '.profile', '.bash_profile', '.zshrc', 'pkgbuild'].includes(name)) return 'shell';
if (name.startsWith('.env')) return 'shell';
const ext = name.includes('.') ? name.split('.').pop() : '';
return BY_EXT[ext] ?? null;
}
function languageExtension(lang) {
if (lang === 'markdown') return markdownLanguage;
if (lang && MODES[lang]) return StreamLanguage.define(MODES[lang]);
return [];
}
// Colours come from the page's TDS variables, so dark/light mode just works.
const highlight = HighlightStyle.define([
{ tag: [t.keyword, t.controlKeyword, t.operatorKeyword, t.modifier], color: 'var(--accent-orange)' },
{ tag: [t.string, t.special(t.string), t.regexp], color: 'var(--accent-green)' },
{ tag: [t.comment, t.lineComment, t.blockComment, t.meta], color: 'var(--text-muted)', fontStyle: 'italic' },
{ tag: [t.number, t.bool, t.atom, t.null], color: 'var(--accent-purple)' },
{ tag: [t.typeName, t.className, t.namespace], color: 'var(--accent-amber)' },
{ tag: [t.function(t.variableName), t.function(t.propertyName), t.definition(t.variableName)], color: 'var(--accent-cyan)' },
{ tag: [t.propertyName, t.attributeName], color: 'var(--accent-cyan)' },
{ tag: [t.tagName], color: 'var(--accent-orange)' },
{ tag: [t.heading, t.heading1, t.heading2, t.heading3], color: 'var(--accent-cyan)', fontWeight: 'bold' },
{ tag: t.strong, fontWeight: 'bold' },
{ tag: t.emphasis, fontStyle: 'italic' },
{ tag: t.strikethrough, textDecoration: 'line-through' },
{ tag: [t.link, t.url], color: 'var(--accent-cyan)', textDecoration: 'underline' },
{ tag: [t.monospace], color: 'var(--accent-amber)' },
{ tag: [t.quote], color: 'var(--text-secondary)', fontStyle: 'italic' },
{ tag: t.inserted, color: 'var(--accent-green)' },
{ tag: t.deleted, color: 'var(--accent-red)' },
{ tag: t.invalid, color: 'var(--accent-red)' },
]);
const theme = EditorView.theme({
'&': { height: '100%', color: 'var(--text-primary)', backgroundColor: 'var(--bg-terminal)', fontSize: 'var(--dd-editor-font, 13px)' },
'.cm-scroller': { fontFamily: 'var(--font-mono)', lineHeight: '1.5' },
'.cm-content': { caretColor: 'var(--accent-orange)' },
'.cm-cursor': { borderLeftColor: 'var(--accent-orange)' },
'.cm-gutters': { backgroundColor: 'var(--bg-secondary)', color: 'var(--text-dim)', border: 'none', borderRight: '1px solid var(--border-dim)' },
'.cm-activeLine': { backgroundColor: 'var(--accent-cyan-dim)' },
'.cm-activeLineGutter': { backgroundColor: 'var(--accent-cyan-dim)', color: 'var(--accent-cyan)' },
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': { backgroundColor: 'rgba(0, 150, 220, 0.28) !important' },
'.cm-searchMatch': { backgroundColor: 'var(--accent-amber-dim)', outline: '1px solid var(--accent-amber)' },
'.cm-searchMatch.cm-searchMatch-selected': { backgroundColor: 'var(--accent-orange-dim)' },
'.cm-selectionMatch': { backgroundColor: 'var(--accent-cyan-dim)' },
'.cm-panels': { backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)', borderColor: 'var(--border-color)' },
'.cm-panel input, .cm-panel button': { fontFamily: 'var(--font-mono)', fontSize: '12px' },
'.cm-textfield': { backgroundColor: 'var(--bg-input)', color: 'var(--text-primary)', border: '1px solid var(--border-color)' },
'.cm-button': { backgroundImage: 'none', backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-color)' },
'.cm-foldGutter span': { color: 'var(--text-dim)' },
// unified diff
'.cm-changedLine': { backgroundColor: 'var(--accent-green-dim) !important' },
'.cm-changedText': { backgroundColor: 'rgba(0, 200, 120, 0.22) !important' },
'.cm-deletedChunk': { backgroundColor: 'var(--accent-red-dim) !important' },
'.cm-deletedChunk .cm-deletedText, .cm-deletedChunk del': { backgroundColor: 'rgba(255, 45, 85, 0.22)', textDecoration: 'none' },
'.cm-insertedLine': { backgroundColor: 'var(--accent-green-dim)' },
'.cm-changeGutter': { width: '3px' },
'.cm-changedLineGutter': { backgroundColor: 'var(--accent-green)' },
'.cm-deletedLineGutter': { backgroundColor: 'var(--accent-red)' },
});
function baseExtensions(path, { readOnly = true, wrap = false } = {}) {
return [
lineNumbers(), foldGutter(), highlightSpecialChars(), highlightActiveLine(), highlightActiveLineGutter(),
drawSelection(), history(), search({ top: true }), highlightSelectionMatches(), bracketMatching(),
syntaxHighlighting(highlight), theme, languageExtension(languageFor(path)),
// Read-only document in an editable view: the caret, selection, arrow keys
// and Ctrl+F work, typing doesn't; no on-screen keyboard on phones.
EditorState.readOnly.of(readOnly),
readOnly ? EditorView.contentAttributes.of({ inputmode: 'none' }) : [],
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...foldKeymap]),
wrap ? EditorView.lineWrapping : [],
];
}
// Read-only (v1) view of a file.
export function viewer(parent, { doc, path, wrap }) {
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap }) }) });
return { view, destroy: () => view.destroy() };
}
// Unified diff of the HEAD version against the working copy.
export function diff(parent, { original, modified, path, wrap }) {
const view = new EditorView({
parent,
state: EditorState.create({
doc: modified,
extensions: [baseExtensions(path, { wrap }), unifiedMergeView({ original, mergeControls: false, highlightChanges: true, gutter: true })],
}),
});
return { view, destroy: () => view.destroy() };
}
// Markdown to sanitized HTML (shown in a sandboxed iframe on top of this).
export function markdown(text) {
return DOMPurify.sanitize(marked.parse(text, { gfm: true, breaks: false }), { USE_PROFILES: { html: true } });
}
+109 -56
View File
@@ -2,12 +2,21 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
<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">
@@ -16,31 +25,60 @@
<div class="lt-header-left">
<div class="lt-brand">
<span class="lt-brand-title">DevDesk</span>
<span class="lt-brand-subtitle">Claude Code workspace</span>
</div>
<div class="dd-session">
<span class="lt-chip lt-chip-info" id="session-chip">—</span>
<button type="button" class="dd-session-chip" id="session-chip" data-state="" aria-haspopup="menu" aria-expanded="false" title="Switch session">
<span class="dd-state-dot" aria-hidden="true"></span><span class="dd-session-label" id="session-name">—</span><span class="dd-caret" aria-hidden="true">▾</span>
</button>
<span class="dd-pane-title" id="pane-title"></span>
<div class="dd-menu dd-switcher" id="switcher" role="menu" hidden></div>
</div>
</div>
<div class="lt-header-right">
<span class="lt-ws-status" id="ws-status" data-state="connecting" role="status">
<button type="button" class="lt-chip dd-attention" id="attention-chip" title="Sessions waiting for you"></button>
<button type="button" class="lt-btn lt-btn-danger lt-btn-sm dd-reauth" id="reauth" hidden title="Your Authelia session expired. Claude keeps running; sign in again to keep using the panels.">Sign in again</button>
<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-chip dd-sso" id="sso-badge" data-state="unknown" title="Signed in through Authelia">SSO</button>
<div class="lt-btn-group dd-tools">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="font-down" title="Smaller text" aria-label="Smaller text">A−</button>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="font-up" title="Larger text" aria-label="Larger text">A+</button>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="notify-toggle" title="Desktop notification when Claude needs you">Alerts off</button>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="fullscreen-toggle" title="Fullscreen (also captures browser shortcuts in Chromium)">Full</button>
<button type="button" class="lt-btn lt-btn-sm" id="sidebar-toggle" aria-controls="sidebar" aria-expanded="false">Panels</button>
<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">&nbsp;⌃⇧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="lt-notif-dropdown-wrap dd-inbox">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm lt-notif-bell-btn" id="inbox-toggle" aria-haspopup="dialog" aria-expanded="false" aria-controls="inbox-panel" aria-label="Inbox" title="Inbox">🔔</button>
<span class="lt-notif-badge" id="inbox-badge" data-count="0" aria-hidden="true"></span>
<div class="lt-notif-panel dd-inbox-panel" id="inbox-panel" role="dialog" aria-label="Inbox" aria-hidden="true">
<div class="lt-notif-panel-header"><span>Inbox</span><button type="button" class="lt-notif-panel-clear">Mark all read</button></div>
<div class="lt-notif-panel-list"></div>
</div>
</div>
<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>
<div class="dd-menu-row">
<span>Text size</span>
<span class="dd-menu-inline">
<button type="button" class="dd-icon-btn" id="font-down" aria-label="Smaller text">A−</button>
<span id="font-size">14</span>
<button type="button" class="dd-icon-btn" id="font-up" aria-label="Larger text">A+</button>
</span>
</div>
<button type="button" class="dd-menu-item" id="theme-menu" role="menuitem">Light mode</button>
<button type="button" class="dd-menu-item" id="notify-toggle" role="menuitem">Desktop notifications</button>
<button type="button" class="dd-menu-item" id="palette-menu" role="menuitem">Command palette</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>
</div>
<button type="button" class="lt-btn lt-btn-sm dd-files-toggle" id="files-toggle" aria-controls="files" aria-expanded="false" title="Files: browse, preview and diff this session's folder (Ctrl+Shift+E)"><span class="dd-panels-label">Files</span><span class="dd-panels-icon" aria-hidden="true">🗀</span></button>
<button type="button" class="lt-btn lt-btn-sm dd-panels" id="sidebar-toggle" aria-controls="sidebar" aria-expanded="false"><span class="dd-panels-label">Panels</span><span class="dd-panels-icon" aria-hidden="true">▤</span></button>
</div>
</header>
<main class="dd-main">
<section class="lt-frame dd-terminal-frame" aria-label="Terminal">
<div id="terminal" class="dd-terminal"></div>
<div id="drop-overlay" class="dd-drop-overlay" hidden><span>Drop to attach to <b id="drop-session">this session</b></span></div>
<div id="upload-status" class="dd-upload-status" role="status" hidden></div>
<div id="overlay" class="dd-overlay" data-tone="info" hidden>
<div class="lt-frame dd-overlay-card" role="alertdialog" aria-labelledby="overlay-title" aria-describedby="overlay-text">
<div class="lt-section-header" id="overlay-title">Disconnected</div>
@@ -52,77 +90,77 @@
</div>
</section>
<section class="lt-frame dd-files" id="files" aria-label="Files" hidden>
<div class="dd-files-resizer" id="files-resizer" role="separator" aria-orientation="vertical" aria-label="Resize the files pane" tabindex="0"></div>
<div class="dd-files-head">
<button type="button" class="dd-icon-btn dd-explorer-toggle" title="Show / hide the explorer" aria-label="Toggle explorer">☰</button>
<div class="dd-tabs" role="tablist" aria-label="Open files"></div>
<span class="dd-files-actions">
<button type="button" class="dd-icon-btn dd-files-find" title="Go to file (Ctrl+P)" aria-label="Go to file">⌕</button>
<button type="button" class="dd-icon-btn dd-files-refresh" title="Refresh" aria-label="Refresh">↻</button>
<button type="button" class="dd-icon-btn dd-files-close" title="Close (Esc)" aria-label="Close files">✕</button>
</span>
</div>
<div class="dd-files-main">
<div class="dd-explorer">
<div class="dd-explorer-views" role="tablist">
<button type="button" role="tab" data-files-view="explorer" aria-selected="true">Explorer</button>
<button type="button" role="tab" data-files-view="changes" aria-selected="false">Changes <span class="dd-files-count"></span></button>
</div>
<div class="dd-explorer-body" role="tree" aria-label="Files"></div>
</div>
<div class="dd-editor-area"></div>
</div>
</section>
<div class="dd-backdrop" id="backdrop" hidden></div>
<aside id="sidebar" class="dd-sidebar" aria-label="Panels">
<section class="lt-frame dd-widget" id="widget-sessions" data-state="live">
<div class="lt-section-header">Sessions
<button type="button" class="dd-header-action" id="sessions-refresh" title="Refresh" aria-label="Refresh sessions">↻</button>
<span class="dd-header-actions">
<button type="button" class="dd-header-action" id="new-session-toggle" aria-expanded="false" title="New session">+ New</button>
<button type="button" class="dd-header-action" id="sessions-refresh" title="Refresh" aria-label="Refresh sessions">↻</button>
</span>
</div>
<div class="lt-section-body">
<ul class="dd-session-list" id="sessions-list"></ul>
<form class="dd-new-session" id="new-session-form" autocomplete="off">
<input class="lt-input" id="new-session-name" maxlength="64" placeholder="New session, e.g. pbs-upgrade"
<form class="dd-new-session" id="new-session-form" autocomplete="off" hidden>
<input class="lt-input" id="new-session-name" maxlength="64" placeholder="Name, e.g. pbs-upgrade"
aria-label="New session name" spellcheck="false">
<select class="lt-select" id="project-select" aria-label="Start the new session in a project folder (optional)"></select>
<button type="submit" class="lt-btn lt-btn-primary lt-btn-sm">Open</button>
</form>
<div class="dd-widget-foot">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="open-picker">Picker</button>
<span class="dd-muted dd-status" id="sessions-status" role="status"></span>
</div>
<ul class="dd-session-list" id="sessions-list"></ul>
<span class="dd-muted dd-status" id="sessions-status" role="status"></span>
</div>
</section>
<section class="lt-frame dd-widget" id="widget-context" data-state="empty">
<div class="lt-section-header">Claude Context</div>
<section class="lt-frame dd-widget" id="widget-alerts" data-state="empty">
<div class="lt-section-header">Ops Alerts <span class="lt-badge lt-badge-sm dd-alert-count"></span></div>
<div class="lt-section-body">
<div class="lt-progress lt-progress--cyan" role="progressbar" aria-label="Context window used" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="lt-progress-bar" style="width:0%"></div>
</div>
<div class="lt-kv-grid dd-context-grid">
<span class="lt-kv-key">Context</span><span class="lt-kv-val" data-field="used">—</span>
<span class="lt-kv-key">Model</span><span class="lt-kv-val" data-field="model">—</span>
<span class="lt-kv-key">Cost</span><span class="lt-kv-val" data-field="cost">—</span>
<span class="lt-kv-key">Edits</span><span class="lt-kv-val" data-field="lines">—</span>
<span class="lt-kv-key">Updated</span><span class="lt-kv-val" data-field="updated">—</span>
</div>
<p class="dd-muted dd-stub-note dd-context-note"></p>
<ul class="dd-alert-list"><li class="dd-empty">Loading…</li></ul>
<p class="dd-muted dd-stub-note dd-alerts-note"></p>
</div>
</section>
<section class="lt-frame dd-widget" id="widget-repo" data-state="stub">
<div class="lt-section-header">Repo / CI <span class="lt-badge lt-badge-sm dd-soon">v1.1</span></div>
<section class="lt-frame dd-widget" id="widget-repo" data-state="empty">
<div class="lt-section-header">Repo / CI</div>
<div class="lt-section-body">
<div class="lt-kv-grid">
<div class="lt-kv-grid dd-repo-grid">
<span class="lt-kv-key">Repo</span><span class="lt-kv-val" data-field="repo">—</span>
<span class="lt-kv-key">Branch</span><span class="lt-kv-val" data-field="branch">—</span>
<span class="lt-kv-key">Tree</span><span class="lt-kv-val" data-field="dirty">—</span>
<span class="lt-kv-key">CI · HEAD</span><span class="lt-kv-val"><span class="lt-status lt-status-pending" data-field="ci">no data</span></span>
<span class="lt-kv-key">CI · HEAD</span><span class="lt-kv-val"><span class="lt-status" data-field="ci">—</span></span>
<span class="lt-kv-key">Open PRs</span><span class="lt-kv-val" data-field="prs">—</span>
</div>
<p class="dd-muted dd-stub-note">Gitea status for this session's repo arrives with devdesk-api.</p>
<p class="dd-muted dd-repo-note"></p>
<button type="button" class="lt-btn lt-btn-sm dd-ask dd-fix-ci" hidden>Ask Claude to fix CI</button>
</div>
</section>
<section class="lt-frame dd-widget" id="widget-alerts" data-state="stub">
<div class="lt-section-header">Ops Alerts <span class="lt-badge lt-badge-sm dd-soon">v2</span></div>
<div class="lt-section-body">
<ul class="dd-alert-list">
<li class="dd-alert">
<span class="lt-dot lt-dot-idle"></span>
<span class="dd-alert-text">No alert source connected</span>
<button type="button" class="lt-btn lt-btn-sm dd-ask" disabled
data-ask-claude="Investigate the firing alert: ">Ask Claude</button>
</li>
</ul>
<p class="dd-muted dd-stub-note">Firing Grafana alerts, each with an “Ask Claude” prompt.</p>
</div>
</section>
</aside>
</main>
<footer class="lt-footer dd-footer">
<div class="lt-footer-hints">
<div class="lt-footer-hints dd-hints">
<span class="lt-footer-hint"><span class="lt-footer-key">⇧⏎</span>newline</span>
<span class="lt-footer-sep">│</span>
<span class="lt-footer-hint"><span class="lt-footer-key">Esc</span>interrupt</span>
@@ -134,8 +172,23 @@
<span class="lt-footer-hint"><span class="lt-footer-key">^b d</span>detach</span>
<span class="lt-footer-sep">│</span>
<span class="lt-footer-hint"><span class="lt-footer-key">⇧drag</span>select</span>
<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>
<button type="button" data-keys="tab">Tab</button>
<button type="button" data-keys="stab">⇧Tab</button>
<button type="button" data-keys="ctrlc">^C</button>
<button type="button" data-keys="up">↑</button>
<button type="button" data-keys="down">↓</button>
<button type="button" data-keys="enter">⏎</button>
<button type="button" data-keys="claude">Claude</button>
<button type="button" data-keys="shell">Shell</button>
</div>
<span class="dd-muted" id="version">devdesk</span>
</footer>
<!--SCRIPT-->
+64
View File
@@ -0,0 +1,64 @@
// Ops Alerts panel: firing/pending Grafana alert rules (`devdesk attach --alerts`),
// each with an "Ask Claude" button that types an investigation prompt into the
// current session (without pressing Enter).
import { el } from './dom.js';
const SEVERITY = {
critical: 'lt-chip-critical',
warning: 'lt-chip-warn',
};
function age(iso) {
const t = Date.parse(iso || '');
if (Number.isNaN(t)) return '';
const s = Math.max(0, (Date.now() - t) / 1000);
if (s < 3600) return `${Math.max(1, Math.floor(s / 60))}m`;
if (s < 86400) return `${Math.floor(s / 3600)}h`;
return `${Math.floor(s / 86400)}d`;
}
export function investigatePrompt(alert) {
const where = alert.instances?.length ? ` on ${alert.instances.join('; ')}` : '';
return `Investigate the ${alert.state} Grafana alert "${alert.name}"`
+ ` (${alert.severity || 'no severity'}, ${alert.folder} / ${alert.group}${where}, since ${alert.since}).`
+ ` Summary: ${alert.summary || 'n/a'}. Use the grafana MCP server to look at the rule and the related`
+ ' metrics, find the cause, and tell me what you would change before changing anything.';
}
export function renderAlerts(root, data) {
const list = root.querySelector('.dd-alert-list');
const note = root.querySelector('.dd-alerts-note');
const count = root.querySelector('.dd-alert-count');
const alerts = data?.alerts ?? [];
count.textContent = alerts.length ? String(alerts.length) : '';
count.className = `lt-badge lt-badge-sm dd-alert-count${alerts.some((a) => a.severity === 'critical') ? ' lt-badge-p1' : alerts.length ? ' lt-badge-p2' : ''}`;
if (!data) {
root.dataset.state = 'empty';
list.replaceChildren(el('li', { class: 'dd-empty' }, 'Loading…'));
note.textContent = '';
return;
}
if (!data.configured) {
root.dataset.state = 'empty';
list.replaceChildren(el('li', { class: 'dd-empty' }, 'Grafana is not configured.'));
note.textContent = 'Set DEVDESK_GRAFANA_URL and a token file in /etc/devdesk/env.local.';
return;
}
root.dataset.state = 'live';
note.textContent = data.error ? `Grafana unreachable: ${data.error}` : '';
if (!alerts.length) {
list.replaceChildren(el('li', { class: 'dd-all-clear' },
el('span', { class: 'lt-dot lt-dot-up' }), data.error ? 'No data' : 'All clear: nothing firing'));
return;
}
list.replaceChildren(...alerts.map((a) => el('li', { class: `dd-alert is-${a.state}` },
el('div', { class: 'dd-alert-head' },
el('span', { class: `lt-chip ${SEVERITY[a.severity] || 'lt-chip-info'}` }, a.state === 'pending' ? 'pending' : (a.severity || 'alert')),
el('a', { class: 'dd-alert-name', href: a.url, target: '_blank', rel: 'noopener', title: `${a.folder} / ${a.group}` }, a.name),
el('span', { class: 'dd-muted dd-alert-age' }, age(a.since))),
a.instances?.length ? el('div', { class: 'dd-muted dd-alert-instances' }, a.instances.join(' · ')) : null,
a.summary ? el('div', { class: 'dd-alert-summary' }, a.summary) : null,
el('button', { type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a) }, 'Ask Claude'))));
}
+220 -8
View File
@@ -12,11 +12,17 @@ export async function fetchToken() {
const res = await fetch(tokenUrl(window.location), {
cache: 'no-store', credentials: 'same-origin', redirect: 'manual',
});
if (res.type === 'opaqueredirect') throw new AuthExpiredError('SSO session expired');
if (res.type === 'opaqueredirect') {
// Any request can discover an expired Authelia session; the header shows
// "Sign in again" when this fires (devdesk#13).
window.dispatchEvent(new CustomEvent('devdesk:auth', { detail: 'expired' }));
throw new AuthExpiredError('SSO session expired');
}
if (res.status === 401 || res.status === 403 || res.status === 407) {
throw new UnauthorizedError(`HTTP ${res.status}`);
}
if (!res.ok) throw new Error(`token request failed: HTTP ${res.status}`);
window.dispatchEvent(new CustomEvent('devdesk:auth', { detail: 'ok' }));
const body = await res.json();
return body.token || '';
}
@@ -94,9 +100,41 @@ export class TtydConnection {
}
}
// Side-channel commands each occupy a ttyd client slot and a server process:
// run at most two at a time (devdesk#8).
let active = 0;
const waiting = [];
async function limited(task) {
if (active >= 2) await new Promise((resolve) => waiting.push(resolve));
active += 1;
try {
return await task();
} finally {
active -= 1;
waiting.shift()?.();
}
}
// Run a short-lived `devdesk attach <args…>` and return its output as text.
// ttyd is the only server-side endpoint, so session actions travel this way.
export async function runCommand(args, timeoutMs = 8000) {
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) => {
const socket = new WebSocket(wsUrl(window.location, args), ['tty']);
@@ -107,15 +145,34 @@ export async function runCommand(args, timeoutMs = 8000) {
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)));
};
const decoder = new TextDecoder();
let head = '';
let tail = '';
socket.onmessage = (event) => {
const { type, payload } = decodeServer(event.data);
if (type !== Server.OUTPUT) return;
chunks.push(payload.slice());
const piece = decoder.decode(payload, { stream: true });
if (head.length < 4096) head += piece;
tail = (tail + piece).slice(-64);
// 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).
// Replies are one JSON line (or a devdesk: error), so only a chunk that
// ends a line with "}" is worth a full parse; big replies stay linear.
if (/^devdesk: .*\n/m.test(head) || (/\}\s*\n\s*$/.test(tail) && isComplete(new TextDecoder().decode(concat(chunks))))) {
finish();
socket.close();
}
};
socket.onerror = () => {};
socket.onclose = finish;
});
}
@@ -124,6 +181,161 @@ export async function fetchSessionList() {
return parseListOutput(await runCommand([LIST_ARG]));
}
// Firing/pending Grafana alerts (`devdesk attach --alerts`).
export async function fetchAlerts() {
return parseListOutput(await runCommand(['--alerts']));
}
function toBase64(bytes) {
let binary = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
}
return btoa(binary);
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const UPLOAD_MAX = 25 * 1024 * 1024;
// Upload a file into the session's upload folder through ttyd: wait for the
// server's ready line, stream the file as base64 input in slices (never the
// whole file in memory), read back {ok, path}. Fails on 20 s of inactivity
// rather than a fixed total, so slow links still work (devdesk#9).
export function uploadFile(session, file, onProgress) {
if (!file.size) return Promise.reject(new Error('file is empty'));
if (file.size > UPLOAD_MAX) return Promise.reject(new Error('larger than 25 MB'));
return limited(() => uploadNow(session, file, onProgress));
}
async function uploadNow(session, file, onProgress) {
const token = await fetchToken();
const name = (file.name || 'pasted-image.png').slice(-100);
const slice = 3 * 16 * 1024; // multiple of 3: base64 of slices concatenates cleanly
return new Promise((resolve, reject) => {
const socket = new WebSocket(wsUrl(window.location, ['--upload', session, name, String(file.size)]), ['tty']);
socket.binaryType = 'arraybuffer';
const decoder = new TextDecoder();
let output = '';
let sending = false;
let idle = 0;
const touch = () => {
clearTimeout(idle);
idle = setTimeout(() => socket.close(), 20000);
};
const send = async () => {
for (let offset = 0; offset < file.size; offset += slice) {
while (socket.bufferedAmount > 1 << 20) await sleep(20);
if (socket.readyState !== WebSocket.OPEN) return;
const bytes = new Uint8Array(await file.slice(offset, offset + slice).arrayBuffer());
socket.send(encodeInput(toBase64(bytes)));
touch();
onProgress?.(Math.min(1, (offset + slice) / file.size));
}
};
touch();
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
socket.onmessage = (event) => {
touch();
const { type, payload } = decodeServer(event.data);
if (type !== Server.OUTPUT) return;
output += decoder.decode(payload, { stream: true });
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 = () => {};
socket.onclose = () => {
clearTimeout(idle);
const text = stripAnsi(output.replace(/^[\s\S]*DEVDESK-UPLOAD-READY/, ''));
try {
const result = parseListOutput(text);
if (result.ok && result.path) {
resolve(result.path);
return;
}
} catch {
// fall through to the error message
}
const message = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).pop();
reject(new Error((message || 'upload failed').replace(/^devdesk:\s*/, '')));
};
});
}
// Blob replies (`--fs-read`, `--git-show`, `--asset`): a JSON header line, then
// base64 between DEVDESK-BLOB-BEGIN/END. Resolves { header, base64 }, base64
// being null when the file has no body (too large, binary). Only the head and
// the tail of the stream are decoded while it arrives, so megabytes stay cheap.
export function runBlob(args, timeoutMs = 30000) {
return limited(() => runBlobNow(args, timeoutMs));
}
async function runBlobNow(args, timeoutMs) {
const token = await fetchToken();
return new Promise((resolve, reject) => {
const socket = new WebSocket(wsUrl(window.location, args), ['tty']);
socket.binaryType = 'arraybuffer';
const chunks = [];
const decoder = new TextDecoder();
let head = '';
let tail = '';
let settled = false;
const done = (fn, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket.close();
fn(value);
};
const timer = setTimeout(() => done(reject, new Error('devdesk did not answer in time')), timeoutMs);
const finish = () => {
const text = stripAnsi(new TextDecoder().decode(concat(chunks)));
const error = /^devdesk: (.*)$/m.exec(text);
const line = text.split(/\r?\n/).find((l) => l.startsWith('{'));
if (!line) {
done(reject, new Error(error ? error[1] : 'no reply'));
return;
}
let header;
try {
header = JSON.parse(line);
} catch {
done(reject, new Error('unreadable reply'));
return;
}
const body = /DEVDESK-BLOB-BEGIN\r?\n([\s\S]*?)\r?\nDEVDESK-BLOB-END/.exec(text);
done(resolve, { header, base64: body ? body[1].replace(/\s+/g, '') : null });
};
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
socket.onmessage = (event) => {
const { type, payload } = decodeServer(event.data);
if (type !== Server.OUTPUT) return;
chunks.push(payload.slice());
const piece = decoder.decode(payload, { stream: true });
if (head.length < 4096) head += piece;
tail = (tail + piece).slice(-256);
const firstLine = /^\s*(\{.*\})\r?$/m.exec(head);
if (tail.includes('DEVDESK-BLOB-END') || /^devdesk: .*\n/m.test(head)
|| (firstLine && !head.includes('DEVDESK-BLOB-BEGIN') && /"tooLarge":true/.test(firstLine[1]))) {
finish();
}
};
socket.onerror = () => {};
socket.onclose = () => {
if (!settled) finish();
};
});
}
// Git + Gitea CI status for a session's folder (`devdesk attach --repo NAME`).
export async function fetchRepo(session) {
return parseListOutput(await runCommand(['--repo', session]));
}
// `devdesk attach --new|--rename|--end …`: resolves on {"ok":true}, otherwise
// rejects with devdesk's own error message.
export async function sessionAction(action, ...args) {
-51
View File
@@ -1,51 +0,0 @@
// Claude Context panel: the current session's model, context-window use, cost
// and edits, from the snapshot devdesk-statusline writes on every Claude update.
import { ago } from './sessions.js';
function compact(tokens) {
if (tokens == null) return '—';
if (tokens >= 1e6) return `${+(tokens / 1e6).toFixed(tokens % 1e6 ? 1 : 0)}M`;
if (tokens >= 1e3) return `${Math.round(tokens / 1e3)}k`;
return String(tokens);
}
export function renderContext(root, session, context) {
const set = (field, text) => {
const node = root.querySelector(`[data-field="${field}"]`);
if (node) node.textContent = text;
};
const bar = root.querySelector('.lt-progress');
const fill = root.querySelector('.lt-progress-bar');
const note = root.querySelector('.dd-context-note');
if (!context) {
root.dataset.state = 'empty';
fill.style.width = '0%';
bar.setAttribute('aria-valuenow', '0');
for (const f of ['used', 'model', 'cost', 'lines', 'updated']) set(f, '—');
note.textContent = session
? 'No data yet: it appears once Claude has redrawn its status line in this session.'
: 'Open a session to see its context.';
return;
}
const pct = context.used_pct;
root.dataset.state = 'live';
fill.style.width = `${Math.min(100, pct ?? 0)}%`;
bar.setAttribute('aria-valuenow', String(Math.round(pct ?? 0)));
bar.classList.remove('lt-progress--cyan', 'lt-progress--red');
if (pct == null || pct < 70) bar.classList.add('lt-progress--cyan');
else if (pct >= 90) bar.classList.add('lt-progress--red');
set('used', pct == null
? `— / ${compact(context.window)}`
: `${compact(context.used_tokens)} / ${compact(context.window)} (${pct.toFixed(pct < 10 ? 1 : 0)}%)`);
set('model', [context.model, context.effort].filter(Boolean).join(' · ') || '—');
set('cost', context.cost_usd == null ? '—' : `$${context.cost_usd.toFixed(2)}`);
set('lines', `+${context.lines_added ?? 0} −${context.lines_removed ?? 0}`);
set('updated', context.updated ? `${ago(context.updated)} ago`.replace('now ago', 'just now') : '—');
note.textContent = pct != null && pct >= 80
? 'Context is filling up; consider /compact or a new conversation.'
: '';
}
+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;
}
+943
View File
@@ -0,0 +1,943 @@
/* global __EDITOR_SHA__ */
// Files pane (devdesk#19, #37), laid out like VS Code: an explorer tree (or the
// git changes list) beside an editor area with tabs. Read the session's files
// with syntax highlighting, preview markdown and images, diff against HEAD,
// see what Claude edited, and jump to any file by name. Read-only for now.
// Everything goes through `devdesk attach --fs-*` over ttyd.
import { runBlob, runCommand } from './connection.js';
import { parseListOutput } from './protocol.js';
import { el } from './dom.js';
import { ago } from './sessions.js';
import { CommandPalette, toast } from './ui.js';
// ---------------------------------------------------------------- editor bundle
const DB = 'devdesk';
const STORE = 'assets';
function idb(mode, run) {
return new Promise((resolve, reject) => {
const open = indexedDB.open(DB, 1);
open.onupgradeneeded = () => open.result.createObjectStore(STORE);
open.onerror = () => reject(open.error);
open.onsuccess = () => {
const tx = open.result.transaction(STORE, mode);
const request = run(tx.objectStore(STORE));
tx.oncomplete = () => {
open.result.close();
resolve(request?.result);
};
tx.onerror = () => reject(tx.error);
};
});
}
async function sha256(text) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
function decodeBase64(b64) {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
return bytes;
}
// The editor (~600 KB) is not in the page: it's fetched over ttyd the first time
// the pane opens, checked against the hash this page was built with, cached in
// IndexedDB under that hash, and run.
let editorLoading = null;
export function loadEditor() {
editorLoading ??= (async () => {
if (window.DevDeskEditor) return window.DevDeskEditor;
let code = null;
try {
code = await idb('readonly', (store) => store.get(__EDITOR_SHA__));
} catch {
// No IndexedDB (private mode): fetch every time.
}
if (!code) {
const { base64 } = await runBlob(['--asset', 'editor.js'], 60000);
if (!base64) throw new Error('the editor bundle is missing on the server');
code = new TextDecoder().decode(decodeBase64(base64));
if (crypto.subtle && (await sha256(code)) !== __EDITOR_SHA__) {
throw new Error('the server has a different DevDesk version: reload the page');
}
idb('readwrite', (store) => {
store.clear();
store.put(code, __EDITOR_SHA__);
}).catch(() => {});
}
const script = document.createElement('script');
script.textContent = code;
document.head.append(script);
script.remove();
if (!window.DevDeskEditor) throw new Error('the editor failed to start');
return window.DevDeskEditor;
})().catch((error) => {
editorLoading = null;
throw error;
});
return editorLoading;
}
// ---------------------------------------------------------------- helpers
const size = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`);
const parent = (path) => path.split('/').slice(0, -1).join('/');
const isMarkdown = (path) => /\.(md|markdown|mdx)$/i.test(path);
const STATUS = {
M: ['M', 'modified', 'amber'], A: ['A', 'added', 'green'], D: ['D', 'deleted', 'red'], R: ['R', 'renamed', 'cyan'],
C: ['C', 'copied', 'cyan'], U: ['U', 'conflict', 'red'], '?': ['N', 'new', 'green'], ' ': ['·', 'edited by Claude', 'cyan'],
};
function statusOf(f) {
const code = f.x === '?' ? '?' : f.x !== ' ' ? f.x : f.y;
return STATUS[code] ?? STATUS.M;
}
// Markdown preview document: sanitized HTML in a sandboxed iframe (no scripts,
// no same-origin), styled from the page's current TDS colours.
function previewDocument(html) {
const css = getComputedStyle(document.documentElement);
const v = (name) => css.getPropertyValue(name).trim();
return `<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>
:root { color-scheme: ${document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}; }
body { margin: 0; padding: 18px 22px 40px; background: ${v('--bg-terminal')}; color: ${v('--text-primary')};
font: 14px/1.65 ${v('--font-mono') || 'monospace'}; overflow-wrap: anywhere; }
h1, h2, h3, h4 { color: ${v('--accent-cyan')}; line-height: 1.3; margin: 1.4em 0 0.5em; }
h1 { font-size: 1.45em; border-bottom: 1px solid ${v('--border-color')}; padding-bottom: 0.3em; }
h2 { font-size: 1.2em; border-bottom: 1px solid ${v('--border-dim')}; padding-bottom: 0.2em; }
a { color: ${v('--accent-orange')}; }
code { background: ${v('--bg-tertiary')}; padding: 1px 5px; color: ${v('--accent-amber')}; font-family: inherit; }
pre { background: ${v('--bg-secondary')}; border: 1px solid ${v('--border-dim')}; padding: 10px 12px; overflow-x: auto; }
pre code { background: none; padding: 0; color: ${v('--text-primary')}; }
blockquote { margin: 0.8em 0; padding: 2px 14px; border-left: 3px solid ${v('--accent-cyan')}; color: ${v('--text-secondary')}; }
table { border-collapse: collapse; margin: 0.8em 0; } th, td { border: 1px solid ${v('--border-color')}; padding: 4px 10px; }
th { background: ${v('--bg-secondary')}; } hr { border: 0; border-top: 1px solid ${v('--border-color')}; }
img { max-width: 100%; } li { margin: 0.2em 0; } input[type=checkbox] { margin-right: 6px; }
</style></head><body>${html}</body></html>`;
}
// Relative <img> in a markdown file (e.g. docs/screenshot.png) can't load from
// the sandboxed preview: fetch up to 10 of them over ttyd and inline them.
async function inlineImages(html, session, mdPath) {
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
const images = [...doc.querySelectorAll('img[src]')]
.filter((img) => !/^([a-z][a-z0-9+.-]*:|\/\/|#)/i.test(img.getAttribute('src'))).slice(0, 10);
if (!images.length) return html;
const base = parent(mdPath);
await Promise.all(images.map(async (img) => {
const parts = [];
for (const part of `${base}/${decodeURIComponent(img.getAttribute('src').split(/[?#]/)[0])}`.split('/')) {
if (part === '..') parts.pop();
else if (part && part !== '.') parts.push(part);
}
try {
const { header, base64 } = await runBlob(['--fs-read', session, parts.join('/')]);
if (header.mime?.startsWith('image/') && base64) img.setAttribute('src', `data:${header.mime};base64,${base64}`);
} catch {
// missing image: leave the broken link
}
}));
return doc.body.innerHTML;
}
// ---------------------------------------------------------------- prefs
function readJson(key, fallback) {
try {
const value = JSON.parse(localStorage.getItem(`devdesk.files.${key}`));
return value ?? fallback;
} catch {
return fallback;
}
}
function writeJson(key, value) {
try {
localStorage.setItem(`devdesk.files.${key}`, JSON.stringify(value));
} catch {
// Private mode: the state lasts until the tab closes.
}
}
// ---------------------------------------------------------------- icons
// A two-character badge per file type (VS Code's icon idea, in the terminal style).
const ICONS = [
[/\.(md|markdown|mdx)$/i, 'M↓', 'cyan'],
[/\.(js|mjs|cjs|jsx)$/i, 'JS', 'amber'],
[/\.(ts|tsx|mts)$/i, 'TS', 'blue'],
[/\.(json|jsonc|webmanifest)$/i, '{}', 'amber'],
[/\.(py|pyi)$/i, 'PY', 'blue'],
[/\.(sh|bash|zsh|bats|env)$|^\.env/i, '$_', 'green'],
[/\.(ya?ml)$/i, 'YM', 'purple'],
[/\.(toml|ini|cfg|conf|properties)$/i, '⚙', 'muted'],
[/\.(service|timer|socket|mount)$/i, 'SD', 'muted'],
[/\.(html?|xml|svg|j2|jinja|php)$/i, '<>', 'orange'],
[/\.(css|scss)$/i, '#', 'blue'],
[/\.(png|jpe?g|gif|webp|ico|bmp)$/i, '▣', 'purple'],
[/\.(go)$/i, 'GO', 'cyan'],
[/\.(rs)$/i, 'RS', 'orange'],
[/\.(sql)$/i, 'DB', 'amber'],
[/\.(lock)$|-lock\.json$/i, '≡', 'muted'],
[/^(dockerfile|.*\.dockerfile)$/i, 'DK', 'cyan'],
[/^(makefile|gnumakefile)$/i, 'MK', 'orange'],
[/^(license|copying)(\..*)?$/i, '©', 'muted'],
[/^\.git/i, '±', 'muted'],
[/\.(diff|patch)$/i, '±', 'green'],
[/\.(txt|log)$/i, '≡', 'muted'],
];
function iconFor(name) {
for (const [re, text, tone] of ICONS) if (re.test(name)) return [text, tone];
return ['·', 'muted'];
}
const base = (path) => path.split('/').pop();
// Fuzzy "go to file": every query character in order; consecutive runs, the
// file name, and starts of segments score higher; shorter paths break ties.
export function fuzzyScore(path, query) {
if (!query) return 1;
const p = path.toLowerCase();
const q = query.toLowerCase().replace(/\s+/g, '');
const nameStart = p.lastIndexOf('/') + 1;
let score = 0;
let last = -1;
let run = 0;
for (const ch of q) {
const i = p.indexOf(ch, last + 1);
if (i < 0) return null;
run = i === last + 1 ? run + 1 : 0;
score += 1 + run * 3 + (i >= nameStart ? 4 : 0) + (i === 0 || p[i - 1] === '/' || p[i - 1] === '.' || p[i - 1] === '-' || p[i - 1] === '_' ? 3 : 0);
last = i;
}
if (p.slice(nameStart).includes(q)) score += 20;
return score - path.length / 100;
}
// ---------------------------------------------------------------- pane
export class FilesPane {
// hooks: session() -> name, paste(text), onToggle(open), focusTerminal()
constructor(root, hooks) {
this.root = root;
this.hooks = hooks;
this.session = null;
this.listings = new Map(); // dir -> listing (per session; cleared on switch)
this.loadingDirs = new Set();
this.changes = null;
this.tabs = []; // { path, mode, pinned }
this.active = null;
this.views = new Map(); // tab key -> { node, editor, header, base64, stale }
this.view = 'explorer';
this.expanded = new Set();
this.focusIndex = 0;
this.wrap = readJson('wrap', false);
this.fileList = null; // go-to-file cache { at, files }
this.explorer = root.querySelector('.dd-explorer-body');
this.tabStrip = root.querySelector('.dd-tabs');
this.editorArea = root.querySelector('.dd-editor-area');
this.count = root.querySelector('.dd-files-count');
this.viewButtons = [...root.querySelectorAll('[data-files-view]')];
for (const button of this.viewButtons) button.addEventListener('click', () => this.setView(button.dataset.filesView));
root.querySelector('.dd-files-refresh').addEventListener('click', () => this.refresh());
root.querySelector('.dd-files-close').addEventListener('click', () => this.close());
root.querySelector('.dd-files-find').addEventListener('click', () => this.quickOpen.open());
root.querySelector('.dd-explorer-toggle').addEventListener('click', () => this.setExplorer(!this.explorerShown()));
this.explorer.addEventListener('keydown', (event) => this.treeKey(event));
root.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.code === 'KeyP') {
event.preventDefault();
this.quickOpen.open();
}
});
this.wireExplorerResize();
// Narrow pane (phone, or dragged small): the explorer slides over the editor.
new ResizeObserver(() => root.classList.toggle('is-narrow', root.clientWidth < 680)).observe(root);
this.quickOpen = new CommandPalette(() => this.loadFileList(), {
id: 'dd-goto', placeholder: 'Go to file… (type part of a name)',
search: (files, query) => (query
? files.map((c) => ({ c, s: fuzzyScore(c.id, query) })).filter((x) => x.s !== null).sort((a, b) => b.s - a.s).slice(0, 60).map((x) => x.c)
: [...this.recent().map((p) => files.find((c) => c.id === p)).filter(Boolean), ...files.slice(0, 40)].slice(0, 40)),
});
}
get isOpen() {
return !this.root.hidden;
}
// Drag the explorer's right edge; the width is remembered per browser.
wireExplorerResize() {
const handle = el('div', { class: 'dd-explorer-resizer', role: 'separator', 'aria-orientation': 'vertical', 'aria-label': 'Resize the explorer' });
this.root.querySelector('.dd-explorer').after(handle);
const apply = (px) => {
const width = Math.round(Math.max(160, Math.min(this.root.clientWidth * 0.6, px)));
this.root.style.setProperty('--dd-explorer-w', `${width}px`);
return width;
};
const saved = readJson('explorerWidth', 0);
if (saved) apply(saved);
handle.addEventListener('pointerdown', (event) => {
event.preventDefault();
handle.setPointerCapture(event.pointerId);
document.body.classList.add('dd-resizing');
const left = this.root.querySelector('.dd-explorer').getBoundingClientRect().left;
const move = (e) => apply(e.clientX - left);
const up = () => {
document.body.classList.remove('dd-resizing');
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', up);
writeJson('explorerWidth', this.root.querySelector('.dd-explorer').getBoundingClientRect().width);
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', up);
});
}
// ------------------------------------------------------------ open / close / session
open(view) {
this.root.hidden = false;
this.hooks.onToggle(true);
this.syncSession();
if (view) this.setView(view === 'files' ? 'explorer' : view);
else this.render();
this.revalidate();
loadEditor().catch(() => {}); // warm up
}
close() {
this.root.hidden = true;
this.hooks.onToggle(false);
this.hooks.focusTerminal();
}
toggle(view) {
if (this.isOpen && (!view || (view === 'changes') === (this.view === 'changes'))) this.close();
else this.open(view);
}
escape() {
if (this.root.classList.contains('is-narrow') && this.explorerShown() && this.active) this.setExplorer(false);
else this.close();
}
stateKey() {
return `state.${this.session}`;
}
saveState() {
if (!this.session) return;
writeJson(this.stateKey(), {
expanded: [...this.expanded], view: this.view, active: this.active,
tabs: this.tabs.filter((t) => t.pinned || t.key === this.active).map(({ path, mode }) => ({ path, mode })),
});
}
syncSession() {
const session = this.hooks.session();
if (session === this.session) return false;
for (const v of this.views.values()) v.editor?.destroy();
this.views.clear();
this.listings.clear();
this.changes = null;
this.fileList = null;
this.session = session;
const saved = session ? readJson(this.stateKey(), {}) : {};
this.expanded = new Set(saved.expanded ?? []);
this.view = saved.view ?? 'explorer';
this.tabs = (saved.tabs ?? []).map((t) => ({ ...t, key: `${t.mode}:${t.path}`, pinned: true }));
this.active = this.tabs.some((t) => t.key === saved.active) ? saved.active : (this.tabs[0]?.key ?? null);
return true;
}
sessionChanged() {
if (this.isOpen && this.syncSession()) {
this.render();
this.revalidate();
}
}
// ------------------------------------------------------------ data
async listDir(dir) {
if (this.loadingDirs.has(dir)) return;
const session = this.session;
this.loadingDirs.add(dir);
try {
const listing = parseListOutput(await runCommand(['--fs-list', session, dir || '.'], 15000));
if (session !== this.session) return;
this.listings.set(dir, listing);
} catch (error) {
if (session !== this.session) return;
if (dir) {
this.expanded.delete(dir);
toast.error(`${dir}: ${error.message}`);
} else {
this.listings.set('', { error: error.message, entries: [] });
}
} finally {
this.loadingDirs.delete(dir);
}
if (session === this.session) this.renderExplorer();
}
async loadChanges() {
const session = this.session;
try {
const changes = parseListOutput(await runCommand(['--git-status', session], 15000));
if (session !== this.session) return;
this.changes = changes;
} catch {
return;
}
const n = this.changes.files?.length ?? 0;
this.count.textContent = n ? String(n) : '';
this.renderExplorer();
this.renderTabs();
if (!this.active) this.renderEditor();
}
async loadFileList() {
if (this.fileList && Date.now() - this.fileList.at < 60000 && this.fileList.session === this.session) return this.fileList.items;
const list = parseListOutput(await runCommand(['--fs-find', this.session], 45000));
const items = list.files.map((path) => {
const name = base(path);
const [icon] = iconFor(name);
return { id: path, label: name, hint: path.includes('/') ? path.slice(0, path.length - name.length - 1) : '', icon, group: 'Files', run: () => this.openFile(path, { pin: true }) };
});
this.fileList = { at: Date.now(), session: this.session, items };
if (list.truncated) toast.info(`Go to file searches the first ${list.files.length} files`);
return items;
}
// Refresh what's visible: the tree's open folders, git status, the active file.
revalidate() {
if (!this.session) return;
this.listDir('');
for (const dir of this.expanded) this.listDir(dir);
this.loadChanges();
}
refresh() {
this.fileList = null;
this.revalidate();
if (this.active) this.reloadTab(this.active, { quiet: false });
}
async claudeTurnEnded() {
if (!this.isOpen) {
// Next time it opens it revalidates anyway; open tabs re-check on activation.
for (const v of this.views.values()) v.stale = true;
return;
}
this.fileList = null;
this.revalidate();
for (const [key, v] of this.views) if (key !== this.active) v.stale = true;
if (this.active) this.reloadTab(this.active, { quiet: true });
}
changeFor(path) {
return this.changes?.files?.find((f) => f.path === path) ?? null;
}
recent() {
return readJson(`recent.${this.session}`, []);
}
pushRecent(path) {
writeJson(`recent.${this.session}`, [path, ...this.recent().filter((p) => p !== path)].slice(0, 8));
}
// ------------------------------------------------------------ layout
explorerShown() {
return !this.root.classList.contains('explorer-hidden');
}
setExplorer(show) {
this.root.classList.toggle('explorer-hidden', !show);
if (show) this.focusTree();
}
setView(view) {
this.view = view;
this.saveState();
this.setExplorer(true);
this.render();
if (view === 'changes') this.loadChanges();
}
render() {
for (const b of this.viewButtons) b.setAttribute('aria-selected', String(b.dataset.filesView === this.view));
this.renderExplorer();
this.renderTabs();
this.renderEditor();
}
// ------------------------------------------------------------ explorer
// Visible rows: the root listing plus every expanded folder's children.
rows() {
const out = [];
const walk = (dir, depth) => {
const listing = this.listings.get(dir);
if (!listing) {
out.push({ kind: 'loading', depth, dir });
return;
}
for (const e of listing.entries) {
const path = dir ? `${dir}/${e.n}` : e.n;
const isDir = e.t === 'd' || e.to === 'd';
out.push({ kind: isDir ? 'dir' : 'file', depth, path, entry: e });
if (isDir && this.expanded.has(path)) walk(path, depth + 1);
}
if (listing.truncated) out.push({ kind: 'note', depth, text: `first ${listing.entries.length} entries only` });
};
walk('', 0);
return out;
}
renderExplorer() {
if (!this.session) {
this.explorer.replaceChildren(el('div', { class: 'dd-files-msg' }, 'Open a session to browse its files.'));
return;
}
if (this.view === 'changes') {
this.renderChangesList();
return;
}
const root = this.listings.get('');
if (root?.error) {
this.explorer.replaceChildren(el('div', { class: 'dd-files-msg is-error' }, root.error));
return;
}
const activePath = this.tabs.find((t) => t.key === this.active)?.path;
const changed = new Map((this.changes?.files ?? []).filter((f) => f.path).map((f) => [f.path, f]));
this.treeRows = this.rows();
this.focusIndex = Math.min(this.focusIndex, Math.max(0, this.treeRows.length - 1));
const nodes = this.treeRows.map((row, i) => {
const indent = { style: `padding-left:${8 + row.depth * 14}px` };
if (row.kind === 'loading') return el('div', { class: 'dd-tree-row is-note', ...indent }, 'Loading…');
if (row.kind === 'note') return el('div', { class: 'dd-tree-row is-note', ...indent }, row.text);
const { entry, path } = row;
const isDir = row.kind === 'dir';
const open = isDir && this.expanded.has(path);
const change = isDir ? null : changed.get(path);
const dirty = isDir && [...changed.keys()].some((p) => p.startsWith(`${path}/`));
const [icon, tone] = isDir ? [open ? '▾' : '▸', 'dir'] : iconFor(entry.n);
const status = change ? statusOf(change) : null;
return el('div', {
class: `dd-tree-row${isDir ? ' is-dir' : ''}${entry.ig ? ' is-ignored' : ''}${path === activePath ? ' is-active' : ''}${status ? ` git-${status[2]}` : ''}${dirty ? ' git-dirty' : ''}`,
role: 'treeitem', 'aria-expanded': isDir ? String(open) : null, 'aria-level': String(row.depth + 1),
tabindex: i === this.focusIndex ? '0' : '-1', 'data-index': String(i),
title: `${path}${entry.t === 'l' ? ' (symlink)' : ''}${status ? ` · ${status[1]}` : ''}${change?.claude ? ' · edited by Claude' : ''}`,
...indent,
onclick: () => {
this.focusIndex = i;
if (isDir) this.toggleDir(path);
else this.openFile(path);
},
ondblclick: () => {
if (!isDir) this.pin(path);
},
},
el('span', { class: `dd-ficon tone-${tone}`, 'aria-hidden': 'true' }, icon),
el('span', { class: 'dd-tree-name' }, entry.n),
change?.claude ? el('span', { class: 'dd-claude-chip', title: 'Claude edited this file' }, 'C') : null,
status ? el('span', { class: `dd-file-status is-${status[2]}` }, status[0]) : null,
dirty ? el('span', { class: 'dd-dirty-dot', title: 'contains changes' }) : null);
});
if (!nodes.length) nodes.push(el('div', { class: 'dd-files-msg' }, 'Empty folder.'));
const hadFocus = this.explorer.contains(document.activeElement);
this.explorer.setAttribute('role', 'tree');
this.explorer.replaceChildren(...nodes);
if (hadFocus) this.focusTree();
}
toggleDir(path, open = !this.expanded.has(path)) {
if (open) {
this.expanded.add(path);
if (!this.listings.has(path)) this.listDir(path);
else this.listDir(path); // revalidate in the background, show the cached one now
} else {
this.expanded.delete(path);
}
this.saveState();
this.renderExplorer();
}
focusTree() {
const row = this.explorer.querySelector(`[data-index="${this.focusIndex}"]`) ?? this.explorer.querySelector('[data-index]');
row?.focus();
}
// ↑/↓ move, → open folder / step in, ← close folder / step out, Enter/Space open.
treeKey(event) {
if (this.view !== 'explorer' || !this.treeRows?.length) return;
const row = this.treeRows[this.focusIndex];
const move = (i) => {
this.focusIndex = Math.max(0, Math.min(this.treeRows.length - 1, i));
for (const node of this.explorer.querySelectorAll('[data-index]')) node.tabIndex = Number(node.dataset.index) === this.focusIndex ? 0 : -1;
this.focusTree();
};
switch (event.key) {
case 'ArrowDown': move(this.focusIndex + 1); break;
case 'ArrowUp': move(this.focusIndex - 1); break;
case 'Home': move(0); break;
case 'End': move(this.treeRows.length - 1); break;
case 'ArrowRight':
if (row?.kind === 'dir') {
if (!this.expanded.has(row.path)) this.toggleDir(row.path, true);
else move(this.focusIndex + 1);
}
break;
case 'ArrowLeft':
if (row?.kind === 'dir' && this.expanded.has(row.path)) this.toggleDir(row.path, false);
else if (row?.path?.includes('/')) {
const up = row.path.split('/').slice(0, -1).join('/');
move(this.treeRows.findIndex((r) => r.path === up));
}
break;
case 'Enter':
case ' ':
if (row?.kind === 'dir') this.toggleDir(row.path);
else if (row?.kind === 'file') this.openFile(row.path, { pin: event.key === 'Enter' });
break;
default:
return;
}
event.preventDefault();
}
// Expand the folders above a file and scroll it into view.
reveal(path) {
const parts = path.split('/');
let changed = false;
for (let i = 1; i < parts.length; i += 1) {
const dir = parts.slice(0, i).join('/');
if (!this.expanded.has(dir)) {
this.expanded.add(dir);
changed = true;
if (!this.listings.has(dir)) this.listDir(dir);
}
}
if (changed) this.saveState();
if (this.view !== 'explorer') this.setView('explorer');
else this.renderExplorer();
this.explorer.querySelector('.is-active')?.scrollIntoView({ block: 'nearest' });
}
renderChangesList() {
if (!this.changes) {
this.explorer.replaceChildren(el('div', { class: 'dd-files-msg' }, 'Loading…'));
return;
}
const { files = [], git, branch } = this.changes;
const head = el('div', { class: 'dd-changes-head' }, git ? `${branch} · ${files.length} uncommitted` : 'Not a git repository · files Claude edited');
if (!files.length) {
this.explorer.replaceChildren(head, el('div', { class: 'dd-files-msg' }, git ? 'No uncommitted changes.' : 'Claude hasn\'t edited files here yet.'));
return;
}
const activePath = this.tabs.find((t) => t.key === this.active)?.path;
const rows = files.map((f) => {
const [letter, label, tone] = statusOf(f);
const path = f.path ?? f.repoPath;
const name = base(path);
const dir = path.slice(0, path.length - name.length).replace(/\/$/, '');
const openable = f.path !== null && f.path !== undefined;
const canDiff = git && f.x !== '?' && f.y !== '?';
return el('div', {
class: `dd-tree-row dd-change-row git-${tone}${path === activePath ? ' is-active' : ''}${openable ? '' : ' is-disabled'}`,
tabindex: '0', role: 'button',
title: `${path} · ${label}${f.from ? ` from ${f.from}` : ''}${f.claude ? ' · edited by Claude' : ''}${openable ? '' : ' (outside this folder)'}`,
onclick: () => openable && this.openFile(f.path, { mode: canDiff ? 'diff' : undefined }),
onkeydown: (event) => {
if (event.key === 'Enter' && openable) this.openFile(f.path, { mode: canDiff ? 'diff' : undefined, pin: true });
},
},
el('span', { class: `dd-ficon tone-${iconFor(name)[1]}`, 'aria-hidden': 'true' }, iconFor(name)[0]),
el('span', { class: 'dd-tree-name' }, name, dir ? el('span', { class: 'dd-change-dir' }, ` ${dir}`) : null),
f.claude ? el('span', { class: 'dd-claude-chip', title: 'Claude edited this file' }, 'C') : null,
f.add !== null && f.add !== undefined ? el('span', { class: 'dd-add' }, `+${f.add}`) : null,
f.del !== null && f.del !== undefined ? el('span', { class: 'dd-del' }, `−${f.del}`) : null,
el('span', { class: `dd-file-status is-${tone}` }, letter));
});
this.explorer.setAttribute('role', 'list');
this.explorer.replaceChildren(head, ...rows);
}
// ------------------------------------------------------------ tabs
// Single click opens a preview tab (italic) that the next click replaces;
// double click, Enter or go-to-file keeps it.
openFile(path, { mode, pin = false } = {}) {
const wanted = mode ?? (isMarkdown(path) ? 'preview' : 'view');
const key = `${wanted}:${path}`;
let tab = this.tabs.find((t) => t.key === key) ?? this.tabs.find((t) => t.path === path && !mode);
if (!tab) {
const preview = this.tabs.findIndex((t) => !t.pinned);
tab = { key, path, mode: wanted, pinned: pin };
if (preview >= 0 && !pin) {
this.dropView(this.tabs[preview].key);
this.tabs[preview] = tab;
} else {
this.tabs.push(tab);
while (this.tabs.length > 12) this.closeTab(this.tabs.find((t) => t.key !== key).key, { render: false });
}
} else if (pin) {
tab.pinned = true;
}
this.pushRecent(path);
this.activate(tab.key);
if (this.root.classList.contains('is-narrow')) this.setExplorer(false);
}
pin(path) {
const tab = this.tabs.find((t) => t.path === path);
if (tab) {
tab.pinned = true;
this.saveState();
this.renderTabs();
}
}
activate(key) {
this.active = key;
this.saveState();
this.renderTabs();
this.renderEditor();
this.renderExplorer();
}
closeTab(key, { render = true } = {}) {
const i = this.tabs.findIndex((t) => t.key === key);
if (i < 0) return;
this.tabs.splice(i, 1);
this.dropView(key);
if (this.active === key) this.active = (this.tabs[i] ?? this.tabs[i - 1])?.key ?? null;
this.saveState();
if (render) {
this.renderTabs();
this.renderEditor();
this.renderExplorer();
}
}
dropView(key) {
this.views.get(key)?.editor?.destroy();
this.views.get(key)?.node.remove();
this.views.delete(key);
}
renderTabs() {
const nodes = this.tabs.map((tab) => {
const name = base(tab.path);
const change = this.changeFor(tab.path);
const status = change ? statusOf(change) : null;
return el('div', {
class: `dd-tab${tab.key === this.active ? ' is-active' : ''}${tab.pinned ? '' : ' is-preview'}${status ? ` git-${status[2]}` : ''}`,
role: 'tab', 'aria-selected': String(tab.key === this.active), tabindex: tab.key === this.active ? '0' : '-1',
title: `${tab.path}${tab.mode === 'diff' ? ' (diff against HEAD)' : ''}${tab.pinned ? '' : ' · preview: the next file replaces it; double-click to keep'}`,
onclick: () => this.activate(tab.key),
ondblclick: () => this.pin(tab.path),
onauxclick: (event) => {
if (event.button === 1) this.closeTab(tab.key);
},
onkeydown: (event) => {
if (event.key === 'Enter') this.activate(tab.key);
},
},
el('span', { class: `dd-ficon tone-${iconFor(name)[1]}`, 'aria-hidden': 'true' }, iconFor(name)[0]),
el('span', { class: 'dd-tab-name' }, name),
tab.mode === 'diff' ? el('span', { class: 'dd-tab-tag' }, 'diff') : null,
el('button', {
type: 'button', class: 'dd-tab-close', 'aria-label': `Close ${name}`, title: 'Close',
onclick: (event) => {
event.stopPropagation();
this.closeTab(tab.key);
},
}, '×'));
});
this.tabStrip.replaceChildren(...nodes);
this.tabStrip.querySelector('.is-active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
// ------------------------------------------------------------ editor
renderEditor() {
for (const [key, v] of this.views) v.node.hidden = key !== this.active;
let empty = this.editorArea.querySelector('.dd-editor-empty');
if (!this.active) {
if (!empty) {
empty = el('div', { class: 'dd-editor-empty' });
this.editorArea.append(empty);
}
empty.hidden = false;
this.renderEmpty(empty);
return;
}
if (empty) empty.hidden = true;
const view = this.views.get(this.active);
if (!view) this.loadTab(this.active);
else if (view.stale) this.reloadTab(this.active, { quiet: true });
}
renderEmpty(node) {
const recent = this.recent();
const n = this.changes?.files?.length ?? 0;
node.replaceChildren(...[
el('div', { class: 'dd-empty-title' }, this.session ? `No file open in ${this.session}` : 'No session'),
el('div', { class: 'dd-empty-actions' },
el('button', { type: 'button', class: 'dd-viewer-btn', onclick: () => this.quickOpen.open() }, 'Go to file… Ctrl+P'),
n ? el('button', { type: 'button', class: 'dd-viewer-btn', onclick: () => this.setView('changes') }, `Review ${n} change${n === 1 ? '' : 's'}`) : null),
recent.length ? el('div', { class: 'dd-empty-recent' },
el('div', { class: 'dd-empty-label' }, 'Recent'),
...recent.map((path) => el('button', { type: 'button', class: 'dd-recent', title: path, onclick: () => this.openFile(path, { pin: true }) },
el('span', { class: `dd-ficon tone-${iconFor(base(path))[1]}` }, iconFor(base(path))[0]),
el('span', { class: 'dd-recent-name' }, base(path)),
el('span', { class: 'dd-change-dir' }, path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '')))) : null,
el('div', { class: 'dd-empty-hint' }, 'Click a file for a preview tab, double-click to keep it open. Esc returns to the terminal.'),
].filter(Boolean));
}
async fetchTab(tab) {
const change = this.changeFor(tab.path);
const deleted = change && (change.x === 'D' || change.y === 'D');
const [blob, original] = await Promise.all([
deleted ? Promise.resolve({ header: { path: tab.path, size: 0, utf8: true, body: true }, base64: '' }) : runBlob(['--fs-read', this.session, tab.path]),
tab.mode === 'diff' ? runBlob(['--git-show', this.session, tab.path]) : Promise.resolve(null),
]);
return { blob, original };
}
async loadTab(key) {
const tab = this.tabs.find((t) => t.key === key);
if (!tab) return;
const node = el('div', { class: 'dd-tab-view' }, el('div', { class: 'dd-files-msg' }, 'Loading…'));
this.editorArea.append(node);
const view = { node, editor: null, header: null, stale: false };
this.views.set(key, view);
node.hidden = key !== this.active;
const session = this.session;
try {
const [{ blob, original }, editor] = await Promise.all([this.fetchTab(tab), loadEditor().catch((error) => ({ error }))]);
if (session !== this.session || this.views.get(key) !== view) return;
this.fill(view, tab, blob, original, editor);
} catch (error) {
if (this.views.get(key) !== view) return;
node.replaceChildren(this.bar(tab, null), el('div', { class: 'dd-files-msg is-error' }, error.message));
}
}
// Claude changed files: re-read; if the content differs, swap it in place
// and keep the scroll position (read-only, so nothing of yours is lost).
async reloadTab(key, { quiet }) {
const tab = this.tabs.find((t) => t.key === key);
const view = this.views.get(key);
if (!tab || !view) {
if (tab) this.loadTab(key);
return;
}
view.stale = false;
try {
const session = this.session;
const [{ blob, original }, editor] = await Promise.all([this.fetchTab(tab), loadEditor().catch((error) => ({ error }))]);
if (session !== this.session || this.views.get(key) !== view) return;
if (blob.header.sha && blob.header.sha === view.header?.sha && tab.mode !== 'diff') return;
const scroller = view.node.querySelector('.cm-scroller');
const top = scroller?.scrollTop ?? 0;
view.editor?.destroy();
this.fill(view, tab, blob, original, editor);
const next = view.node.querySelector('.cm-scroller');
if (next) next.scrollTop = top;
if (quiet && view.header?.sha !== blob.header.sha) view.node.querySelector('.dd-viewer-meta')?.classList.add('is-flash');
} catch {
// gone or unreadable: keep what's shown
}
}
bar(tab, header) {
const change = this.changeFor(tab.path);
const meta = header ? [
size(header.size ?? 0),
header.mtime ? `changed ${ago(header.mtime)}` : '',
header.utf8 === false ? 'Latin-1' : '',
tab.mode === 'diff' ? 'diff against HEAD' : '',
change ? statusOf(change)[1] : '',
change?.claude ? 'edited by Claude' : '',
].filter(Boolean).join(' · ') : '';
const button = (label, run, extra = {}) => el('button', { type: 'button', class: 'dd-viewer-btn', onclick: run, ...extra }, label);
const switchMode = (mode) => this.openFile(tab.path, { mode, pin: true });
const actions = [];
if (tab.mode !== 'diff' && isMarkdown(tab.path)) actions.push(button(tab.mode === 'preview' ? 'Source' : 'Preview', () => switchMode(tab.mode === 'preview' ? 'view' : 'preview')));
if (change && change.x !== '?' && change.y !== '?' && this.changes?.git) {
actions.push(button(tab.mode === 'diff' ? 'File' : 'Diff', () => switchMode(tab.mode === 'diff' ? (isMarkdown(tab.path) ? 'preview' : 'view') : 'diff')));
}
if (tab.mode !== 'preview') {
actions.push(button(this.wrap ? 'No wrap' : 'Wrap', () => {
this.wrap = !this.wrap;
writeJson('wrap', this.wrap);
for (const key of [...this.views.keys()]) this.dropView(key);
this.renderEditor();
}, { title: 'Wrap long lines' }));
}
actions.push(button('Reveal', () => this.reveal(tab.path), { title: 'Show in the explorer' }));
actions.push(button('Copy', () => navigator.clipboard?.writeText(tab.path).then(() => toast.success('Path copied'), () => toast.error('Clipboard refused')), { title: 'Copy the path' }));
actions.push(button('@Claude', () => {
this.hooks.paste(`@${tab.path} `);
toast.info(`Mentioned @${tab.path} in the prompt`);
}, { title: 'Type @path into the current prompt so Claude reads the file' }));
return el('div', { class: 'dd-viewer-bar' },
el('span', { class: 'dd-viewer-where', title: [tab.path, meta].filter(Boolean).join('\n') },
el('span', { class: 'dd-viewer-path' }, tab.path),
meta ? el('span', { class: 'dd-viewer-meta' }, ` · ${meta}`) : null),
el('span', { class: 'dd-viewer-actions' }, ...actions));
}
fill(view, tab, blob, original, editor) {
const { header, base64 } = blob;
view.header = header;
const text = (b64, utf8 = true) => new TextDecoder(utf8 ? 'utf-8' : 'latin1').decode(decodeBase64(b64 || ''));
const content = el('div', { class: 'dd-viewer-content' });
if (header.tooLarge) {
content.append(el('div', { class: 'dd-files-msg' }, `This file is ${size(header.size)}; the viewer opens files up to ${size(header.max)}.`));
} else if (header.mime?.startsWith('image/') && base64) {
content.classList.add('is-image');
content.append(el('img', { src: `data:${header.mime};base64,${base64}`, alt: tab.path }));
} else if (header.binary || !header.body) {
content.append(el('div', { class: 'dd-files-msg' }, `Binary file (${size(header.size)}).`));
} else if (editor?.error) {
content.append(el('pre', { class: 'dd-plain' }, text(base64, header.utf8)));
} else if (tab.mode === 'preview') {
content.classList.add('is-preview');
const frame = el('iframe', {
class: 'dd-md-frame', title: `Preview of ${tab.path}`,
sandbox: 'allow-popups allow-popups-to-escape-sandbox', srcdoc: previewDocument(editor.markdown(text(base64))),
});
content.append(frame);
inlineImages(editor.markdown(text(base64)), this.session, tab.path).then((html) => {
if (frame.isConnected) frame.srcdoc = previewDocument(html);
});
} else if (tab.mode === 'diff') {
view.editor = editor.diff(content, { original: text(original?.base64), modified: text(base64), path: tab.path, wrap: this.wrap });
} else {
view.editor = editor.viewer(content, { doc: text(base64, header.utf8), path: tab.path, wrap: this.wrap });
}
view.node.replaceChildren(this.bar(tab, header), content);
}
// Theme switch: the editor follows CSS variables; previews need a re-render.
themeChanged() {
for (const tab of this.tabs) {
if (tab.mode === 'preview') this.dropView(tab.key);
}
if (this.isOpen) this.renderEditor();
}
}
+172
View File
@@ -0,0 +1,172 @@
// The bell (house .lt-notif-* pattern): what needs you right now (waiting
// sessions, firing Grafana alerts) plus recent events you may have missed
// (a background session finished or started waiting, CI passed or failed).
// Events live in localStorage, so every tab shows the same inbox.
import { el } from './dom.js';
import { ago } from './sessions.js';
const KEY = 'devdesk.inbox';
const MAX = 30;
function load() {
try {
const list = JSON.parse(localStorage.getItem(KEY) || '[]');
return Array.isArray(list) ? list : [];
} catch {
return [];
}
}
function save(events) {
try {
localStorage.setItem(KEY, JSON.stringify(events.slice(0, MAX)));
} catch {
// Private mode: the inbox lasts as long as the tab.
}
}
const when = (ms) => {
const a = ago(Math.floor(ms / 1000));
return a === 'now' ? 'just now' : `${a} ago`;
};
const KINDS = {
waiting: { icon: '!', tone: 'red' },
finished: { icon: '✓', tone: 'green' },
'ci-pass': { icon: '✓', tone: 'green' },
'ci-fail': { icon: '✗', tone: 'red' },
};
export class Inbox {
// hooks: openSession(name), showAlerts()
constructor({ button, badge, panel, hooks }) {
Object.assign(this, { button, badge, panel, hooks });
this.events = load();
this.waiting = [];
this.alerts = [];
this.list = panel.querySelector('.lt-notif-panel-list');
panel.querySelector('.lt-notif-panel-clear').addEventListener('click', () => this.markAllRead());
window.addEventListener('storage', (event) => {
if (event.key === KEY) {
this.events = load();
this.render();
}
});
}
get isOpen() {
return !this.panel.hasAttribute('aria-hidden');
}
setOpen(open) {
if (open) this.panel.removeAttribute('aria-hidden');
else this.panel.setAttribute('aria-hidden', 'true');
this.button.setAttribute('aria-expanded', String(open));
if (open) this.render();
}
// Something happened. The same kind for the same session within a minute is
// one event (several tabs, quick flaps).
record({ kind, session, text, url }) {
const now = Date.now();
if (this.events.some((e) => e.kind === kind && e.session === session && now - e.at < 60000)) return;
this.events.unshift({ id: `${now}-${Math.random().toString(36).slice(2, 7)}`, kind, session, text, url, at: now, read: false });
this.events = this.events.slice(0, MAX);
save(this.events);
this.render();
}
// Live state: other sessions waiting on you, firing alerts.
setLive({ waiting, alerts }) {
if (waiting) this.waiting = waiting;
if (alerts) this.alerts = alerts;
this.render();
}
markAllRead() {
this.events = this.events.map((e) => ({ ...e, read: true }));
save(this.events);
this.render();
}
unread() {
return this.events.filter((e) => !e.read && e.kind !== 'waiting').length;
}
render() {
const count = this.waiting.length + this.alerts.length + this.unread();
const urgent = this.waiting.length > 0 || this.alerts.some((a) => a.severity === 'critical');
this.badge.textContent = count ? String(count) : '';
this.badge.dataset.count = String(count);
this.badge.classList.toggle('is-calm', count > 0 && !urgent);
this.button.title = count
? [
this.waiting.length && `${this.waiting.length} session(s) need you`,
this.alerts.length && `${this.alerts.length} alert(s) firing`,
this.unread() && `${this.unread()} new event(s)`,
].filter(Boolean).join(' · ')
: 'Inbox: nothing new';
if (this.isOpen) this.renderPanel();
}
renderPanel() {
const close = (run) => () => {
this.setOpen(false);
run();
};
const item = ({ dot, title, meta, unread, onclick }) => el('div', {
class: `lt-notif-item${unread ? ' lt-notif-item--unread' : ''}`, role: 'button', tabindex: '0', onclick,
onkeydown: (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onclick();
}
},
},
el('span', { class: `lt-notif-dot dd-notif-${dot}` }),
el('div', { class: 'lt-notif-item-body' },
el('div', { class: 'lt-notif-item-title' }, title),
meta ? el('div', { class: 'lt-notif-item-time' }, meta) : null));
const nodes = [];
if (this.waiting.length) {
nodes.push(el('div', { class: 'dd-notif-section' }, 'Needs you'));
for (const s of this.waiting) {
nodes.push(item({
dot: 'red', unread: true, title: `${s.name}: ${s.status?.message || 'Claude is waiting for your input'}`,
meta: s.status?.since ? `waiting ${ago(s.status.since)}` : 'waiting',
onclick: close(() => this.hooks.openSession(s.name)),
}));
}
}
if (this.alerts.length) {
nodes.push(el('div', { class: 'dd-notif-section' }, 'Ops alerts'));
for (const a of this.alerts.slice(0, 5)) {
nodes.push(item({
dot: a.severity === 'critical' ? 'red' : 'amber', title: `${a.severity ? `${a.severity}: ` : ''}${a.name}`,
meta: [a.state, a.summary].filter(Boolean).join(' · '), onclick: close(() => this.hooks.showAlerts()),
}));
}
if (this.alerts.length > 5) nodes.push(el('div', { class: 'dd-notif-more' }, `+${this.alerts.length - 5} more in the Ops Alerts panel`));
}
const recent = this.events.filter((e) => e.kind !== 'waiting' || !this.waiting.some((s) => s.name === e.session));
if (recent.length) {
nodes.push(el('div', { class: 'dd-notif-section' }, 'Recent'));
for (const e of recent.slice(0, 12)) {
const kind = KINDS[e.kind] ?? { tone: 'cyan' };
nodes.push(item({
dot: e.read ? 'read' : kind.tone, unread: !e.read, title: e.text, meta: when(e.at),
onclick: close(() => {
this.events = this.events.map((x) => (x.id === e.id ? { ...x, read: true } : x));
save(this.events);
if (e.url) window.open(e.url, '_blank', 'noopener');
else if (e.session) this.hooks.openSession(e.session);
}),
}));
}
}
if (!nodes.length) nodes.push(el('div', { class: 'dd-notif-empty' }, 'Nothing needs you. Sessions that finish or wait while you\'re elsewhere show up here.'));
this.list.replaceChildren(...nodes);
}
}
+723 -68
View File
@@ -1,10 +1,19 @@
/* global __DEVDESK_VERSION__ */
import { createTerminal, loadFonts } from './term.js';
import { TtydConnection, fetchToken, AuthExpiredError, UnauthorizedError } from './connection.js';
import { parseTitle, sessionFromSearch } from './protocol.js';
import { SessionsPanel, rememberSession } from './sessions.js';
import { renderContext } from './context.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, 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 { Inbox } from './inbox.js';
import { FilesPane } from './files.js';
import { el } from './dom.js';
import * as notify from './notify.js';
const $ = (id) => document.getElementById(id);
@@ -31,19 +40,26 @@ const ui = {
body: document.body,
wsStatus: $('ws-status'),
wsLabel: $('ws-label'),
sso: $('sso-badge'),
reauth: $('reauth'),
sessionChip: $('session-chip'),
sessionName: $('session-name'),
switcher: $('switcher'),
attention: $('attention-chip'),
paneTitle: $('pane-title'),
overlay: $('overlay'),
overlayTitle: $('overlay-title'),
overlayText: $('overlay-text'),
overlayActions: $('overlay-actions'),
moreToggle: $('more-toggle'),
moreMenu: $('more-menu'),
notifyButton: $('notify-toggle'),
sidebarButton: $('sidebar-toggle'),
backdrop: $('backdrop'),
};
const urlSession = sessionFromSearch(window.location.search);
const state = {
session: sessionFromSearch(window.location.search), // null = picker
session: urlSession, // null = picker
conn: null,
attempts: 0,
retryTimer: null,
@@ -52,23 +68,26 @@ const state = {
lastActivity: Date.now(),
};
const touch = window.matchMedia('(pointer: coarse)').matches;
const isNarrow = () => !window.matchMedia('(min-width: 1100px)').matches;
let term;
let fit;
let sessionsPanel;
let palette;
let inbox;
let files;
let lastRepo = null;
let lastAlerts = null;
let waitingOthers = [];
let paneTitleRaw = '';
// ---------------------------------------------------------------- status UI
function setWsState(stateName, label) {
ui.wsStatus.dataset.state = stateName;
ui.wsLabel.textContent = label;
}
function setSso(stateName) {
ui.sso.dataset.state = stateName;
ui.sso.textContent = stateName === 'expired' ? 'SSO expired' : 'SSO';
ui.sso.title = stateName === 'expired'
? 'Authelia session expired. Click to sign in again.'
: 'Signed in through Authelia';
ui.wsStatus.title = label;
}
function showOverlay({ title, text, actions = [], tone = 'info' }) {
@@ -90,10 +109,201 @@ function hideOverlay() {
ui.overlay.hidden = true;
}
function showContext() {
// Pane title (Claude's own title) only when it says more than the session name.
function renderPaneTitle() {
const bare = paneTitleRaw.replace(/^[^\p{L}\p{N}]+/u, '').trim();
ui.paneTitle.textContent = bare && bare !== state.session ? paneTitleRaw : '';
}
function renderSessionChip() {
const data = sessionsPanel?.data;
const current = data?.sessions?.find((s) => s.name === state.session);
renderContext($('widget-context'), state.session, current?.context ?? null);
const status = current ? sessionStatus(current) : null;
ui.sessionName.textContent = state.session || 'picker';
ui.sessionChip.dataset.state = status?.key ?? '';
ui.sessionChip.title = [
state.session ? `Session ${state.session}${status ? ` · ${status.label}` : ''}` : 'Session picker',
current?.status?.message,
current?.context?.used_pct != null ? `context ${Math.floor(current.context.used_pct)}%` : '',
'Click to switch session',
].filter(Boolean).join('\n');
}
// Other sessions waiting on you: header chip, a notification when one starts
// waiting (devdesk#15), and the Panels badge. Only the tab that fetched the list
// notifies, so several open tabs don't ring once each.
let prevWaiting = new Set();
const prevStatus = new Map();
function renderAttention(fetched) {
const data = sessionsPanel?.data;
waitingOthers = (data?.sessions ?? []).filter((s) => s.name !== state.session && sessionStatus(s).key === 'waiting');
for (const s of waitingOthers) {
if (fetched && !prevWaiting.has(s.name)) notify.ring(s.name, s.status?.message || 'Claude needs your input');
}
prevWaiting = new Set(waitingOthers.map((s) => s.name));
if (fetched) recordTransitions(data?.sessions ?? []);
ui.attention.classList.toggle('is-active', waitingOthers.length > 0);
ui.attention.textContent = waitingOthers.length === 1 ? `${waitingOthers[0].name} needs you` : `${waitingOthers.length} need you`;
ui.attention.title = waitingOthers.map((s) => `${s.name}: ${s.status?.message || 'waiting'}`).join('\n');
ui.attention.dataset.target = waitingOthers[0]?.name || '';
renderPanelsBadge();
}
// Inbox events for what happened while you were elsewhere: a session (other than
// the one you're looking at) finished a turn or started waiting. Only the tab
// that fetched the list records them; the inbox is shared via localStorage.
function recordTransitions(sessions) {
for (const s of sessions) {
const key = sessionStatus(s).key;
const prev = prevStatus.get(s.name);
prevStatus.set(s.name, key);
if (!prev || prev === key) continue;
const away = s.name !== state.session || document.hidden;
if (!away) continue;
if (key === 'waiting') {
inbox.record({ kind: 'waiting', session: s.name, text: `${s.name} needs you: ${s.status?.message || 'Claude is waiting for your input'}` });
} else if (prev === 'working' && key === 'idle') {
inbox.record({ kind: 'finished', session: s.name, text: `${s.name} finished` });
}
}
}
function renderPanelsBadge() {
inbox?.setLive({ waiting: waitingOthers, alerts: lastAlerts?.alerts ?? [] });
}
function onSessionData({ fetched = false } = {}) {
renderSessionChip();
renderAttention(fetched);
if (!ui.switcher.hidden) renderSwitcher();
}
function showAlerts(data) {
lastAlerts = data;
renderAlerts($('widget-alerts'), data);
renderPanelsBadge();
}
let alertsBusy = false;
async function refreshAlerts() {
if (alertsBusy) return;
alertsBusy = true;
try {
showAlerts(await fetchAlerts());
share('alerts', lastAlerts);
} catch {
// Keep the last rendering; the next refresh retries.
} finally {
alertsBusy = false;
}
}
// ---------------------------------------------------------------- file drop / paste
const uploadUi = $('upload-status');
let uploadHideTimer = 0;
function uploadMessage(text, isError = false, sticky = false) {
clearTimeout(uploadHideTimer);
uploadUi.hidden = false;
uploadUi.textContent = text;
uploadUi.classList.toggle('is-error', isError);
if (!sticky) uploadHideTimer = setTimeout(() => { uploadUi.hidden = true; }, isError ? 8000 : 4000);
}
async function attachFiles(files) {
if (!files.length) return;
if (!state.session || !state.conn?.isOpen) {
toast.warning('Open a session first: files are attached to the current session.');
return;
}
const session = state.session;
const paths = [];
for (const file of files) {
try {
const path = await uploadFile(session, file, (p) =>
uploadMessage(`Uploading ${file.name || 'image'}… ${Math.round(p * 100)}%`, false, true));
paths.push(path);
} catch (error) {
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();
toast.success(paths.length === 1 ? `Attached ${paths[0].split('/').pop()}` : `Attached ${paths.length} files`);
}
function wireUploads() {
const frame = document.querySelector('.dd-terminal-frame');
const overlay = $('drop-overlay');
let depth = 0;
const hasFiles = (event) => [...(event.dataTransfer?.types ?? [])].includes('Files');
frame.addEventListener('dragenter', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
depth += 1;
$('drop-session').textContent = state.session || 'a session';
overlay.hidden = false;
});
frame.addEventListener('dragover', (event) => {
if (hasFiles(event)) event.preventDefault();
});
frame.addEventListener('dragleave', () => {
depth = Math.max(0, depth - 1);
if (!depth) overlay.hidden = true;
});
frame.addEventListener('drop', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
depth = 0;
overlay.hidden = true;
attachFiles([...event.dataTransfer.files]);
});
// Pasting a screenshot / copied file: upload it instead of pasting text.
document.addEventListener('paste', (event) => {
const files = [...(event.clipboardData?.files ?? [])];
if (!files.length) return;
event.preventDefault();
event.stopPropagation();
attachFiles(files);
}, true);
}
// Repo / CI panel for the current session; notifies when HEAD's CI finishes.
let lastCi = { key: '', state: '' };
let repoBusy = false;
async function refreshRepo() {
const session = state.session;
if (!session) {
lastRepo = null;
renderRepo($('widget-repo'), null);
return;
}
if (repoBusy) return;
repoBusy = true;
try {
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)) {
const text = `CI ${ci === 'success' ? 'passed' : 'failed'} on ${repo.branch} @ ${repo.sha}`;
notify.ring(session, text);
toast[ci === 'success' ? 'success' : 'error'](text);
inbox.record({ kind: ci === 'success' ? 'ci-pass' : 'ci-fail', session, text: `${session}: ${text}`, url: repo.ci?.url });
}
lastCi = { key, state: ci };
} catch {
// Keep the last rendering; the next refresh retries.
} finally {
repoBusy = false;
}
}
let refreshTimer = 0;
@@ -111,28 +321,33 @@ function updateUrl(session) {
// ---------------------------------------------------------------- connection
function connect(session) {
// reconnect: automatic re-attach after a drop. It must not re-create a session
// that was ended or renamed meanwhile, so it asks for --existing (devdesk#4).
function connect(session, { reconnect = false } = {}) {
clearTimeout(state.retryTimer);
state.conn?.dispose();
state.session = session;
updateUrl(session);
sessionsPanel?.setCurrent(session);
showContext();
ui.sessionChip.textContent = session || 'picker';
paneTitleRaw = '';
renderPaneTitle();
onSessionData();
files?.sessionChanged();
hideOverlay();
setWsState('connecting', 'connecting');
state.openedAt = 0;
term.reset();
const conn = new TtydConnection({
arg: session,
arg: session && reconnect ? ['--existing', session] : session,
size: () => ({ cols: term.cols, rows: term.rows }),
onOpen: () => {
state.openedAt = Date.now();
state.attempts = 0;
setWsState('connected', 'live');
setSso('ok');
if (session) rememberSession(session);
refreshSoon(800); // opening ?arg=<new name> just created this session
refreshRepo();
fit.fit();
term.focus();
},
@@ -185,12 +400,11 @@ function onClosed(code) {
function onFailure(error) {
if (error instanceof AuthExpiredError) {
setSso('expired');
setWsState('disconnected', 'signed out');
showOverlay({
title: 'Sign-in expired',
text: 'Your Authelia session has expired. Claude keeps running on the server; sign in again to reattach.',
actions: [{ label: 'Re-authenticate', primary: true, run: () => window.location.reload() }],
actions: [{ label: 'Sign in again', primary: true, run: () => window.location.reload() }],
tone: 'warn',
});
return;
@@ -217,13 +431,13 @@ function scheduleReconnect() {
showOverlay({
title: 'Reconnecting',
text: `Connection lost; Claude keeps running on the server. Retrying in ${seconds}s (attempt ${state.attempts}).`,
actions: [{ label: 'Retry now', primary: true, run: () => connect(state.session) }, pickerAction],
actions: [{ label: 'Retry now', primary: true, run: () => connect(state.session, { reconnect: true }) }, pickerAction],
});
state.retryTimer = setTimeout(() => connect(state.session), delay);
state.retryTimer = setTimeout(() => connect(state.session, { reconnect: true }), delay);
}
function retrySoon() {
if (state.retryTimer && !state.conn?.isOpen) connect(state.session);
if (state.retryTimer && !state.conn?.isOpen) connect(state.session, { reconnect: true });
}
// ---------------------------------------------------------------- titles & bell
@@ -235,12 +449,14 @@ function applyTitle() {
titleFrame = 0;
const parsed = parseTitle(pendingTitle);
if (!parsed) {
ui.paneTitle.textContent = pendingTitle;
paneTitleRaw = pendingTitle;
renderPaneTitle();
return;
}
if (parsed.session === 'picker') {
ui.sessionChip.textContent = 'picker';
ui.paneTitle.textContent = 'choose a session';
ui.sessionName.textContent = 'picker';
paneTitleRaw = 'choose a session';
renderPaneTitle();
notify.setBaseTitle('DevDesk · picker');
return;
}
@@ -250,61 +466,459 @@ function applyTitle() {
updateUrl(parsed.session);
rememberSession(parsed.session);
sessionsPanel.setCurrent(parsed.session);
showContext();
onSessionData();
refreshRepo();
files?.sessionChanged();
}
ui.sessionChip.textContent = parsed.session;
ui.paneTitle.textContent = parsed.pane;
paneTitleRaw = parsed.pane;
renderPaneTitle();
notify.setBaseTitle(`${parsed.session} · DevDesk`);
}
// ---------------------------------------------------------------- menus
const menus = [];
function wireMenu(button, menu, onOpen) {
menus.push({ button, menu });
button.addEventListener('click', (event) => {
event.stopPropagation();
const open = menu.hidden;
closeMenus();
if (open) {
onOpen?.();
menu.hidden = false;
button.setAttribute('aria-expanded', 'true');
}
});
menu.addEventListener('click', (event) => event.stopPropagation());
}
function closeMenus() {
for (const { button, menu } of menus) {
menu.hidden = true;
button.setAttribute('aria-expanded', 'false');
}
if (inbox?.isOpen) inbox.setOpen(false);
}
// Session switcher on the session chip (devdesk#16).
function renderSwitcher() {
const sessions = [...(sessionsPanel?.data?.sessions ?? [])].sort((a, b) =>
(b.running - a.running) || ((b.activity || 0) - (a.activity || 0)) || a.name.localeCompare(b.name));
const choose = (run) => () => {
closeMenus();
run();
};
const items = sessions.map((s) => {
const status = sessionStatus(s);
const ctx = s.context?.used_pct != null ? ` · ctx ${Math.floor(s.context.used_pct)}%` : '';
return el('button', {
type: 'button', role: 'menuitem',
class: `dd-menu-item dd-switch-item is-${status.key}${s.name === state.session ? ' is-current' : ''}`,
title: s.dir || '', onclick: choose(() => connect(s.name)),
},
el('span', { class: `lt-dot ${status.dot}` }),
el('span', { class: 'dd-switch-name' }, s.name),
el('span', { class: 'dd-switch-meta' }, `${status.label}${ctx}`));
});
if (!items.length) items.push(el('div', { class: 'dd-menu-note' }, sessionsPanel?.data ? 'No sessions yet.' : 'Loading…'));
ui.switcher.replaceChildren(
...items,
el('div', { class: 'dd-menu-sep' }),
el('button', {
type: 'button', role: 'menuitem', class: 'dd-menu-item',
onclick: choose(() => sessionsPanel.newDialog()),
}, '+ New session…'),
el('button', { type: 'button', role: 'menuitem', class: 'dd-menu-item', onclick: choose(() => connect(null)) }, 'Open picker'),
);
}
// ---------------------------------------------------------------- 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);
document.documentElement.style.setProperty('--dd-editor-font', `${clamped - 1}px`);
$('font-size').textContent = String(clamped);
fit.fit();
}
function renderNotifyButton() {
const labels = { granted: 'Alerts on', denied: 'Alerts blocked', default: 'Alerts off', unsupported: 'No alerts' };
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') });
}
if (state.session) {
add({ id: 'files', group: 'Files', icon: '🗀', label: files.isOpen ? 'Close files' : 'Browse files', kbd: 'Ctrl+⇧E', keywords: 'explorer folder read open view', run: () => files.toggle('files') });
add({ id: 'changes', group: 'Files', icon: '±', label: 'Review changes (git diff)', keywords: 'git status diff claude edits', run: () => files.open('changes') });
add({
id: 'goto-file', group: 'Files', icon: '⌕', label: 'Go to file…', kbd: 'Ctrl+P in Files', keywords: 'open find quick search',
run: () => {
files.open();
files.quickOpen.open();
},
});
}
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+⇧ E', 'Files: browse, preview, diff (Esc closes)'],
['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());
}
// Drag the files pane's left edge (or use ←/→ on it). The terminal keeps at
// least ~60 columns; the width is remembered per browser.
function wireResizer() {
const main = document.querySelector('.dd-main');
const handle = $('files-resizer');
const apply = (px) => {
const max = main.clientWidth - 520;
const width = Math.round(Math.max(340, Math.min(max, px)));
main.style.setProperty('--dd-files-w', `${width}px`);
return width;
};
const saved = Number(store.get('filesWidth', 0));
if (saved) apply(saved);
handle.addEventListener('pointerdown', (event) => {
event.preventDefault();
handle.setPointerCapture(event.pointerId);
ui.body.classList.add('dd-resizing');
const move = (e) => apply(main.getBoundingClientRect().right - e.clientX);
const up = () => {
ui.body.classList.remove('dd-resizing');
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', up);
store.set('filesWidth', $('files').getBoundingClientRect().width);
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', up);
});
handle.addEventListener('keydown', (event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
const width = apply($('files').getBoundingClientRect().width + (event.key === 'ArrowLeft' ? 40 : -40));
store.set('filesWidth', width);
});
}
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 = {
unsupported: 'Desktop notifications: unsupported',
denied: 'Desktop notifications: blocked by the browser',
default: 'Enable desktop notifications',
granted: notify.isMuted() ? 'Desktop notifications: muted (unmute)' : 'Desktop notifications: on (mute)',
};
ui.notifyButton.textContent = labels[current];
ui.notifyButton.dataset.state = current;
ui.notifyButton.disabled = current === 'unsupported' || current === 'denied';
}
function setSidebar(open) {
if (open && files?.isOpen) files.close();
ui.body.classList.toggle('dd-sidebar-open', open);
ui.sidebarButton.setAttribute('aria-expanded', String(open));
store.set('sidebar', open ? 'open' : 'closed');
if (open) sessionsPanel?.refresh();
ui.backdrop.hidden = !(open && isNarrow());
if (!isNarrow()) store.set('sidebar', open ? 'open' : 'closed');
if (open) {
sessionsPanel?.refresh();
refreshAlerts();
refreshRepo();
}
renderPanelsBadge();
}
function closeDrawerIfNarrow() {
if (isNarrow() && ui.body.classList.contains('dd-sidebar-open')) setSidebar(false);
}
// Phone key bar: keys an on-screen keyboard can't send (devdesk#14).
const KEYS = {
esc: '\x1b', tab: '\t', stab: '\x1b[Z', ctrlc: '\x03', up: '\x1b[A', down: '\x1b[B', enter: '\r',
claude: '\x020', shell: '\x021', // tmux prefix (C-b) + window number
};
function wireKeybar() {
if (!touch) return;
ui.body.classList.add('dd-touch');
const bar = $('keybar');
bar.hidden = false;
// Don't steal focus from the terminal (that would close the on-screen keyboard).
bar.addEventListener('pointerdown', (event) => event.preventDefault());
bar.addEventListener('click', (event) => {
const key = event.target.closest('[data-keys]')?.dataset.keys;
if (key && KEYS[key]) state.conn?.sendInput(KEYS[key]);
});
// Keep the terminal above the on-screen keyboard.
const vv = window.visualViewport;
if (vv) {
const update = () => {
const hidden = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
document.documentElement.style.setProperty('--dd-kb', `${hidden}px`);
};
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
}
}
function wireChrome() {
$('font-down').addEventListener('click', () => setFontSize(term.options.fontSize - 1));
$('font-up').addEventListener('click', () => setFontSize(term.options.fontSize + 1));
ui.notifyButton.addEventListener('click', async () => {
await notify.requestNotifications();
await toggleNotifications();
renderNotifyButton();
});
renderNotifyButton();
$('fullscreen-toggle').addEventListener('click', async () => {
if (document.fullscreenElement) {
await document.exitFullscreen();
$('fullscreen-toggle').addEventListener('click', () => {
closeMenus();
toggleFullscreen();
});
$('help-open').addEventListener('click', () => {
closeMenus();
showHelp();
});
$('footer-help').addEventListener('click', showHelp);
$('palette-menu').addEventListener('click', () => {
closeMenus();
palette.open();
});
files = new FilesPane($('files'), {
session: () => state.session,
paste: (text) => {
term.paste(text);
},
focusTerminal: () => term.focus(),
onToggle: (open) => {
ui.body.classList.toggle('dd-files-open', open);
$('files-toggle').setAttribute('aria-expanded', String(open));
if (open && ui.body.classList.contains('dd-sidebar-open')) setSidebar(false);
},
});
$('files-toggle').addEventListener('click', () => files.toggle());
wireResizer();
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 === 'KeyE') {
event.preventDefault();
event.stopPropagation();
if (state.session) files.toggle();
return;
}
await document.documentElement.requestFullscreen();
// Chromium: capture Ctrl+W/Ctrl+T/Esc etc. for the terminal while fullscreen.
navigator.keyboard?.lock?.().catch(() => {});
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();
$('theme-menu').textContent = getTheme() === 'light' ? 'Dark mode' : 'Light mode';
});
ui.sidebarButton.addEventListener('click', () => setSidebar(!ui.body.classList.contains('dd-sidebar-open')));
const wide = window.matchMedia('(min-width: 1100px)').matches;
setSidebar(store.get('sidebar', wide ? 'open' : 'closed') === 'open');
$('theme-menu').addEventListener('click', () => {
closeMenus();
toggleTheme();
});
inbox = new Inbox({
button: $('inbox-toggle'),
badge: $('inbox-badge'),
panel: $('inbox-panel'),
hooks: {
openSession: (name) => connect(name),
showAlerts: () => {
setSidebar(true);
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
},
},
});
$('inbox-toggle').addEventListener('click', (event) => {
event.stopPropagation();
const open = !inbox.isOpen;
closeMenus();
inbox.setOpen(open);
});
$('inbox-panel').addEventListener('click', (event) => event.stopPropagation());
wireMenu(ui.sessionChip, ui.switcher, () => {
renderSwitcher();
sessionsPanel.refresh();
});
document.addEventListener('click', closeMenus);
ui.sso.addEventListener('click', () => {
if (ui.sso.dataset.state === 'expired') window.location.reload();
ui.sidebarButton.addEventListener('click', () => setSidebar(!ui.body.classList.contains('dd-sidebar-open')));
ui.backdrop.addEventListener('click', () => setSidebar(false));
setSidebar(!isNarrow() && store.get('sidebar', 'open') === 'open');
window.matchMedia('(min-width: 1100px)').addEventListener('change', () => {
ui.backdrop.hidden = !(ui.body.classList.contains('dd-sidebar-open') && isNarrow());
});
// Esc closes menus and the narrow-screen drawer before the terminal sees it.
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || dialogOpen()) return;
// Files pane: Esc goes back / closes, unless the terminal or CodeMirror's
// own search panel has the focus.
if (files?.isOpen && !event.target.closest?.('.xterm, .cm-panels') && !menus.some(({ menu }) => !menu.hidden) && !inbox.isOpen) {
event.preventDefault();
event.stopPropagation();
files.escape();
return;
}
const menuOpen = menus.some(({ menu }) => !menu.hidden) || inbox.isOpen;
const drawerOpen = isNarrow() && ui.body.classList.contains('dd-sidebar-open');
if (!menuOpen && !drawerOpen) return;
event.preventDefault();
event.stopPropagation();
closeMenus();
if (drawerOpen) setSidebar(false);
}, true);
// "Sign in again" appears only when a request finds the SSO session expired (devdesk#13).
window.addEventListener('devdesk:auth', (event) => {
ui.reauth.hidden = event.detail !== 'expired';
});
ui.reauth.addEventListener('click', () => window.location.reload());
// Ctrl+W closes the tab before the terminal sees it; ask first.
window.addEventListener('beforeunload', (event) => {
if (state.conn?.isOpen && state.session) {
@@ -317,36 +931,66 @@ function wireChrome() {
document.addEventListener(type, () => { state.lastActivity = Date.now(); }, { capture: true, passive: true });
}
// While you're active, touch the Authelia gate so its inactivity timer doesn't lapse.
setInterval(async () => {
setInterval(() => {
if (Date.now() - state.lastActivity > 10 * 60 * 1000) return;
try {
await fetchToken();
setSso('ok');
} catch (error) {
if (error instanceof AuthExpiredError) setSso('expired');
}
fetchToken().catch(() => {});
}, 3 * 60 * 1000);
window.addEventListener('online', retrySoon);
document.addEventListener('visibilitychange', () => {
if (!document.hidden) retrySoon();
});
// Session states feed the header chip and the notifications for other sessions,
// so poll even in a background tab (browsers throttle that to about once a
// minute) — unless another tab just shared a fresh list (devdesk#8, #15).
onShared('sessions', (data) => sessionsPanel.accept(data));
setInterval(() => {
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) sessionsPanel.refresh();
if (!heardWithin('sessions', 16 * 1000)) sessionsPanel.refresh();
}, 15 * 1000);
// Alerts feed the Panels badge too.
onShared('alerts', showAlerts);
setInterval(() => {
if (!heardWithin('alerts', 62 * 1000)) refreshAlerts();
}, 60 * 1000);
// Repo status: on connect, after Claude's turns, and once a minute (devdesk#5, #8).
setInterval(() => {
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo();
}, 60 * 1000);
ui.attention.addEventListener('click', () => {
if (ui.attention.dataset.target) connect(ui.attention.dataset.target);
});
}
// A bare URL (no ?arg=) reattaches the session you used last, or the most
// recently active one; the picker is only the fallback (devdesk#16).
function defaultSession(data) {
const sessions = data?.sessions ?? [];
const known = new Set(sessions.map((s) => s.name));
const recent = recentNames().find((name) => known.has(name));
if (recent) return recent;
const running = sessions.filter((s) => s.running).sort((a, b) => (b.activity || 0) - (a.activity || 0));
return running[0]?.name ?? null;
}
// ---------------------------------------------------------------- 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);
await loadFonts(fontSize);
({ term, fit } = createTerminal($('terminal'), {
fontSize,
theme: getTheme(),
onShiftEnter: () => state.conn?.sendInput('\n'),
}));
onThemeChange((theme) => {
term.options.theme = THEMES[theme];
files?.themeChanged();
});
$('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));
@@ -355,8 +999,10 @@ async function boot() {
if (!titleFrame) titleFrame = requestAnimationFrame(applyTitle);
});
term.onBell(() => {
notify.ring(state.session, ui.paneTitle.textContent);
refreshSoon(); // Claude finished a turn: context numbers just changed
notify.ring(state.session, ui.paneTitle.textContent || 'Claude needs your attention');
refreshSoon(); // Claude finished a turn: states and context just changed
setTimeout(refreshRepo, 2000); // …and maybe committed or pushed
setTimeout(() => files?.claudeTurnEnded(), 1200); // …or edited files
});
let fitFrame = 0;
@@ -376,11 +1022,13 @@ async function boot() {
nameInput: $('new-session-name'),
select: $('project-select'),
refreshButton: $('sessions-refresh'),
pickerButton: $('open-picker'),
newToggle: $('new-session-toggle'),
status: $('sessions-status'),
hooks: {
onOpen: (name) => connect(name),
onPicker: () => connect(null),
onOpen: (name) => {
closeDrawerIfNarrow();
connect(name);
},
onEnding: () => { state.endingCurrent = true; },
onEnded: (name) => {
if (name !== state.session) state.endingCurrent = false;
@@ -391,18 +1039,25 @@ async function boot() {
state.session = to;
updateUrl(to);
rememberSession(to);
ui.sessionChip.textContent = to;
sessionsPanel.setCurrent(to);
renderSessionChip();
},
onData: () => showContext(),
onData: (_data, meta) => onSessionData(meta),
},
});
bindAskClaude($('sidebar'), (text) => {
state.conn?.sendInput(text);
// Alert text is untrusted: no control characters (a newline would run it in a
// shell window, CR would submit), and bracketed paste around it (devdesk#2).
term.paste(pasteSafe(text));
closeDrawerIfNarrow();
term.focus();
});
wireChrome();
connect(state.session);
wireKeybar();
wireUploads();
refreshAlerts();
await sessionsPanel.refresh();
connect(urlSession ?? defaultSession(sessionsPanel.data));
}
boot();
+19 -1
View File
@@ -32,11 +32,29 @@ export async function requestNotifications() {
return Notification.requestPermission();
}
const MUTE_KEY = 'devdesk.notify.muted';
export function isMuted() {
try {
return localStorage.getItem(MUTE_KEY) === '1';
} catch {
return false;
}
}
export function setMuted(muted) {
try {
localStorage.setItem(MUTE_KEY, muted ? '1' : '0');
} catch {
// Private mode: the mute just won't persist.
}
}
export function ring(session, detail) {
if (attentive()) return;
unread += 1;
render();
if (notificationsState() === 'granted') {
if (notificationsState() === 'granted' && !isMuted()) {
const note = new Notification(`Claude · ${session || 'devdesk'}`, {
body: detail || 'Claude needs your attention',
tag: `devdesk-${session}`,
+8
View File
@@ -67,6 +67,14 @@ export function slugify(text) {
.replace(/^[-_]+/, '').replace(/-+$/, '').slice(0, 64);
}
// Untrusted text (e.g. Grafana alert annotations) typed into a terminal: drop
// every control character so it can't run a command (newline in a shell),
// submit early (CR) or inject escape sequences (devdesk#2).
export function pasteSafe(text) {
// eslint-disable-next-line no-control-regex
return String(text ?? '').replace(/[\x00-\x1f\x7f]+/g, ' ');
}
export function stripAnsi(text) {
// eslint-disable-next-line no-control-regex
return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '');
+61
View File
@@ -0,0 +1,61 @@
// Repo / CI panel: git state of the current session's folder plus the Gitea CI
// status of its HEAD commit (`devdesk attach --repo <session>`).
const CI = {
success: ['passing', 'lt-status-completed'],
failure: ['failed', 'lt-status-failed'],
error: ['error', 'lt-status-failed'],
pending: ['running', 'lt-status-running'],
warning: ['warning', 'lt-status-pending'],
};
function link(href, text) {
const a = document.createElement('a');
a.href = href;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = text;
return a;
}
export function renderRepo(root, repo) {
const field = (name) => root.querySelector(`[data-field="${name}"]`);
const set = (name, value) => field(name).replaceChildren(value);
const grid = root.querySelector('.dd-repo-grid');
const note = root.querySelector('.dd-repo-note');
const fix = root.querySelector('.dd-fix-ci');
fix.hidden = true;
// No repository: one line instead of a grid of dashes (devdesk#17).
if (!repo || !repo.git) {
root.dataset.state = 'empty';
grid.hidden = true;
note.textContent = repo ? `Not a git repository: ${repo.dir}` : 'Open a session to see its repository.';
return;
}
root.dataset.state = 'live';
grid.hidden = false;
set('repo', repo.url ? link(repo.url, repo.repo) : (repo.remote || 'no remote'));
const sync = repo.upstream
? [repo.ahead ? `↑${repo.ahead}` : '', repo.behind ? `↓${repo.behind}` : ''].filter(Boolean).join(' ') || 'in sync'
: 'no upstream';
set('branch', `${repo.branch} @ ${repo.sha || '—'} · ${sync}`);
set('dirty', repo.dirty ? `${repo.dirty} changed` : 'clean');
const ci = field('ci');
const [label, cls] = repo.ci?.total ? (CI[repo.ci.state] || [repo.ci.state, 'lt-status-pending'])
: [repo.ahead ? 'not pushed' : 'no CI', ''];
ci.className = `lt-status ${cls}`;
ci.replaceChildren(repo.ci?.url ? link(repo.ci.url, label) : label);
set('prs', repo.url && repo.prs != null ? link(`${repo.url}/pulls`, `${repo.prs} open`) : '—');
note.textContent = repo.repo ? '' : 'Not a Gitea repository; CI status unavailable.';
if (repo.ci?.total && ['failure', 'error'].includes(repo.ci.state)) {
fix.hidden = false;
fix.dataset.askClaude = `CI is failing for ${repo.repo} on ${repo.branch} at ${repo.sha}`
+ ` (${repo.ci.url || 'see Gitea Actions'}). Use the gitea MCP server to read the failed job's log,`
+ ' find the cause, and fix it. Tell me the plan before pushing anything.';
}
}
+171 -55
View File
@@ -3,6 +3,11 @@
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;
@@ -24,6 +29,11 @@ function writeRecents(recents) {
}
}
// Session names this browser used most recently, newest first.
export function recentNames() {
return readRecents().map((r) => r.name);
}
export function rememberSession(name) {
if (name) writeRecents([{ name, at: Date.now() }, ...readRecents().filter((r) => r.name !== name)]);
}
@@ -45,33 +55,42 @@ 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);
// What Claude is doing in a session, from the devdesk-state-hook snapshot.
// Returns { key, label, dot } for display; key is one of
// waiting | working | idle | exited | running | dormant.
export function sessionStatus(s) {
if (!s?.running) return { key: 'dormant', label: 'dormant', dot: 'lt-dot-idle' };
switch (s.status?.state) {
case 'waiting': return { key: 'waiting', label: 'needs you', dot: 'lt-dot-down' };
case 'working': return { key: 'working', label: 'working', dot: 'lt-dot-warn' };
case 'idle': return { key: 'idle', label: 'idle', dot: 'lt-dot-up' };
case 'exited': return { key: 'exited', label: 'Claude exited', dot: 'lt-dot-idle' };
default: return { key: 'running', label: 'running', dot: 'lt-dot-up' };
}
node.append(...children.filter((c) => c !== null && c !== undefined));
return node;
}
export class SessionsPanel {
// hooks: onOpen(name), onPicker(), onEnded(name), onRenamed(from, to), onData(data)
constructor({ list, form, nameInput, select, refreshButton, pickerButton, status, hooks }) {
Object.assign(this, { list, nameInput, select, status, hooks });
// hooks: onOpen(name), onEnding(name), onEnded(name), onEndAborted(), onRenamed(from, to), onData(data)
constructor({ list, form, nameInput, select, refreshButton, newToggle, status, hooks }) {
Object.assign(this, { list, form, nameInput, select, newToggle, status, hooks });
this.current = null;
this.data = null;
this.busy = false;
this.pending = null;
refreshButton.addEventListener('click', () => this.refresh());
pickerButton.addEventListener('click', () => hooks.onPicker());
newToggle.addEventListener('click', () => this.toggleForm());
form.addEventListener('submit', (event) => {
event.preventDefault();
this.createFromForm();
});
}
toggleForm(open = this.form.hidden) {
this.form.hidden = !open;
this.newToggle.setAttribute('aria-expanded', String(open));
if (open) this.nameInput.focus();
}
setCurrent(name) {
this.current = name;
if (this.data) this.render();
@@ -82,29 +101,46 @@ export class SessionsPanel {
this.status.classList.toggle('is-error', isError);
}
async refresh() {
if (this.busy) return;
this.busy = true;
// Concurrent callers share the request in flight (boot awaits the first list).
refresh() {
this.pending ??= this.load().finally(() => { this.pending = null; });
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 {
this.data = await fetchSessionList();
fetched = true;
share('sessions', this.data);
if (!this.status.classList.contains('is-error')) this.setStatus('');
} catch (err) {
this.setStatus(`list unavailable: ${err.message}`, true);
} finally {
this.busy = false;
}
this.render();
this.hooks.onData?.(this.data);
this.hooks.onData?.(this.data, { fetched });
}
async act(label, action, ...args) {
this.setStatus(`${label}…`);
// A list another tab fetched (devdesk#8).
accept(data) {
this.data = data;
this.render();
this.hooks.onData?.(this.data, { fetched: false });
}
// 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;
}
}
@@ -113,42 +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?.();
}
@@ -156,17 +266,29 @@ export class SessionsPanel {
renderSession(s) {
const isCurrent = s.name === this.current;
const ctx = s.context?.used_pct != null ? ` · ctx ${Math.floor(s.context.used_pct)}%` : '';
const status = sessionStatus(s);
const ctx = s.context?.used_pct != null ? `ctx ${Math.floor(s.context.used_pct)}%` : '';
const since = s.status?.since ? ago(s.status.since) : ago(s.activity);
const model = isCurrent && s.context?.model ? s.context.model.replace(/^Claude\s+/i, '') : '';
const meta = s.running
? `${s.attached} attached · ${ago(s.activity)}${ctx}`
? [`${status.label}${since && since !== 'now' ? ` ${since}` : ''}`, ctx, model, s.attached ? `${s.attached} open` : '']
.filter(Boolean).join(' · ')
: 'dormant · resumes Claude';
const pct = s.context?.used_pct;
const title = [
s.dir,
status.key === 'waiting' && s.status?.message,
s.context?.model && `${s.context.model}${s.context.cost_usd != null ? ` · $${s.context.cost_usd.toFixed(2)}` : ''}`,
].filter(Boolean).join('\n');
const iconButton = (label, glyph, onclick, extra = '') =>
el('button', { class: `dd-icon-btn ${extra}`, type: 'button', title: label, 'aria-label': `${label} ${s.name}`, onclick }, glyph);
return el('li', { class: `dd-session-item${isCurrent ? ' is-current' : ''}${s.running ? '' : ' is-dormant'}` },
el('button', { class: 'dd-session-open', type: 'button', title: s.dir || '', onclick: () => this.hooks.onOpen(s.name) },
el('span', { class: `lt-dot ${s.running ? 'lt-dot-up' : 'lt-dot-idle'}` }),
return el('li', { class: `dd-session-item is-${status.key}${isCurrent ? ' is-current' : ''}` },
el('button', { class: 'dd-session-open', type: 'button', title, onclick: () => this.hooks.onOpen(s.name) },
el('span', { class: `lt-dot ${status.dot}` }),
el('span', { class: 'dd-session-name' }, s.name),
el('span', { class: 'dd-session-meta' }, meta)),
el('span', { class: 'dd-session-meta' }, meta),
pct != null ? el('span', { class: `dd-ctx${pct >= 90 ? ' is-full' : pct >= 70 ? ' is-high' : ''}`, 'aria-hidden': 'true' },
el('span', { class: 'dd-ctx-fill', style: `width:${Math.min(100, pct)}%` })) : null),
el('span', { class: 'dd-session-actions' },
iconButton('Rename', '✎', () => this.rename(s.name)),
el('a', {
@@ -199,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);
}
}
+31
View File
@@ -0,0 +1,31 @@
// One tab polls for every open DevDesk tab (devdesk#8): whoever fetches the
// session list or the alerts broadcasts the result, and a tab that heard a fresh
// result recently skips its own poll. Each poll costs a ttyd client and a PTY.
const channel = typeof BroadcastChannel === 'function' ? new BroadcastChannel('devdesk') : null;
const heard = new Map();
const handlers = new Map();
channel?.addEventListener('message', (event) => {
const { key, data } = event.data ?? {};
const handler = handlers.get(key);
if (!handler) return;
heard.set(key, Date.now());
handler(data);
});
export function onShared(key, handler) {
handlers.set(key, handler);
}
export function share(key, data) {
try {
channel?.postMessage({ key, data });
} catch {
// Not cloneable or channel closed: the other tabs just poll themselves.
}
}
export function heardWithin(key, ms) {
return Date.now() - (heard.get(key) ?? 0) < ms;
}
+75 -28
View File
@@ -3,32 +3,77 @@ import { FitAddon } from '@xterm/addon-fit';
import { WebglAddon } from '@xterm/addon-webgl';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { Unicode11Addon } from '@xterm/addon-unicode11';
import { ClipboardAddon } from '@xterm/addon-clipboard';
import { Base64, ClipboardAddon } from '@xterm/addon-clipboard';
// LotusGuild TDS tokens (web_template base.css :root) mapped onto the ANSI palette.
export const THEME = Object.freeze({
background: '#010304',
foreground: '#c4d9ee',
cursor: '#FF6B00',
cursorAccent: '#010304',
selectionBackground: 'rgba(0, 212, 255, 0.30)',
selectionInactiveBackground: 'rgba(0, 212, 255, 0.15)',
black: '#0d1520',
red: '#FF2D55',
green: '#00FF88',
yellow: '#FFB300',
blue: '#3A8DFF',
magenta: '#BF5FFF',
cyan: '#00D4FF',
white: '#c4d9ee',
brightBlack: '#3e607a',
brightRed: '#FF5C7A',
brightGreen: '#33FFAA',
brightYellow: '#FFD700',
brightBlue: '#6AA8FF',
brightMagenta: '#D08CFF',
brightCyan: '#33DFFF',
brightWhite: '#FFFFFF',
// Terminal output (tmux copy-mode, Claude) may *write* the browser clipboard
// via OSC 52, but must never read it: anything printed could exfiltrate it (devdesk#7).
const writeOnlyClipboard = {
readText: () => '',
writeText: (selection, text) => {
if (selection === 'c') return navigator.clipboard?.writeText(text).catch(() => {});
return undefined;
},
};
// 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';
@@ -45,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,
@@ -55,14 +100,14 @@ export function createTerminal(container, { fontSize, onShiftEnter }) {
allowProposedApi: true, // unicode11
macOptionIsMeta: true,
drawBoldTextInBrightColors: false,
theme: THEME,
theme: THEMES[theme],
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new Unicode11Addon());
term.unicode.activeVersion = '11';
term.loadAddon(new ClipboardAddon()); // OSC 52 from tmux copy-mode / Claude
term.loadAddon(new ClipboardAddon(new Base64(), writeOnlyClipboard));
term.loadAddon(new WebLinksAddon((event, uri) => {
event.preventDefault();
window.open(uri, '_blank', 'noopener');
@@ -90,6 +135,8 @@ export function createTerminal(container, { fontSize, onShiftEnter }) {
term.open(container);
// Touch devices: the DOM renderer is more reliable at high DPR than WebGL.
if (window.matchMedia?.('(pointer: coarse)').matches) return { term, fit };
try {
const webgl = new WebglAddon();
webgl.onContextLoss(() => webgl.dispose());
+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'));
});
}
+339
View File
@@ -0,0 +1,339 @@
// 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.querySelector('.lt-cmd-overlay.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`
// (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).
export class CommandPalette {
constructor(getCommands, options = {}) {
this.options = { id: 'lt-cmd', placeholder: 'Type a command or a session name…', ...options };
const id = this.options.id;
this.getCommands = getCommands;
this.commands = [];
this.visible = [];
this.selected = 0;
this.opener = null;
this.input = el('input', {
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',
});
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` },
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');
}
async open(query = '') {
this.opener = document.activeElement;
this.overlay.classList.add('is-open');
this.input.value = query;
this.input.focus();
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();
}
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() {
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;
}
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;
}
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) {
let index = 0;
const nodes = [];
for (const [group, commands] of groups) {
if (group) 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: `${this.options.id}-item-${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();
}
}
+3 -4
View File
@@ -1,7 +1,6 @@
// Sidebar widgets. In v1 only Sessions is live; Repo/CI and Claude Context get
// data from devdesk-api in v1.1, Ops Alerts in v2. The "Ask Claude" pattern is
// already wired: any [data-ask-claude] button types its prompt into the current
// session without pressing Enter, so you can review it before sending.
// "Ask Claude": any [data-ask-claude] button (Ops Alerts, Repo/CI) types its
// prompt into the current session without pressing Enter, so you can review it
// before sending.
export function bindAskClaude(root, send) {
root.addEventListener('click', (event) => {
+597 -37
View File
@@ -37,9 +37,12 @@ body.dd-app::after {
}
.dd-header .lt-header-left {
flex: 1 1 auto;
min-width: 0;
gap: var(--space-lg);
}
.dd-header .lt-header-right { flex: 0 0 auto; }
.dd-session { flex: 0 1 auto; }
.dd-session {
display: flex;
@@ -57,27 +60,103 @@ body.dd-app::after {
max-width: 38vw;
}
.dd-sso {
cursor: default;
background: transparent;
.dd-session-chip {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 6px;
max-width: 34vw;
padding: 3px 8px;
border: 1px solid var(--accent-cyan-border);
background: var(--accent-cyan-dim);
color: var(--accent-cyan);
font-family: var(--font-mono);
}
.dd-sso[data-state="ok"] { color: var(--accent-green); background: var(--accent-green-dim); }
.dd-sso[data-state="unknown"] { color: var(--text-muted); }
.dd-sso[data-state="expired"] {
color: var(--accent-red);
background: var(--accent-red-dim);
text-shadow: var(--glow-red);
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
}
.dd-session-chip:hover,
.dd-session-chip[aria-expanded="true"] { border-color: var(--accent-cyan); box-shadow: var(--box-glow-cyan); }
.dd-session-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-caret { color: var(--text-muted); font-size: 0.7rem; }
/* Current session's Claude state as a dot on the chip (devdesk#13). */
.dd-state-dot { flex: 0 0 auto; width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); }
.dd-session-chip[data-state="working"] .dd-state-dot { background: var(--accent-amber); box-shadow: 0 0 6px var(--accent-amber); animation: lt-pulse 1.2s ease-in-out infinite; }
.dd-session-chip[data-state="waiting"] { border-color: rgba(255, 45, 85, 0.6); color: var(--accent-red); background: var(--accent-red-dim); }
.dd-session-chip[data-state="waiting"] .dd-state-dot { background: var(--accent-red); box-shadow: 0 0 6px var(--accent-red); animation: lt-pulse 1.2s ease-in-out infinite; }
.dd-session-chip[data-state="idle"] .dd-state-dot { background: var(--accent-green); }
.dd-tools .lt-btn[data-state="granted"] { color: var(--accent-green); border-color: var(--accent-green-border); }
.dd-tools .lt-btn[data-state="denied"] { color: var(--accent-red); }
/* Connection status: a dot; the word only when it isn't "live". */
.dd-ws[data-state="connected"] #ws-label { display: none; }
.dd-panels { position: relative; clip-path: none; } /* the corner cut hid the active border */
@media (prefers-reduced-motion: reduce), (pointer: coarse) {
.dd-attention,
.dd-session-chip .dd-state-dot { animation: none !important; }
}
/* base.css draws keys in --text-dim (~1.5:1 on this background). */
.dd-sidebar .lt-kv-key { color: var(--text-secondary); font-size: 0.7rem; }
#sidebar-toggle[aria-expanded="true"] {
color: var(--accent-orange);
border-color: var(--accent-orange-border);
}
/* ---- menus (session switcher, ⋯) ----------------------------------------------- */
.dd-session,
.dd-more { position: relative; }
.dd-more-toggle { font-size: 1rem; line-height: 1; padding-inline: 8px; }
.dd-menu {
position: absolute;
top: calc(100% + 6px);
z-index: 40;
min-width: 240px;
max-width: min(360px, calc(100vw - 16px));
max-height: 70vh;
overflow-y: auto;
padding: 4px;
border: 1px solid var(--accent-cyan-border);
background: var(--bg-secondary);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7), var(--box-glow-cyan);
}
.dd-menu[hidden] { display: none; }
.dd-switcher { left: 0; }
.dd-more-menu { right: 0; }
.dd-menu-item {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%;
padding: 6px 8px;
background: none;
border: 1px solid transparent;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.74rem;
text-align: left;
cursor: pointer;
}
.dd-menu-item:hover:not(:disabled),
.dd-menu-item:focus-visible { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.dd-menu-item:disabled { color: var(--text-muted); cursor: default; }
.dd-menu-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
font-size: 0.74rem;
color: var(--text-secondary);
}
.dd-menu-inline { display: inline-flex; align-items: center; gap: 4px; color: var(--text-primary); }
.dd-menu-sep { height: 1px; margin: 4px 0; background: var(--border-dim); }
.dd-menu-note { padding: 6px 8px; color: var(--text-muted); font-size: 0.72rem; }
.dd-menu-foot { padding: 6px 8px 2px; border-top: 1px solid var(--border-dim); margin-top: 4px; color: var(--text-muted); font-size: 0.62rem; }
.dd-switch-item.is-current { border-color: var(--accent-orange-border); background: var(--accent-orange-dim); }
.dd-switch-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; }
.dd-switch-meta { color: var(--text-muted); font-size: 0.64rem; white-space: nowrap; }
.dd-switch-item.is-waiting .dd-switch-meta { color: var(--accent-red); }
.dd-switch-item.is-working .dd-switch-meta { color: var(--accent-amber); }
/* ---- layout ---------------------------------------------------------------- */
.dd-main {
position: fixed;
@@ -134,14 +213,8 @@ body.dd-sidebar-open .dd-sidebar {
justify-content: flex-start;
}
.dd-soon {
margin-left: auto;
color: var(--text-muted);
letter-spacing: 0.12em;
}
.dd-header-actions { margin-left: auto; display: inline-flex; gap: var(--space-sm); }
.dd-header-action {
margin-left: auto;
background: none;
border: 0;
color: var(--accent-cyan);
@@ -153,7 +226,7 @@ body.dd-sidebar-open .dd-sidebar {
.dd-muted {
color: var(--text-muted);
font-size: 0.68rem;
font-size: 0.7rem;
}
.dd-stub-note {
@@ -193,7 +266,7 @@ body.dd-sidebar-open .dd-sidebar {
text-align: left;
}
.dd-session-open:hover { background: var(--accent-cyan-dim); }
.dd-session-open .lt-dot { grid-row: 1 / span 2; }
.dd-session-open .lt-dot { grid-row: 1 / span 3; }
.dd-session-name {
font-size: 0.78rem;
@@ -204,13 +277,48 @@ body.dd-sidebar-open .dd-sidebar {
}
.dd-session-meta {
font-size: 0.62rem;
color: var(--text-muted);
font-size: 0.68rem;
color: var(--text-secondary);
}
/* Context-window fill under each session (devdesk#17). */
.dd-ctx {
grid-column: 2;
height: 2px;
margin-top: 3px;
background: var(--bg-tertiary);
overflow: hidden;
}
.dd-ctx-fill { display: block; height: 100%; background: var(--accent-cyan); }
.dd-ctx.is-high .dd-ctx-fill { background: var(--accent-amber); }
.dd-ctx.is-full .dd-ctx-fill { background: var(--accent-red); }
.dd-session-item.is-recent .dd-session-name { color: var(--text-secondary); font-weight: 400; }
.dd-session-item.is-dormant .dd-session-name { color: var(--text-secondary); }
.dd-session-item.is-waiting { border-color: rgba(255, 45, 85, 0.45); background: var(--accent-red-dim); }
.dd-session-item.is-waiting .dd-session-meta { color: var(--accent-red); }
.dd-session-item.is-working .dd-session-meta { color: var(--accent-amber); }
/* base.css sets display on .lt-btn/.lt-chip, which beats the [hidden] attribute. */
.dd-header [hidden] { display: none !important; }
/* Header chip: other sessions waiting on you. */
.dd-attention {
text-transform: none;
letter-spacing: 0;
max-width: 22vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: none;
cursor: pointer;
font-family: var(--font-mono);
color: var(--accent-red);
background: var(--accent-red-dim);
animation: lt-pulse 1.6s ease-in-out infinite;
}
.dd-attention.is-active { display: inline-flex; }
.dd-session-actions {
display: flex;
@@ -258,6 +366,7 @@ body.dd-sidebar-open .dd-sidebar {
padding: 4px 6px;
}
.dd-new-session[hidden] { display: none; }
.dd-new-session {
display: grid;
grid-template-columns: 1fr auto;
@@ -275,20 +384,65 @@ body.dd-sidebar-open .dd-sidebar {
gap: var(--space-sm);
}
/* ---- stub widgets ------------------------------------------------------------ */
/* ---- ops alerts --------------------------------------------------------------- */
.dd-alert-count { margin-left: auto; }
.dd-alert-count:empty { display: none; }
.dd-alert-list { display: flex; flex-direction: column; gap: var(--space-sm); }
.dd-alert {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-direction: column;
gap: 3px;
padding: 6px 8px;
border: 1px solid var(--border-dim);
border-left: 2px solid var(--accent-amber);
font-size: 0.72rem;
color: var(--text-secondary);
}
.dd-alert-text { flex: 1 1 auto; }
.dd-alert:has(.lt-chip-critical) { border-left-color: var(--accent-red); }
.dd-alert-head { display: flex; align-items: center; gap: var(--space-sm); min-width: 0; }
.dd-alert-name { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-alert-age { margin-left: auto; white-space: nowrap; }
.dd-alert-instances { overflow-wrap: anywhere; }
.dd-alert-summary { color: var(--text-secondary); }
.dd-alert .dd-ask { align-self: flex-start; margin-top: 2px; }
.dd-all-clear { display: flex; align-items: center; gap: var(--space-sm); color: var(--accent-green); font-size: 0.74rem; }
.dd-context-grid { margin-top: var(--space-sm); }
[data-state="stub"] .lt-section-body { opacity: 0.75; }
.dd-repo-grid[hidden] { display: none; }
.dd-repo-note:empty { display: none; }
.dd-fix-ci { margin-top: var(--space-sm); }
.dd-fix-ci[hidden] { display: none; }
[data-state="stub"] .lt-section-body,
#widget-context[data-state="empty"] .lt-kv-grid { opacity: 0.6; }
/* ---- file drop / upload ------------------------------------------------------ */
.dd-drop-overlay {
position: absolute;
inset: 0;
z-index: 6;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed var(--accent-cyan);
background: rgba(0, 212, 255, 0.08);
color: var(--accent-cyan);
font-size: 0.9rem;
pointer-events: none;
}
.dd-drop-overlay[hidden] { display: none; }
.dd-upload-status {
position: absolute;
right: 14px;
bottom: 16px;
z-index: 6;
max-width: 70%;
padding: 6px 10px;
border: 1px solid var(--accent-cyan-border);
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 0.72rem;
box-shadow: var(--box-glow-cyan);
overflow-wrap: anywhere;
}
.dd-upload-status[hidden] { display: none; }
.dd-upload-status.is-error { border-color: rgba(255, 45, 85, 0.5); color: var(--accent-red); }
/* ---- overlay ----------------------------------------------------------------- */
.dd-overlay {
@@ -337,6 +491,33 @@ body.dd-sidebar-open .dd-sidebar {
.dd-footer .lt-footer-key { margin-right: 4px; }
.dd-keybar { display: flex; gap: 4px; width: 100%; overflow-x: auto; scrollbar-width: none; }
.dd-keybar[hidden] { display: none; }
.dd-keybar button {
flex: 1 1 auto;
min-width: 0;
height: 34px;
padding: 0 4px;
border: 1px solid var(--border-dim);
background: var(--bg-secondary);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.8rem;
touch-action: manipulation;
}
.dd-keybar button:active { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
/* Touch: the key bar replaces the shortcut hints; the page shrinks above the
on-screen keyboard (--dd-kb is set from visualViewport). */
body.dd-touch { --dd-footer-height: 44px; }
body.dd-touch .dd-hints { display: none; }
body.dd-touch .dd-main { bottom: calc(var(--dd-footer-height) + var(--dd-kb, 0px) + env(safe-area-inset-bottom, 0px)); }
body.dd-touch .dd-footer {
bottom: var(--dd-kb, 0px);
height: calc(var(--dd-footer-height) + env(safe-area-inset-bottom, 0px));
padding: 4px 4px calc(4px + env(safe-area-inset-bottom, 0px));
}
/* ---- small screens ----------------------------------------------------------- */
@media (max-width: 1099px) {
.dd-sidebar {
@@ -344,7 +525,7 @@ body.dd-sidebar-open .dd-sidebar {
top: var(--dd-gap);
right: var(--dd-gap);
bottom: var(--dd-gap);
width: min(var(--dd-sidebar-width), calc(100vw - 2 * var(--dd-gap)));
width: min(var(--dd-sidebar-width), calc(100vw - 56px)); /* leave a strip to tap out */
z-index: 20;
background-color: var(--bg-secondary);
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.6);
@@ -353,14 +534,393 @@ body.dd-sidebar-open .dd-sidebar {
.dd-pane-title { display: none; }
}
.dd-backdrop {
position: absolute;
inset: 0;
z-index: 15;
background: rgba(1, 3, 4, 0.55);
}
.dd-backdrop[hidden] { display: none; }
@media (max-width: 719px) {
:root { --dd-gap: 4px; }
.lt-brand-subtitle,
#font-down,
#font-up,
#fullscreen-toggle,
.dd-sso { display: none; }
.lt-brand { display: none; }
.dd-session-chip { max-width: 46vw; }
/* Phones: the red Panels badge and the switcher carry "needs you" instead. */
.dd-attention.is-active { display: none; }
.dd-header { padding: 0 var(--space-sm); gap: var(--space-sm); }
.dd-header .lt-header-left { gap: var(--space-sm); }
/* base.css hides the WS indicator on phones; keep it as a bare dot. */
.dd-header .dd-ws { display: inline-flex; padding: 4px; }
.dd-header .dd-ws #ws-label { display: none; }
.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-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; }
/* ---- inbox (bell) ---------------------------------------------------------------- */
.dd-inbox .lt-notif-bell-btn { font-size: 0.9rem; line-height: 1; padding-inline: 8px; }
.dd-inbox .lt-notif-badge { top: -5px; right: -5px; }
.dd-inbox .lt-notif-badge.is-calm { background: var(--accent-amber); animation: none; }
.dd-inbox-panel { width: min(360px, calc(100vw - 16px)); z-index: 40; }
.dd-inbox-panel .lt-notif-panel-list { max-height: min(460px, calc(100vh - 140px)); }
.dd-notif-section {
padding: 6px 0.75rem 4px;
font-size: 0.62rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-orange);
border-bottom: 1px solid var(--border-dim);
background: var(--bg-primary);
}
.dd-notif-red { background: var(--accent-red); box-shadow: 0 0 6px var(--accent-red); }
.dd-notif-amber { background: var(--accent-amber); box-shadow: 0 0 6px var(--accent-amber); }
.dd-notif-green { background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green); }
.dd-notif-cyan { background: var(--accent-cyan); }
.dd-notif-read { background: var(--border-color); box-shadow: none; }
html[data-theme="light"] .lt-notif-dot { box-shadow: none; }
.dd-notif-empty,
.dd-notif-more { padding: 0.75rem; color: var(--text-muted); font-size: 0.72rem; line-height: 1.5; }
.dd-panels-icon { display: none; }
@media (max-width: 719px) {
/* Phone header: theme lives in the ⋯ menu; Panels becomes an icon. */
.dd-header .lt-theme-btn { display: none; }
.dd-inbox .lt-notif-badge { top: 1px; right: 1px; }
.dd-panels-label { display: none; }
.dd-panels-icon { display: inline; font-size: 1rem; }
.dd-inbox-panel { position: fixed; top: calc(var(--header-height) + 4px); right: 8px; left: 8px; width: auto; }
}
/* ---- files pane (devdesk#19, #37): explorer + tabs + editor ---------------------- */
.dd-files {
position: relative;
display: flex;
flex-direction: column;
flex: 0 0 var(--dd-files-w, 52%);
min-width: 0;
margin: 0;
background: var(--bg-terminal);
--dd-explorer-w: 240px;
}
.dd-files[hidden] { display: none; }
body.dd-resizing { cursor: col-resize; user-select: none; }
body.dd-resizing .dd-terminal,
body.dd-resizing .dd-md-frame { pointer-events: none; }
.dd-files-resizer {
position: absolute;
top: 0;
bottom: 0;
left: calc(-1 * var(--dd-gap) - 2px);
width: calc(var(--dd-gap) + 6px);
cursor: col-resize;
z-index: 3;
}
.dd-files-resizer:hover,
.dd-files-resizer:focus-visible,
body.dd-resizing .dd-files-resizer { background: linear-gradient(90deg, transparent 40%, var(--accent-cyan) 40%, var(--accent-cyan) 60%, transparent 60%); outline: none; }
#files-toggle[aria-expanded="true"] { color: var(--accent-orange); border-color: var(--accent-orange-border); }
/* head: explorer toggle | tabs | actions */
.dd-files-head { display: flex; align-items: stretch; gap: 2px; min-height: 34px; padding: 0 4px; border-bottom: 1px solid var(--border-dim); background: var(--bg-secondary); }
.dd-files-head .dd-icon-btn { align-self: center; }
.dd-files-actions { display: flex; align-items: center; gap: 2px; margin-left: auto; }
.dd-tabs { display: flex; flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: thin; }
.dd-tab {
display: flex;
align-items: center;
gap: 6px;
max-width: 220px;
padding: 0 6px 0 10px;
border-right: 1px solid var(--border-dim);
border-top: 2px solid transparent;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.74rem;
white-space: nowrap;
cursor: pointer;
user-select: none;
}
.dd-tab:hover { color: var(--text-primary); background: var(--bg-tertiary); }
.dd-tab.is-active { color: var(--text-primary); background: var(--bg-terminal); border-top-color: var(--accent-orange); }
.dd-tab.is-preview .dd-tab-name { font-style: italic; }
.dd-tab-name { overflow: hidden; text-overflow: ellipsis; }
.dd-tab-tag { padding: 0 4px; border: 1px solid var(--border-color); color: var(--text-muted); font-size: 0.6rem; }
.dd-tab-close { width: 18px; height: 18px; padding: 0; border: 0; background: none; color: inherit; opacity: 0; font-size: 0.9rem; line-height: 1; cursor: pointer; }
.dd-tab:hover .dd-tab-close,
.dd-tab.is-active .dd-tab-close,
.dd-tab-close:focus-visible { opacity: 0.7; }
.dd-tab-close:hover { opacity: 1; color: var(--accent-red); }
.dd-tab.git-amber .dd-tab-name { color: var(--accent-amber); }
.dd-tab.git-green .dd-tab-name { color: var(--accent-green); }
.dd-tab.git-red .dd-tab-name { color: var(--accent-red); text-decoration: line-through; }
/* main: explorer column | editor */
.dd-files-main { position: relative; display: flex; flex: 1; min-height: 0; }
.dd-explorer { display: flex; flex-direction: column; flex: 0 0 var(--dd-explorer-w); min-width: 0; border-right: 1px solid var(--border-dim); background: var(--bg-secondary); }
.dd-files.explorer-hidden .dd-explorer,
.dd-files.explorer-hidden .dd-explorer-resizer,
.dd-files.is-narrow .dd-explorer-resizer { display: none; }
.dd-explorer-resizer { flex: 0 0 4px; margin-left: -2px; margin-right: -2px; cursor: col-resize; z-index: 2; }
.dd-explorer-resizer:hover { background: var(--accent-cyan); }
.dd-explorer-views { display: flex; border-bottom: 1px solid var(--border-dim); }
.dd-explorer-views button {
flex: 1;
padding: 6px 4px 5px;
border: 0;
border-bottom: 2px solid transparent;
background: none;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.64rem;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
}
.dd-explorer-views button:hover { color: var(--text-primary); }
.dd-explorer-views button[aria-selected="true"] { color: var(--accent-orange); border-bottom-color: var(--accent-orange); }
.dd-files-count:not(:empty) { margin-left: 3px; padding: 0 5px; border-radius: 8px; background: var(--accent-amber-dim); color: var(--accent-amber); font-size: 0.6rem; }
.dd-explorer-body { flex: 1; min-height: 0; overflow: auto; padding: 4px 0 12px; outline: none; }
.dd-tree-row {
display: flex;
align-items: center;
gap: 6px;
min-height: 24px;
padding-right: 8px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.76rem;
white-space: nowrap;
cursor: pointer;
user-select: none;
}
.dd-tree-row:hover { background: var(--bg-tertiary); }
.dd-tree-row:focus-visible { outline: 1px solid var(--accent-cyan); outline-offset: -1px; }
.dd-tree-row.is-active { background: var(--accent-cyan-dim); box-shadow: inset 2px 0 0 var(--accent-cyan); }
.dd-tree-row.is-ignored { opacity: 0.5; }
.dd-tree-row.is-note { color: var(--text-muted); font-size: 0.7rem; cursor: default; }
.dd-tree-row.is-disabled { opacity: 0.5; cursor: default; }
.dd-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.dd-tree-row.git-amber .dd-tree-name { color: var(--accent-amber); }
.dd-tree-row.git-green .dd-tree-name { color: var(--accent-green); }
.dd-tree-row.git-red .dd-tree-name { color: var(--accent-red); text-decoration: line-through; }
.dd-tree-row.git-cyan .dd-tree-name { color: var(--accent-cyan); }
.dd-tree-row.git-dirty > .dd-tree-name { color: var(--accent-amber); }
.dd-dirty-dot { flex: 0 0 6px; height: 6px; border-radius: 50%; background: var(--accent-amber); }
.dd-change-dir { color: var(--text-muted); font-size: 0.66rem; }
.dd-changes-head { padding: 6px 10px; color: var(--text-muted); font-size: 0.68rem; border-bottom: 1px solid var(--border-dim); }
/* file-type badges */
.dd-ficon {
flex: 0 0 18px;
font-family: var(--font-mono);
font-size: 0.56rem;
font-weight: 700;
letter-spacing: -0.04em;
text-align: center;
line-height: 1;
}
.dd-ficon.tone-dir { color: var(--text-muted); font-size: 0.7rem; }
.dd-ficon.tone-cyan { color: var(--accent-cyan); }
.dd-ficon.tone-amber { color: var(--accent-amber); }
.dd-ficon.tone-blue { color: #5b9cff; }
.dd-ficon.tone-green { color: var(--accent-green); }
.dd-ficon.tone-purple { color: var(--accent-purple); }
.dd-ficon.tone-orange { color: var(--accent-orange); }
.dd-ficon.tone-muted { color: var(--text-muted); }
html[data-theme="light"] .dd-ficon.tone-blue { color: #1f4fbf; }
.dd-file-status { flex: 0 0 1.1em; font-weight: 700; text-align: center; font-size: 0.7rem; }
.dd-file-status.is-amber { color: var(--accent-amber); }
.dd-file-status.is-green { color: var(--accent-green); }
.dd-file-status.is-red { color: var(--accent-red); }
.dd-file-status.is-cyan { color: var(--accent-cyan); }
.dd-claude-chip { padding: 0 4px; border: 1px solid var(--accent-orange-border); color: var(--accent-orange); font-size: 0.58rem; font-weight: 700; }
.dd-add { color: var(--accent-green); font-size: 0.66rem; }
.dd-del { color: var(--accent-red); font-size: 0.66rem; }
.dd-files-msg { padding: 14px; color: var(--text-muted); font-size: 0.76rem; line-height: 1.5; }
.dd-files-msg.is-error { color: var(--accent-red); }
/* editor area */
.dd-editor-area { position: relative; display: flex; flex: 1; min-width: 0; flex-direction: column; }
.dd-tab-view { display: flex; flex: 1; min-height: 0; flex-direction: column; }
.dd-tab-view[hidden] { display: none; }
.dd-viewer-bar { display: flex; align-items: center; gap: 8px; min-height: 30px; padding: 3px 8px; border-bottom: 1px solid var(--border-dim); }
.dd-viewer-where { flex: 1; min-width: 0; overflow: hidden; font-size: 0.7rem; text-overflow: ellipsis; white-space: nowrap; }
.dd-viewer-path { color: var(--text-secondary); }
.dd-viewer-meta { color: var(--text-muted); font-size: 0.66rem; transition: color 0.6s; }
.dd-viewer-meta.is-flash { color: var(--accent-amber); }
.dd-viewer-actions { display: flex; flex: 0 0 auto; gap: 4px; }
.dd-viewer-btn {
padding: 2px 8px;
border: 1px solid var(--border-color);
background: none;
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 0.68rem;
white-space: nowrap;
cursor: pointer;
}
.dd-viewer-btn:hover { border-color: var(--accent-cyan); background: var(--accent-cyan-dim); }
.dd-viewer-content { display: flex; flex: 1; min-height: 0; flex-direction: column; overflow: hidden; }
.dd-viewer-content > .cm-editor { flex: 1; min-height: 0; }
.dd-viewer-content .cm-editor.cm-focused { outline: none; }
.dd-viewer-content.is-image {
align-items: center;
justify-content: center;
overflow: auto;
padding: 12px;
background: repeating-conic-gradient(var(--bg-secondary) 0 25%, var(--bg-terminal) 0 50%) 0 0 / 20px 20px;
}
.dd-viewer-content.is-image img { max-width: 100%; height: auto; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); }
.dd-md-frame { flex: 1; width: 100%; border: 0; background: var(--bg-terminal); }
.dd-plain { flex: 1; margin: 0; padding: 10px; overflow: auto; font-size: 0.76rem; white-space: pre; }
/* empty editor */
.dd-editor-empty { display: flex; flex: 1; flex-direction: column; gap: 14px; padding: 28px 24px; overflow: auto; }
.dd-editor-empty[hidden] { display: none; }
.dd-empty-title { color: var(--text-secondary); font-size: 0.82rem; }
.dd-empty-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.dd-empty-actions .dd-viewer-btn { padding: 5px 12px; font-size: 0.72rem; }
.dd-empty-label { margin-bottom: 4px; color: var(--accent-orange); font-size: 0.62rem; letter-spacing: 0.12em; text-transform: uppercase; }
.dd-recent { display: flex; align-items: center; gap: 6px; width: 100%; padding: 3px 4px; border: 0; background: none; color: var(--accent-cyan); font-family: var(--font-mono); font-size: 0.76rem; text-align: left; cursor: pointer; }
.dd-recent:hover { background: var(--accent-cyan-dim); }
.dd-recent-name { white-space: nowrap; }
.dd-empty-hint { color: var(--text-muted); font-size: 0.68rem; }
/* Narrow pane (phone, or dragged small): the explorer slides over the editor
and closes when a file opens; ☰ brings it back. */
.dd-files.is-narrow .dd-explorer {
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 4;
width: min(300px, 85%);
box-shadow: 8px 0 24px rgba(0, 0, 0, 0.45);
}
html[data-theme="light"] .dd-files.is-narrow .dd-explorer { box-shadow: 8px 0 24px rgba(50, 80, 130, 0.25); }
@media (max-width: 1099px) {
/* Narrow window: the pane covers the terminal instead of squeezing it
(squeezing would resize tmux for every client). */
.dd-files { position: absolute; inset: var(--dd-gap); z-index: 18; flex: none; }
.dd-files-resizer { display: none; }
}
@media (max-width: 719px) {
.dd-cmd-btn { display: none; }
.dd-tab { max-width: 160px; }
}
@media (pointer: coarse) {
.dd-tree-row { min-height: 34px; }
.dd-tab-close { opacity: 0.7; width: 28px; height: 28px; }
}