Compare commits

..
12 Commits
Author SHA1 Message Date
jaredandClaude Opus 5.5 73e77aca73 feat: v0.9.0 conversation search, plan usage meters, quiet phone at the desk
build-deb / Lint, test, package (push) Successful in 3m6s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- devdesk attach --search (grep + jq over ~/.claude/projects, six files at
  a time) and --resume-convo NAME ID [fork]; devdesk-claude-run passes
  --fork-session once. Search dialog with Resume / Fork / Open.
- statusLine snapshots rate_limits and pr; footer/menu meters with a 90 %
  warning; PR chip on session rows.
- CLAUDE_CLIENT_PRESENCE_FILE per tmux session id; --presence on|off with a
  self-expiring marker; the page heartbeats it while visible.

Closes #24
Closes #25
Closes #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:45:26 -04:00
jaredandClaude Opus 5.5 f82c13612d feat: v0.8.2 clickable paths, alert sessions, ops guard and trail
build-deb / Lint, test, package (push) Successful in 2m45s
build-deb / Deploy to CT 126 (push) Successful in 5s
build-deb / Notify on failure (push) Skipped
- Terminal link provider: printed paths that are files of the session open
  in Files (at the line); the file list is preloaded so the first hover works.
- Alerts: "In a new session" opens alert-<name> and types the prompt once
  Claude is up.
- devdesk-guard PreToolUse(Bash) hook: permissionDecision "ask" for
  destructive commands (verified to prompt in auto mode); DEVDESK_GUARD and
  guard.local.
- State hook logs Bash commands and derives host tags (ssh targets, CT/VM
  ids); --list carries tags, --peek the last commands; UI shows both.

Closes #21
Closes #18
Closes #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:37:46 -04:00
jaredandClaude Opus 5.5 a92d10ace3 feat: v0.8.1 answer sessions from the inbox: allow/deny, peek & reply
build-deb / Lint, test, package (push) Successful in 2m4s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- PermissionRequest hook: the state file records tool, detail and
  description of a pending permission; Notification keeps it.
- devdesk attach --answer (presses 1/Esc only if the same wait is on and the
  prompt is visible), --peek (last exchange from the transcript), --reply
  (bracketed paste + Enter, streamed like uploads).
- Inbox: Allow/Deny/Open for permission waits; Peek & reply for other waits
  and finished sessions. Matrix messages include the command.

Closes #22
Closes #23

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:22:37 -04:00
jaredandClaude Opus 5.5 66321f4748 feat: v0.8.0 edit and save files with conflict detection
build-deb / Lint, test, package (push) Successful in 1m57s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- devdesk attach --fs-write SESSION PATH BYTES BASESHA|new: streamed like
  uploads, temp file in the same folder, sha re-check (conflict reply instead
  of overwriting), mode/owner kept, atomic mv; new files with folders, never
  outside the session folder or through a symlink out of it.
- Editor: editable CodeMirror for UTF-8 text, dirty tracking, Ctrl+S,
  Save/Revert, conflict dialog with a disk-vs-yours diff, changed-on-disk
  banner, drafts in localStorage (flushed on pagehide), close/leave guards,
  New file.
- Upload streaming factored into streamIn(), shared by uploads and saves.

Closes #20

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:10:45 -04:00
jaredandClaude Opus 5.5 927920c162 fix: old links never re-create ended sessions
build-deb / Lint, test, package (push) Successful in 1m56s
build-deb / Deploy to CT 126 (push) Successful in 24s
build-deb / Notify on failure (push) Skipped
Inbox entries, the switcher, palette switches, the attention chip and Matrix
links (?arg=NAME&existing=1) attach with --existing; an ended session gets an
explanation and an explicit "Start again". Inbox entries for ended sessions
can't resurrect them; entries expire after 7 days.

Closes #38

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:57:52 -04:00
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
39 changed files with 5500 additions and 190 deletions
+6 -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
@@ -40,9 +40,12 @@ install: all
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 \
libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix $(DESTDIR)$(LIBDIR)/
libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix libexec/devdesk-guard $(DESTDIR)$(LIBDIR)/
install -m 0644 libexec/devdesk-files.sh libexec/devdesk-inbox.sh libexec/devdesk-history.sh $(DESTDIR)$(LIBDIR)/
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:
+14 -2
View File
@@ -36,7 +36,9 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
- **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.
- **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. With the panels closed, the **Panels** button shows a count of waiting sessions and Grafana alerts.
- 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.
- **Answer from the inbox:** a session waiting on a permission shows the exact command/file and **Allow / Deny** (the key is pressed only if that prompt is still on screen); other waits and finished sessions offer **Peek & reply**: the last exchange and a reply box.
- **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.
@@ -45,7 +47,15 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
- **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. Paths can't leave the session folder.
- **Editing:** text files are editable; ● marks unsaved tabs; Ctrl+S (or Save) writes atomically and keeps the file's mode and owner. If the file changed on disk since you opened it (Claude edited it), saving asks: Compare (a diff of disk vs yours), Overwrite or Cancel; a change on disk while you edit shows a banner instead of replacing your text. Unsaved edits are kept as drafts in the browser, so a reload or a session switch doesn't lose them. + creates a file (folders as needed). The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser.
- **Paths are links:** a file path Claude prints (`web/src/files.js`, `notes.md:12`) opens in Files when clicked, at that line if given.
- **Ops guard:** `devdesk-guard` (a PreToolUse hook) makes Claude ask before destructive commands (`pct destroy`, `zfs destroy`, Ceph pool/OSD removal, `rm -r /…`, `git push --force`, `reboot`…), even in auto mode. `DEVDESK_GUARD=0` turns it off; `/etc/devdesk/guard.local` adds rules (`regex<TAB>reason`). Bash commands are logged per session, and sessions are tagged with the hosts they touch (ssh targets, CT/VM ids).
- **Search past conversations** (⋯ menu, palette): full text over every Claude conversation on the host; **Resume** it in a new session or **Fork** it.
- **Plan usage:** the 5-hour and weekly windows as meters in the footer (warning at 90 %); a **PR** chip on sessions whose branch has a pull request.
- **Quiet phone at the desk:** while a tab shows a session, Remote Control holds back phone pushes for it (client-presence marker, expires ~2 min after you leave).
- **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use and cost to `/var/lib/devdesk/context/<session>.json`. The Sessions panel shows it as a bar (cost in the tooltip), and Claude's own footer shows a one-line summary.
- **Light mode:** the terminal switches palettes with the page. DevDesk starts Claude with its `dark-ansi` theme so Claude draws with the terminal's 16 colours, and the light palette maps bright white to ink; tmux and the status line use ANSI colours too. A session picks up Claude's theme the next time Claude starts in it (`/exit`, then ⏎ resumes the same conversation).
- **Phone:** a key bar replaces the shortcut hints (Esc, Tab, ⇧Tab, ^C, ↑, ↓, ⏎, Claude/Shell window), and the panels open as a drawer.
- **SSH:** `devdesk attach <name>`, `devdesk new <name> [project]`, `devdesk rename <old> <new>`, `devdesk end <name>`, `devdesk ls`, `devdesk pick`, `devdesk repo <name>`, `devdesk alerts`.
@@ -59,8 +69,10 @@ 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) |
Text size, desktop notifications (with a mute) and fullscreen are in the **⋯** menu. In Chromium, fullscreen also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves.
The **☀/◐** button switches light and dark mode (saved per browser, shared with the other web_template apps' `lt_theme` preference). Text size, desktop notifications (with a mute), the keyboard shortcuts sheet and fullscreen are in the **⋯** menu; everything is also in the command palette. In Chromium, fullscreen also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves.
## Configuration
- **Put your changes in `/etc/devdesk/env.local`.** It's read after the packaged `env` and never shipped, so `apt upgrade` never asks about it. Example: `DEVDESK_REMOTE_CONTROL=1`.
+43 -1
View File
@@ -17,6 +17,12 @@ 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"
# shellcheck source=libexec/devdesk-inbox.sh
. "$DEVDESK_LIBEXEC/devdesk-inbox.sh"
# shellcheck source=libexec/devdesk-history.sh
. "$DEVDESK_LIBEXEC/devdesk-history.sh"
usage() {
sed -n '2,12s/^# \{0,1\}//p' "$0"
@@ -65,6 +71,7 @@ cmd_list() {
--arg proj "$(projects_tsv)" \
--argjson ctx "$(per_session_json "$DEVDESK_CONTEXT_DIR")" \
--argjson st "$(per_session_json "$DEVDESK_STATUS_DIR")" \
--argjson tags "$(per_session_json "$DEVDESK_STATE_DIR/tags")" \
--arg dflt "$DEVDESK_DEFAULT_SESSION" \
--arg root "$DEVDESK_PROJECTS_ROOT" '
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
@@ -81,7 +88,7 @@ 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), status: ($st[.name] // null)}),
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null), tags: ($tags[.name].hosts // [])}),
projects: ([{name: $dflt, dir: $root}]
+ [rows($proj)[] | {name: .[0], dir: (.[1:] | join("\t"))}
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
@@ -296,20 +303,54 @@ 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 | --fs-write | --git-status | --git-show | --asset)
local action=${1#--}
shift
"cmd_${action//-/_}" "$@"
dd_linger
return
;;
--peek | --search)
local action=${1#--}
shift
"cmd_$action" "$@"
dd_linger
return
;;
--resume-convo | --presence)
local action=${1#--}
shift
"cmd_${action//-/_}" "$@"
ok_json
dd_linger
return
;;
--answer | --reply)
local action=${1#--}
shift
"cmd_$action" "$@"
ok_json
dd_linger
return
;;
--new | --rename | --end)
@@ -317,6 +358,7 @@ cmd_attach() {
shift
"cmd_$action" "$@"
ok_json
dd_linger
return
;;
--existing)
+201
View File
@@ -1,3 +1,204 @@
devdesk (0.9.0) bookworm; urgency=medium
* Search past conversations (#24): every Claude conversation on this host
(DevDesk and code-server), by what you asked, what Claude said, the
commands it ran and their output (⋯ menu or the palette). Results show
the title, folder, dates and highlighted snippets; Resume opens the
conversation in a new session in its folder, Fork continues it as a new
conversation (claude --fork-session) and leaves the original as it is.
A conversation already open in a running session offers "Open" instead,
and resuming it twice is refused.
* Plan usage and PRs (#25): the 5-hour and weekly plan windows from
Claude's statusLine, as meters in the footer (⋯ menu on phones), with a
warning at 90 % of the 5-hour window; a PR chip (with review state) on
sessions whose branch has a pull request. (The statusLine has no prompt
cache expiry, so there is no cache timer.)
* Phone pushes held back while you're at the desk (#26): while a DevDesk
tab shows a session, its Claude sees a client-presence marker
(CLAUDE_CLIENT_PRESENCE_FILE) and Remote Control holds back pushes; the
marker expires about 2 minutes after the tab stops showing it. Applies to
sessions whose Claude started on this version (/exit, then ⏎).
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 21:44:07 -0400
devdesk (0.8.2) bookworm; urgency=medium
* Click file paths in the terminal (#21): a path Claude prints
(web/src/files.js, ./notes.md:12, /root/code/x/y.py:3) is a link when it
is a file of the session; it opens in Files, at that line if given.
* Alerts: "In a new session" (#18) opens (or reuses) alert-<name> in the
default folder and types the investigation prompt there once Claude is
up, without sending it.
* Ops trail and guard (#27):
- devdesk-guard, a PreToolUse hook for Bash, makes Claude ask before
commands that destroy things (pct/qm destroy, zfs/zpool destroy, Ceph
pool/OSD removal, rbd rm, wipefs/mkfs/dd to /dev, rm -r of / or a
top-level folder, git push --force, DROP TABLE, stopping devdesk-tmux,
reboot/poweroff…), also in auto mode. It never blocks or allows on its
own. DEVDESK_GUARD=0 turns it off; /etc/devdesk/guard.local adds rules
(regex<TAB>reason).
- Every Bash command is logged per session (last 500); sessions are
tagged with the hosts they work on (ssh targets, CT/VM ids), shown in
the session list, the switcher and the palette; Peek lists the last
commands.
* Sessions pick up the new hooks when Claude next starts (/exit, then ⏎).
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 20:36:32 -0400
devdesk (0.8.1) bookworm; urgency=medium
* Answer sessions from the 🔔 inbox without switching to them:
- Allow / Deny (#22): a PermissionRequest hook records what Claude wants
to run (tool, command/file/URL, Claude's description); the inbox shows
it with Allow, Deny and Open. The key is pressed in that session's
Claude window only if the same wait is still on and the permission
prompt is on screen, so a stale button never types into Claude.
- Peek & reply (#23): the last thing you asked and Claude's latest answer
(from the session's transcript), and a reply box; the reply is pasted
as one bracketed paste and sent. Available for sessions that wait for
input and from "finished" entries.
- Matrix "needs you" messages include the command.
* New attach commands: --answer SESSION SINCE allow|deny, --peek SESSION,
--reply SESSION BYTES (text streamed like uploads).
* Sessions pick up the new PermissionRequest hook when Claude next starts
(/exit, then ⏎).
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 20:22:37 -0400
devdesk (0.8.0) bookworm; urgency=medium
* Edit and save files in the Files pane (#20):
- Text files open editable; ● marks unsaved tabs; Ctrl+S or Save writes
through `devdesk attach --fs-write`: a temp file in the same folder,
mode and owner kept, swapped in with mv.
- Conflict detection: the save carries the sha256 of the version you
opened; if Claude (or anyone) changed the file since, nothing is written
and you choose Compare (diff of disk vs yours), Overwrite or Cancel.
- A file that changes on disk while you edit it shows a banner (Compare,
Reload, Keep mine) instead of replacing your text; clean files still
reload in place.
- Unsaved edits are mirrored to the browser as drafts (flushed when the
page closes), so a reload, a crash or a session switch doesn't lose
them; a draft for a file that changed since offers Compare/Restore/
Discard.
- Closing a tab with unsaved changes asks Save / Don't save / Cancel;
leaving the page warns.
- + New file (missing folders are created; never outside the session
folder, and never through a symlink out of it).
- Markdown previews show unsaved Source edits. Latin-1 files stay
read-only.
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 20:09:49 -0400
devdesk (0.7.3) bookworm; urgency=medium
* Fix (#38): links that mean "go to that session" no longer re-create one
that was ended. The 🔔 inbox, the session switcher, palette switches, the
"needs you" chip and Matrix links (now ?arg=NAME&existing=1) open sessions
existing-only; an ended one shows "NAME has ended" with an explicit
"Start NAME again". Inbox entries for ended sessions are marked as such,
and entries older than 7 days are dropped.
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 19:57:52 -0400
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):
+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
+5
View File
@@ -42,5 +42,10 @@ DEVDESK_PUBLIC_URL=https://dev.lotusguild.org
DEVDESK_MATRIX_WEBHOOK_FILE=/etc/devdesk/matrix-webhook
DEVDESK_MATRIX_DELAY=30
# 0 turns off the guard that makes Claude ask before destructive commands
# (pct/zfs/ceph destroy, rm -r /…, force pushes, reboots). Extra rules:
# /etc/devdesk/guard.local, one "regex<TAB>reason" per line.
DEVDESK_GUARD=1
# 1 = also enable Remote Control so sessions can be continued from the Claude app.
DEVDESK_REMOTE_CONTROL=0
+17 -2
View File
@@ -42,15 +42,22 @@ run_new() {
}
run_resume() {
local id args started rc
local id args started rc fork=()
id=$(dd_registry_get "$NAME" claude_session_id)
if [ -z "$id" ]; then
run_new
return
fi
mapfile -d '' args < <(common_args)
# "Fork" from conversation search (devdesk#24): continue as a new
# conversation, once; the SessionStart hook then records the new id.
if [ "$(dd_registry_get "$NAME" fork)" = true ]; then
fork=(--fork-session)
# shellcheck disable=SC2016 # $n is a jq variable
dd_registry_update 'del(.sessions[$n].fork)' --arg n "$NAME" 2>/dev/null || true
fi
started=$SECONDS
"$CLAUDE_BIN" --resume "$id" "${args[@]}"
"$CLAUDE_BIN" --resume "$id" "${fork[@]}" "${args[@]}"
rc=$?
# A resume that fails straight away means the transcript is gone; start fresh.
if [ "$rc" -ne 0 ] && [ $((SECONDS - started)) -lt 10 ]; then
@@ -69,6 +76,14 @@ menu() {
}
printf '\033]2;%s\033\134' "$NAME" # pane title (ST = ESC \\)
# While a DevDesk tab shows this session, this marker exists and Claude tells
# Remote Control a client is present, which holds back phone pushes (devdesk#26).
# Keyed by tmux's session id, which survives renames.
sid=$(dd_tmux display -p -t "${TMUX_PANE:-}" '#{session_id}' 2>/dev/null)
if [ -n "$sid" ]; then
export CLAUDE_CLIENT_PRESENCE_FILE="$DEVDESK_PRESENCE_DIR/${sid#\$}"
fi
mode=resume
while :; do
NAME=$(dd_current_session) # follow renames
+274
View File
@@ -0,0 +1,274 @@
# 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"
}
# --fs-write SESSION REL BYTES BASE (devdesk#20): save a file from the editor.
# The content arrives as base64 on stdin after the READY line (like uploads).
# BASE is the sha256 the editor loaded: if the file changed since, nothing is
# written and the reply is {conflict: true, sha: <current>}. BASE "new" creates
# a file (and missing folders) and refuses to overwrite one. Writes go to a
# temp file in the same folder, keep the mode and owner, and land with mv.
cmd_fs_write() {
[ $# -eq 4 ] || dd_die "usage: devdesk attach --fs-write SESSION PATH BYTES BASESHA|new"
local root rel=$2 size=$3 base=$4 target dir name tmp b64len actual current sha mtime
root=$(dd_session_root "$1")
if ! [[ $size =~ ^[0-9]+$ ]] || [ "$size" -gt "$DEVDESK_FS_MAX" ]; then
dd_die "too large: the editor saves files up to $((DEVDESK_FS_MAX / 1024)) KiB"
fi
[[ $base =~ ^([0-9a-f]{64}|new)$ ]] || dd_die "bad base: $base"
case $rel in /* | *$'\n'* | *$'\r'* | '' | */ | . | ..) dd_die "bad path: $rel" ;; esac
name=${rel##*/}
if [ "$name" = . ] || [ "$name" = .. ]; then dd_die "bad path: $rel"; fi
if [ "$base" = new ]; then
if [ -e "$root/$rel" ] || [ -L "$root/$rel" ]; then dd_die "already exists: $rel"; fi
case $rel in */*) dir=$root/${rel%/*} ;; *) dir=$root ;; esac
# Missing folders are created, but never through a symlink out of the
# root: check the deepest folder that exists before creating any.
local existing=$dir
while [ ! -e "$existing" ]; do existing=${existing%/*}; done
existing=${existing#"$root"}
dd_fs_path "$root" "${existing#/}" >/dev/null
mkdir -p -- "$dir" || dd_die "cannot create folder for $rel"
dir=${dir#"$root"}
dir=$(dd_fs_path "$root" "${dir#/}")
target="$dir/$name"
else
target=$(dd_fs_path "$root" "$rel")
[ -f "$target" ] || dd_die "not a file: $rel"
dir=${target%/*}
fi
tmp=$(mktemp --tmpdir="$dir" ".${name}.devdesk-XXXXXX") || dd_die "cannot write in ${dir#"$root"/}"
trap 'rm -f "$tmp"' 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-WRITE-READY\r\n'
if [ "$b64len" -gt 0 ]; then
head -c "$b64len" | base64 -d >"$tmp" 2>/dev/null || true
fi
stty sane 2>/dev/null || true
actual=$(stat -c %s "$tmp" 2>/dev/null || echo 0)
if [ "$actual" != "$size" ]; then
rm -f "$tmp"
dd_die "save incomplete ($actual of $size bytes)"
fi
if [ "$base" = new ]; then
if [ -e "$target" ]; then
rm -f "$tmp"
dd_die "already exists: $rel"
fi
chmod 0644 "$tmp"
else
current=$(sha256sum -- "$target" | cut -d' ' -f1)
if [ "$current" != "$base" ]; then
rm -f "$tmp"
trap - HUP INT TERM
jq -cMn --arg p "$rel" --arg h "$current" '{ok: false, conflict: true, path: $p, sha: $h}'
return
fi
chmod --reference="$target" "$tmp" 2>/dev/null || true
chown --reference="$target" "$tmp" 2>/dev/null || true
fi
mv -f -- "$tmp" "$target" || { rm -f "$tmp"; dd_die "cannot replace $rel"; }
trap - HUP INT TERM
sha=$(sha256sum -- "$target" | cut -d' ' -f1)
mtime=$(stat -c %Y -- "$target")
jq -cMn --arg p "${target#"$root"/}" --arg h "$sha" --argjson s "$size" --argjson m "$mtime" \
'{ok: true, path: $p, sha: $h, size: $s, mtime: $m}'
}
# --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"
}
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# Claude Code PreToolUse hook for Bash (wired in via claude-settings.json):
# commands that destroy things (containers, VMs, pools, disks, root-level
# folders, cluster members…) get permissionDecision "ask", so Claude has to get
# your OK even in auto mode (devdesk#27). It never blocks and never allows on
# its own; anything else passes through untouched.
#
# DEVDESK_GUARD=0 in /etc/devdesk/env.local turns it off. Extra rules go in
# /etc/devdesk/guard.local, one per line: an extended regex, a tab, a reason.
# shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" 2>/dev/null || exit 0
[ "${DEVDESK_GUARD:-1}" != 0 ] || exit 0
command=$(jq -rM '.tool_input.command // empty' 2>/dev/null) || exit 0
[ -n "$command" ] || exit 0
# Built-in rules: extended regex <TAB> reason.
rules=$(
cat <<'RULES'
\bpct[[:space:]]+destroy\b destroys a Proxmox container
\bqm[[:space:]]+destroy\b destroys a Proxmox VM
\bpvecm[[:space:]]+delnode\b removes a node from the Proxmox cluster
\bpveceph[[:space:]]+(osd|pool|mon|mgr|mds|fs)[[:space:]]+destroy\b destroys Ceph components
\bceph[[:space:]]+osd[[:space:]]+(purge|destroy|rm|out)\b removes or takes out a Ceph OSD
\bceph[[:space:]]+osd[[:space:]]+pool[[:space:]]+(delete|rm)\b deletes a Ceph pool
\bceph[[:space:]]+(fs[[:space:]]+rm|mon[[:space:]]+remove|auth[[:space:]]+del)\b removes Ceph components
\brbd[[:space:]]+(rm|remove|purge)\b deletes RBD images
\bzfs[[:space:]]+destroy\b destroys a ZFS dataset or snapshot
\bzpool[[:space:]]+(destroy|labelclear)\b destroys a ZFS pool
\b(lvremove|vgremove|pvremove|wipefs|shred)\b wipes volumes or disks
\bmkfs(\.[a-z0-9]+)?\b formats a filesystem
\bsgdisk[[:space:]]+(-[a-zA-Z]*Z|--zap) wipes a partition table
\bdd\b[^|;&]*\bof=/dev/ writes raw to a device
\brm[[:space:]]+(-[^[:space:]]*[[:space:]]+)*-[^[:space:]]*[rR][^[:space:]]*[[:space:]]+(-[^[:space:]]*[[:space:]]+)*['"]?/([a-z0-9_.*-]+/?)?['"]?([[:space:];&|)]|$) recursively deletes / or a top-level folder
\bgit[[:space:]]+push\b[^|;&]*([[:space:]]-f\b|--force) force-pushes (rewrites remote history)
\b(DROP[[:space:]]+(DATABASE|SCHEMA|TABLE)|TRUNCATE[[:space:]]+TABLE)\b drops database objects
\bsystemctl[[:space:]]+(stop|restart|kill|disable|mask)[[:space:]]+devdesk-tmux ends every DevDesk session
(^|[;&|][[:space:]]*|\bssh[[:space:]]+[^[:space:]]+[[:space:]]+['"]?|\bsystemctl[[:space:]]+)(reboot|poweroff|halt|shutdown)\b reboots or shuts down a machine
\bkubectl[[:space:]]+delete\b deletes Kubernetes resources
\bdocker[[:space:]]+(system[[:space:]]+prune|volume[[:space:]]+(rm|prune))\b deletes Docker data
RULES
cat "${DEVDESK_GUARD_LOCAL:-/etc/devdesk/guard.local}" 2>/dev/null
)
while IFS=$'\t' read -r pattern reason; do
if [ -z "$pattern" ] || [ "${pattern:0:1}" = "#" ]; then continue; fi
if grep -Eiq -- "$pattern" <<<"$command" 2>/dev/null; then
jq -cMn --arg r "DevDesk guard: this command ${reason:-matches a rule in guard.local}. Confirm before it runs." \
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: $r}}'
exit 0
fi
done <<<"$rules"
exit 0
+126
View File
@@ -0,0 +1,126 @@
# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables
# Past conversations (devdesk#24) and client presence (devdesk#26), sourced by
# bin/devdesk and reached through `devdesk attach --search|--resume-convo|--presence`.
dd_projects_dir() {
printf '%s/projects\n' "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
}
dd_convo_file() {
[[ $1 =~ ^[0-9a-f-]{36}$ ]] || return 1
compgen -G "$(dd_projects_dir)/*/$1.jsonl" | head -n1
}
# --search QUERY: full-text search over every Claude conversation on this host
# (DevDesk and code-server alike). Case-insensitive, plain text. Newest first;
# per conversation: title, folder, dates and up to 3 snippets (what you asked,
# Claude's answers, commands it ran, their output).
cmd_search() {
[ $# -eq 1 ] || dd_die "usage: devdesk attach --search QUERY"
local query=$1 dir files f
if [ ${#query} -lt 2 ] || [ ${#query} -gt 200 ] || [[ $query == *$'\n'* ]]; then dd_die "search for 2 to 200 characters"; fi
dir=$(dd_projects_dir)
# Files that mention it at all (grep is fast even over hundreds of MB), newest first.
mapfile -t files < <({ LC_ALL=C grep -rlFi --include='*.jsonl' -- "$query" "$dir" 2>/dev/null || true; } |
while IFS= read -r f; do printf '%s\t%s\n' "$(stat -c %Y "$f")" "$f"; done | sort -rn | head -n 30 | cut -f2-)
# Six conversations at a time (each is a grep and a jq over large lines).
local tmp i=0
tmp=$(mktemp -d)
for f in "${files[@]}"; do
dd_search_one "$query" "$f" >"$tmp/$(printf '%03d' "$i")" &
i=$((i + 1))
if [ $((i % 6)) -eq 0 ]; then wait; fi
done
wait
{ cat "$tmp"/* 2>/dev/null || true; } | jq -cMs --arg q "$query" '{query: $q, results: [.[] | select(. != null)][:25]}'
rm -rf "$tmp"
}
# One conversation's search result (or nothing), for cmd_search.
dd_search_one() {
local query=$1 f=$2
{ LC_ALL=C grep -Fi -m 30 -- "$query" "$f" || true; } | jq -cMs --arg q "$query" --arg id "$(basename "$f" .jsonl)" \
--arg mtime "$(stat -c %Y "$f")" --slurpfile head <(head -n 80 "$f" | jq -cM 'select(.type == "custom-title" or .type == "user" or .cwd)' 2>/dev/null | jq -cMs .) '
def texts:
if .type == "user" then
(.message.content | if type == "string" then [{role: "you", text: .}]
else [.[]? | if .type == "text" then {role: "you", text: .text}
elif .type == "tool_result" then {role: "output", text: (.content | if type == "string" then . else ([.[]? | .text? // empty] | join("\n")) end)}
else empty end] end)
elif .type == "assistant" then
[.message.content[]? | if .type == "text" then {role: "claude", text: .text}
elif .type == "tool_use" then {role: "command", text: (.input.command // .input.file_path // .input.url // .input.pattern // empty)}
else empty end]
else [] end;
($q | ascii_downcase) as $lq
| [.[] | select(.isSidechain != true) | . as $e | texts[] | select(.text | type == "string")
| (.text | ascii_downcase | index($lq)) as $i | select($i != null)
| {role, at: $e.timestamp,
snippet: ((if $i > 90 then "…" else "" end) + (.text[([$i - 90, 0] | max):($i + ($q | length) + 140)] | gsub("\\s+"; " "))
+ (if (.text | length) > $i + ($q | length) + 140 then "…" else "" end))}] as $m
| select($m | length > 0)
| ($head[0] // []) as $h
| {id: $id,
title: (([$h[] | select(.type == "custom-title") | .customTitle] | last)
// ([$h[] | select(.type == "user" and (.message.content | type == "string") and (.message.content | startswith("<") | not)) | .message.content] | first | .[0:120])
// "untitled"),
prompt: ([$h[] | select(.type == "user" and (.message.content | type == "string") and (.message.content | startswith("<") | not)) | .message.content] | first | .[0:160]),
cwd: ([$h[] | .cwd // empty] | first),
started: ([$h[] | .timestamp // empty] | first),
updated: ($mtime | tonumber),
hits: ($m | length), matches: ($m[:3])}' 2>/dev/null
}
# --resume-convo NAME ID [fork]: open a past conversation in a new session NAME,
# in the folder it ran in. "fork" continues it as a new conversation and leaves
# the original untouched (claude --fork-session).
cmd_resume_convo() {
if [ $# -lt 2 ] || [ $# -gt 3 ]; then dd_die "usage: devdesk attach --resume-convo NAME ID [fork]"; fi
local name=$1 id=$2 fork=${3:-} file dir
dd_valid_name "$name" || dd_die "invalid session name: $name"
if [ -n "$fork" ] && [ "$fork" != fork ]; then dd_die "the third argument can only be: fork"; fi
file=$(dd_convo_file "$id") || dd_die "no conversation $id"
[ -n "$file" ] || dd_die "no conversation $id"
if dd_server_up && dd_tmux has-session -t "=$name" 2>/dev/null; then dd_die "a session called $name is already running"; fi
# Two Claudes on one transcript would interleave it: resuming one that is
# open elsewhere is refused (forking is fine).
if [ -z "$fork" ]; then
local other
for other in $(jq -rM --arg id "$id" '.sessions | to_entries[] | select(.value.claude_session_id == $id) | .key' "$DEVDESK_REGISTRY" 2>/dev/null); do
if [ "$other" != "$name" ] && dd_tmux has-session -t "=$other" 2>/dev/null; then
dd_die "that conversation is open in session $other: open it there, or fork it"
fi
done
fi
dir=$(jq -rM 'select(.cwd) | .cwd' "$file" 2>/dev/null | head -n1)
[ -n "$dir" ] && [ -d "$dir" ] || dir=$(dd_resolve_dir "$name")
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {dir: $d, claude_session_id: $id, claude_source: "devdesk-history"}
+ (if $fork == "fork" then {fork: true} else {} end))' \
--arg n "$name" --arg d "$dir" --arg id "$id" --arg fork "$fork"
dd_ensure_server
create_detached "$name" "$dir"
}
# --presence SESSION on|off (devdesk#26): while a DevDesk tab shows a session,
# its Claude sees a client-presence marker (CLAUDE_CLIENT_PRESENCE_FILE, set by
# devdesk-claude-run per tmux session) and Remote Control holds back phone
# pushes. The browser refreshes it every ~45 s; it disappears ~2 minutes after
# the last refresh, so pushes resume once you walk away.
cmd_presence() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --presence SESSION on|off"
local session=$1 state=$2 sid marker
dd_valid_name "$session" || dd_die "invalid session name: $session"
dd_server_up || return 0
sid=$(dd_tmux display -p -t "=$session:" '#{session_id}' 2>/dev/null) || return 0
marker="$DEVDESK_PRESENCE_DIR/${sid#\$}"
if [ "$state" = off ]; then
rm -f "$marker"
return 0
fi
[ "$state" = on ] || dd_die "presence is on or off"
mkdir -p "$DEVDESK_PRESENCE_DIR" && touch "$marker"
# Expire it unless refreshed meanwhile.
# (fd 3 closed too: a reaper must not hold a caller's extra descriptors open)
setsid -f bash -c 'sleep 120; [ -n "$(find "$1" -newermt "-110 seconds" 2>/dev/null)" ] || rm -f "$1"' _ "$marker" \
</dev/null >/dev/null 2>&1 3>&- || true
}
+114
View File
@@ -0,0 +1,114 @@
# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables
# Answer a session without switching to it (devdesk#22, #23), sourced by
# bin/devdesk and reached through `devdesk attach --answer|--peek|--reply`.
dd_state_file() {
printf '%s/%s.json\n' "$DEVDESK_STATUS_DIR" "$1"
}
# The Claude window's visible screen (not the scrollback).
dd_claude_screen() {
dd_tmux capture-pane -p -J -t "=$1:claude" 2>/dev/null
}
# --answer SESSION SINCE allow|deny: press the permission prompt's key in that
# session's Claude window. Only when the same wait is still on (SINCE) and the
# prompt is actually on screen, so a stale button never types into Claude.
cmd_answer() {
[ $# -eq 3 ] || dd_die "usage: devdesk attach --answer SESSION SINCE allow|deny"
local session=$1 since=$2 choice=$3 file
dd_valid_name "$session" || dd_die "invalid session name: $session"
[[ $since =~ ^[0-9]+$ ]] || dd_die "bad wait id: $since"
case $choice in allow | deny) ;; *) dd_die "answer allow or deny" ;; esac
if ! { dd_server_up && dd_tmux has-session -t "=$session" 2>/dev/null; }; then dd_die "session '$session' isn't running"; fi
file=$(dd_state_file "$session")
if [ "$(jq -rM '"\(.state) \(.since) \(.request != null)"' "$file" 2>/dev/null)" != "waiting $since true" ]; then
dd_die "nothing to answer: it was already answered"
fi
# Every permission prompt lists "1. Yes" first (Bash, edits, fetches, MCP).
if ! dd_claude_screen "$session" | tail -n 40 | grep -Eq '^[[:space:]]*(❯[[:space:]]*)?1\.[[:space:]]+Yes'; then
dd_die "the permission prompt isn't on screen any more"
fi
if [ "$choice" = allow ]; then
dd_tmux send-keys -t "=$session:claude" 1
else
dd_tmux send-keys -t "=$session:claude" Escape
fi
# A denial doesn't fire a hook; the turn ends there. An approval is followed
# by PostToolUse, which takes it from here.
local tmp
tmp=$(mktemp "$DEVDESK_STATUS_DIR/.$session.XXXXXX") || return 0
if jq -cMn --arg s "$([ "$choice" = allow ] && echo working || echo idle)" --arg m "$([ "$choice" = allow ] && echo "Allowed from DevDesk" || echo "Denied from DevDesk")" \
'{state: $s, since: (now | floor), event: "DevDeskAnswer", message: $m}' >"$tmp"; then
mv -f "$tmp" "$file"
else
rm -f "$tmp"
fi
}
# The session's conversation file, from the id the SessionStart hook recorded.
dd_transcript() {
local id
id=$(dd_registry_get "$1" claude_session_id)
[[ $id =~ ^[0-9a-f-]{36}$ ]] || return 1
compgen -G "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/*/$id.jsonl" | head -n1
}
# --peek SESSION: what the session is doing and the last exchange, from the end
# of its transcript (the last assistant text and the last thing you asked).
cmd_peek() {
[ $# -eq 1 ] || dd_die "usage: devdesk attach --peek SESSION"
local session=$1 transcript status commands
dd_valid_name "$session" || dd_die "invalid session name: $session"
status=$(jq -cM . "$(dd_state_file "$session")" 2>/dev/null) || status=null
# The last commands this session ran (devdesk#27).
commands=$(tail -n 8 "$DEVDESK_STATE_DIR/commands/$session.jsonl" 2>/dev/null | jq -cMs '.' 2>/dev/null) || commands='[]'
[ -n "$commands" ] || commands='[]'
transcript=$(dd_transcript "$session") || transcript=""
if [ -z "$transcript" ] || [ ! -r "$transcript" ]; then
jq -cMn --arg s "$session" --argjson st "${status:-null}" --argjson c "$commands" '{session: $s, status: $st, assistant: null, user: null, commands: $c}'
return
fi
tail -n 400 -- "$transcript" | jq -cMs --arg s "$session" --argjson st "${status:-null}" --argjson c "$commands" '
def text: if type == "string" then . else ([.[]? | select(.type == "text") | .text] | join("\n")) end;
[.[] | select(.isSidechain != true and .isMeta != true)] as $all
| ([$all[] | select(.type == "assistant") | {text: (.message.content | text), at: .timestamp} | select(.text != "")] | last) as $a
| ([$all[] | select(.type == "user" and (.message.content | type == "string" or (any(.[]?; .type == "text"))))
| {text: (.message.content | text), at: .timestamp}
| select(.text != "" and (.text | startswith("<") | not))] | last) as $u
| {session: $s, status: $st,
assistant: (if $a then {text: ($a.text | .[-4000:]), at: $a.at, truncated: ($a.text | length > 4000)} else null end),
user: (if $u then {text: ($u.text | .[-600:]), at: $u.at} else null end), commands: $c}' 2>/dev/null ||
jq -cMn --arg s "$session" --argjson st "${status:-null}" --argjson c "$commands" '{session: $s, status: $st, assistant: null, user: null, commands: $c}'
}
# --reply SESSION BYTES: type a message into the session's Claude prompt and
# send it. The text arrives as base64 after the READY line (like uploads) and is
# pasted as one bracketed paste, so newlines in it don't send it early.
cmd_reply() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --reply SESSION BYTES"
local session=$1 size=$2 file tmp b64len
dd_valid_name "$session" || dd_die "invalid session name: $session"
if ! [[ $size =~ ^[0-9]+$ ]] || [ "$size" -lt 1 ] || [ "$size" -gt 16384 ]; then dd_die "a reply is 1 to 16384 bytes"; fi
if ! { dd_server_up && dd_tmux has-session -t "=$session" 2>/dev/null; }; then dd_die "session '$session' isn't running"; fi
file=$(dd_state_file "$session")
if [ "$(jq -rM '"\(.state) \(.request != null)"' "$file" 2>/dev/null)" = "waiting true" ]; then
dd_die "Claude is asking for a permission: answer that first"
fi
tmp=$(mktemp)
trap 'rm -f "$tmp"' 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-REPLY-READY\r\n'
head -c "$b64len" | base64 -d >"$tmp" 2>/dev/null || true
stty sane 2>/dev/null || true
if [ "$(stat -c %s "$tmp")" != "$size" ]; then
rm -f "$tmp"
dd_die "reply incomplete"
fi
dd_tmux load-buffer -b devdesk-reply "$tmp" && dd_tmux paste-buffer -p -d -b devdesk-reply -t "=$session:claude" &&
sleep 0.2 && dd_tmux send-keys -t "=$session:claude" Enter
rm -f "$tmp"
trap - HUP INT TERM
}
+22 -1
View File
@@ -37,14 +37,26 @@ dd_load_env
: "${DEVDESK_PUBLIC_URL:=}"
: "${DEVDESK_MATRIX_WEBHOOK_FILE:=/etc/devdesk/matrix-webhook}"
: "${DEVDESK_MATRIX_DELAY:=30}"
: "${DEVDESK_PRESENCE_DIR:=/run/devdesk/presence}"
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
}
@@ -121,7 +133,8 @@ dd_registry_set_claude() {
dd_registry_remove() {
dd_registry_update 'del(.sessions[$n])' --arg n "$1"
rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json"
rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" "$DEVDESK_STATE_DIR/edits/$1.txt" \
"$DEVDESK_STATE_DIR/commands/$1.jsonl" "$DEVDESK_STATE_DIR/tags/$1.json"
}
dd_registry_rename() {
@@ -133,6 +146,14 @@ dd_registry_rename() {
mv -f "$d/$1.json" "$d/$2.json"
fi
done
local f
for f in "edits/%s.txt" "commands/%s.jsonl" "tags/%s.json"; do
# shellcheck disable=SC2059 # the pattern is ours
if [ -f "$DEVDESK_STATE_DIR/$(printf "$f" "$1")" ]; then
# shellcheck disable=SC2059
mv -f "$DEVDESK_STATE_DIR/$(printf "$f" "$1")" "$DEVDESK_STATE_DIR/$(printf "$f" "$2")"
fi
done
}
# Project name (directory or its slug) -> absolute directory inside the projects root.
+5 -1
View File
@@ -27,8 +27,12 @@ attached=$(dd_tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev
[ "${attached:-0}" = 0 ] || exit 0
message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null)
detail=$(jq -rM '.request.detail // empty' "$state_file" 2>/dev/null)
[ -z "$detail" ] || message="$message: ${detail:0:300}"
url=""
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session"
# existing=1: if the session was ended by the time you tap, say so instead of
# starting a new Claude there (devdesk#38).
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session&existing=1"
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))}')
+3
View File
@@ -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
+68 -5
View File
@@ -2,6 +2,9 @@
# 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
# PermissionRequest -> waiting, with what Claude wants to run
# (tool + command/file/URL), for the inbox's
# Allow/Deny (devdesk#22)
# Notification -> waiting (Claude needs input or a permission)
# Stop, SessionStart -> idle
# SessionEnd -> exited
@@ -16,10 +19,56 @@ outer=${TMUX:-}
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
IFS=$'\t' read -r event message < <(jq -rM '[.hook_event_name // "", (.message // "" | gsub("[\t\n]"; " "))] | @tsv' <<<"$input" 2>/dev/null)
# 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
# Ops trail (devdesk#27): every Bash command, and the hosts a session works on
# (ssh targets, Proxmox CT/VM ids) as tags for the session list.
if [ "$event:$tool" = PostToolUse:Bash ]; then
cmd=$(jq -rM '.tool_input.command // empty' <<<"$input" 2>/dev/null)
if [ -n "$cmd" ]; then
base="${DEVDESK_STATE_DIR:-/var/lib/devdesk}"
mkdir -p "$base/commands" "$base/tags" 2>/dev/null
log="$base/commands/$session.jsonl"
jq -cMn --arg c "${cmd:0:500}" '{at: (now | floor), cmd: $c}' >>"$log" 2>/dev/null
if [ "$(wc -l <"$log" 2>/dev/null || echo 0)" -gt 600 ]; then
tail -n 500 "$log" >"$log.tmp" 2>/dev/null && mv -f "$log.tmp" "$log"
fi
hosts=$(
# ssh [options] [user@]host: the first word that isn't an option (or an option's value)
awk '{ for (i = 1; i <= NF; i++) if ($i == "ssh") { j = i + 1
while (j <= NF && $j ~ /^-/) { if ($j ~ /^-[bcDEeFIiJLlmOopQRSWw]$/) j++; j++ }
if (j <= NF) { h = $j; sub(/^[^@]*@/, "", h); gsub(/[^A-Za-z0-9_.-].*$/, "", h); if (h != "") print h } } }' <<<"$cmd"
grep -oE '\bpct[[:space:]]+(exec|enter|push|pull|config|set|start|stop|shutdown|reboot|status|snapshot|rollback|migrate)[[:space:]]+[0-9]{3,}' <<<"$cmd" | grep -oE '[0-9]+$' | sed 's/^/CT /'
grep -oE '\bqm[[:space:]]+(guest[[:space:]]+exec|terminal|config|set|start|stop|shutdown|reboot|status|snapshot|rollback|migrate)[[:space:]]+[0-9]{3,}' <<<"$cmd" | grep -oE '[0-9]+$' | sed 's/^/VM /'
)
if [ -n "$hosts" ]; then
tags="$base/tags/$session.json"
jq -cMn --rawfile new <(printf '%s\n' "$hosts") --slurpfile old <(cat "$tags" 2>/dev/null || echo '{}') '
($new | split("\n") | map(select(length > 0))) as $n
| {hosts: ($n + ($old[0].hosts // []) | reduce .[] as $h ([]; if index([$h]) then . else . + [$h] end) | .[:4]),
at: (now | floor)}' >"$tags.tmp" 2>/dev/null && mv -f "$tags.tmp" "$tags"
fi
fi
fi
case $event in
UserPromptSubmit | PostToolUse) state=working ;;
Notification) state=waiting ;;
Notification | PermissionRequest) state=waiting ;;
Stop | SessionStart) state=idle ;;
SessionEnd) state=exited ;;
*) exit 0 ;;
@@ -31,19 +80,33 @@ file="$dir/$session.json"
if [ "$state" != waiting ] && [ -f "$file" ] && grep -q "\"state\":\"$state\"" "$file"; then
exit 0
fi
# What a permission prompt is about. tool_use_id is null in PermissionRequest,
# so a wait is identified by its `since` instead.
request=null
if [ "$event" = PermissionRequest ]; then
request=$(jq -cM '(.tool_input // {}) as $i | {tool: (.tool_name // "a tool"),
detail: (($i.command // $i.file_path // $i.notebook_path // $i.url // $i.pattern // $i.query // $i.path // ($i | tostring)) | tostring | .[0:600]),
description: ($i.description // null)}' <<<"$input" 2>/dev/null) || request=null
message="Claude wants to use ${tool:-a tool}"
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` (and what the wait is about), 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)
if [ "$request" = null ]; then
request=$(jq -cM '.request // null' "$file" 2>/dev/null) || request=null
[ "$request" = null ] || message=$(jq -rM '.message // empty' "$file" 2>/dev/null)
fi
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
if jq -cMn --arg s "$state" --arg m "$message" --arg e "$event" --argjson t "$since" --argjson r "${request:-null}" \
'{state: $s, since: $t, event: $e, message: (if $m == "" then null else $m end)} + (if $r then {request: $r} else {} end)' >"$tmp"; then
mv -f "$tmp" "$file"
else
rm -f "$tmp"
+15 -9
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# Claude Code statusLine command (wired in via claude-settings.json). Claude pipes
# its status JSON in on every update; we keep a snapshot per DevDesk session for
# the web UI's Claude Context panel and print a one-line status for Claude's footer.
# the web UI (context, plan usage, PR) and print a one-line status for Claude's footer.
# Runs often while Claude streams, so it stays cheap and never fails loudly.
input=$(cat)
@@ -29,7 +29,12 @@ if [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then
+ (.cache_read_input_tokens // 0)) else null end),
cost_usd: .cost.total_cost_usd,
lines_added: .cost.total_lines_added,
lines_removed: .cost.total_lines_removed
lines_removed: .cost.total_lines_removed,
# devdesk#25: plan usage (account-wide) and the pull request of the branch
rate_limits: (.rate_limits // null | if . then {
five_hour: (.five_hour | if . then {pct: .used_percentage, resets_at} else null end),
seven_day: (.seven_day | if . then {pct: .used_percentage, resets_at} else null end)} else null end),
pr: (.pr // null | if . then {number, url, review_state, kind} else null end)
}' <<<"$input" >"$tmp" 2>/dev/null; then
mv -f "$tmp" "$dir/$session.json"
else
@@ -38,12 +43,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"
}
}
+24
View File
@@ -1,5 +1,6 @@
{
"preferredNotifChannel": "terminal_bell",
"theme": "dark-ansi",
"hooks": {
"SessionStart": [
{
@@ -72,6 +73,29 @@
}
]
}
],
"PermissionRequest": [
{
"hooks": [
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-state-hook",
"timeout": 5
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-guard",
"timeout": 5
}
]
}
]
},
"statusLine": {
+12 -9
View File
@@ -24,18 +24,21 @@ set -g set-titles-string "devdesk:#S|#T"
# --- every new session gets a spare shell next to Claude ---------------------
set-hook -g session-created "new-window -d -n shell"
# --- LotusGuild TDS colours ---------------------------------------------------
# --- colours ---------------------------------------------------------------------
# ANSI colour names, not hex: the browser maps them through its dark or light
# palette, so the status bar follows DevDesk's theme (devdesk#31). black is the
# bar, white muted text, cyan the accent, yellow the session name.
set -g status-position bottom
set -g status-style "bg=#060c14,fg=#7fa3bf"
set -g status-left "#[fg=#FF6B00,bold] #S #[fg=#3e607a,nobold]│ "
set -g status-style "bg=black,fg=white"
set -g status-left "#[fg=yellow,bold] #S #[fg=brightblack,nobold]│ "
set -g status-left-length 40
set -g status-right ""
set -g window-status-format " #I:#W "
set -g window-status-current-format "#[fg=#030508,bg=#00D4FF,bold] #I:#W "
set -g window-status-bell-style "fg=#FFB300,bold"
set -g message-style "bg=#0d1520,fg=#00D4FF"
set -g mode-style "bg=#00D4FF,fg=#030508"
set -g pane-border-style "fg=#0d1520"
set -g pane-active-border-style "fg=#00D4FF"
set -g window-status-current-format "#[fg=cyan,reverse,bold] #I:#W "
set -g window-status-bell-style "fg=yellow,bold"
set -g message-style "bg=black,fg=cyan"
set -g mode-style "fg=cyan,reverse"
set -g pane-border-style "fg=brightblack"
set -g pane-active-border-style "fg=cyan"
source-file -q /etc/devdesk/tmux.local.conf
+375 -1
View File
@@ -310,7 +310,7 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
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')" ]
[ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha&existing=1\ttrue')" ]
# answered within the delay: nothing is posted
echo '{"hook_event_name":"PostToolUse"}' | hook
@@ -327,6 +327,25 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
kill "$RECEIVER"
}
@test "under ttyd, one-shot replies wait for the browser to hang up (#35)" {
start_server
# No tty (CLI, tests): no waiting.
start=$(date +%s%N)
DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=2 devdesk attach --list >/dev/null
[ $(( ($(date +%s%N) - start) / 1000000 )) -lt 1500 ]
# On a tty, as under ttyd: the reply is printed, then it waits. script's
# stdin stays open (like a browser that hasn't hung up); a closed stdin
# would hand the command EOF at once.
start=$(date +%s%N)
run bash -c "sleep 2 | script -qec \"DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=0.6 '$REPO/bin/devdesk' attach --list\" /dev/null"
elapsed=$(( ($(date +%s%N) - start) / 1000000 ))
[[ $output == *'"sessions"'* ]]
[ "$elapsed" -ge 500 ]
# Errors are printed before the wait, too.
run bash -c "sleep 1 | script -qec \"DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=0.3 '$REPO/bin/devdesk' attach --rename nosuch x\" /dev/null"
[[ $output == *"no session named"* ]]
}
@test "alerts reports unconfigured and unreachable Grafana cleanly" {
run env DEVDESK_GRAFANA_URL= devdesk alerts
[ "$status" -eq 0 ]
@@ -472,3 +491,358 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
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')" ]
}
# Save through --fs-write the way the browser does: content as base64 on stdin.
fs_write() { # SESSION PATH BASE CONTENT-FILE
local size
size=$(stat -c %s "$4")
base64 -w0 "$4" | devdesk attach --fs-write "$1" "$2" "$size" "$3"
}
@test "fs-write saves atomically, keeps the mode, and refuses stale bases (#20)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
printf 'v1\n' >"$p/conf.sh"
chmod 0750 "$p/conf.sh"
base=$(sha256sum "$p/conf.sh" | cut -d' ' -f1)
printf 'v2\n' >"$TMP/new"
run fs_write alpha conf.sh "$base" "$TMP/new"
[ "$status" -eq 0 ]
[ "$(tail -n1 <<<"$output" | jq -r '.ok')" = true ]
[ "$(cat "$p/conf.sh")" = v2 ]
[ "$(stat -c %a "$p/conf.sh")" = 750 ]
[ "$(tail -n1 <<<"$output" | jq -r '.sha')" = "$(sha256sum "$p/conf.sh" | cut -d' ' -f1)" ]
# Someone (Claude) changed it since: the old base no longer matches.
printf 'v3 from me\n' >"$TMP/mine"
run fs_write alpha conf.sh "$base" "$TMP/mine"
[ "$(tail -n1 <<<"$output" | jq -r '[.ok, .conflict] | @tsv')" = "$(printf 'false\ttrue')" ]
[ "$(cat "$p/conf.sh")" = v2 ]
# No temp files left behind
[ -z "$(find "$p" -name '*.devdesk-*')" ]
# Empty files are fine
: >"$TMP/empty"
base=$(sha256sum "$p/conf.sh" | cut -d' ' -f1)
run fs_write alpha conf.sh "$base" "$TMP/empty"
[ "$(tail -n1 <<<"$output" | jq -r '.ok')" = true ]
[ ! -s "$p/conf.sh" ]
}
@test "fs-write creates new files (and folders) but never overwrites or escapes (#20)" {
p="$DEVDESK_PROJECTS_ROOT/alpha"
printf 'hello\n' >"$TMP/c"
run fs_write alpha docs/notes/new.md new "$TMP/c"
[ "$(tail -n1 <<<"$output" | jq -r '[.ok, .path] | @tsv')" = "$(printf 'true\tdocs/notes/new.md')" ]
[ "$(cat "$p/docs/notes/new.md")" = hello ]
run fs_write alpha docs/notes/new.md new "$TMP/c"
[ "$status" -ne 0 ]
[[ $output == *"already exists"* ]]
ln -s "$TMP/outside" "$p/out"
for bad in ../escape.txt /etc/x out/pwned.txt out/newdir/x.txt docs/ ""; do
run fs_write alpha "$bad" new "$TMP/c"
[ "$status" -ne 0 ]
done
[ ! -e "$TMP/outside/pwned.txt" ] && [ ! -e "$TMP/outside/newdir" ] && [ ! -e "$DEVDESK_PROJECTS_ROOT/escape.txt" ]
# A short stream fails without touching anything
run bash -c "printf aGVs | '$REPO/bin/devdesk' attach --fs-write alpha docs/notes/new.md 6 $(sha256sum "$p/docs/notes/new.md" | cut -d' ' -f1)"
[ "$status" -ne 0 ]
[ "$(cat "$p/docs/notes/new.md")" = hello ]
}
@test "permission requests are recorded and answered only while their prompt shows (#22)" {
start_server
devdesk new alpha
claude_args alpha >/dev/null
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":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/x","description":"Remove x"}}' | hook
echo '{"hook_event_name":"Notification","message":"Claude needs your permission","notification_type":"permission_prompt"}' | hook
st="$DEVDESK_STATE_DIR/state/alpha.json"
[ "$(jq -r '[.state, .request.tool, .request.detail, .request.description, .message] | @tsv' "$st")" = \
"$(printf 'waiting\tBash\trm -rf /tmp/x\tRemove x\tClaude wants to use Bash')" ]
since=$(jq -r .since "$st")
# The fake Claude window shows no prompt: refused, nothing typed.
run devdesk attach --answer alpha "$since" allow
[ "$status" -ne 0 ]
[[ $output == *"isn't on screen"* ]]
# Put a prompt on screen (what Claude draws), then allow.
tmux -S "$DEVDESK_SOCKET" respawn-window -k -t =alpha:claude "printf ' Do you want to proceed?\n ❯ 1. Yes\n 2. No\n'; read -r -n1 k; echo \"got:\$k\" > '$TMP/key'; sleep 30"
sleep 0.5
run devdesk attach --answer alpha 999 allow
[ "$status" -ne 0 ]
[[ $output == *"already answered"* ]]
run devdesk attach --answer alpha "$since" allow
[ "$status" -eq 0 ]
sleep 0.5
[ "$(cat "$TMP/key")" = "got:1" ]
[ "$(jq -r .state "$st")" = working ]
# Answered: a second click is refused.
run devdesk attach --answer alpha "$since" deny
[ "$status" -ne 0 ]
}
@test "peek shows the last exchange; reply pastes into the Claude window (#23)" {
start_server
devdesk new alpha
claude_args alpha >/dev/null
export CLAUDE_CONFIG_DIR="$TMP/claude"
id=11111111-2222-3333-4444-555555555555
mkdir -p "$CLAUDE_CONFIG_DIR/projects/-x"
cat >"$CLAUDE_CONFIG_DIR/projects/-x/$id.jsonl" <<'JSONL'
{"type":"user","message":{"role":"user","content":"Please upgrade PBS"},"timestamp":"2026-10-10T10:00:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{}}]},"timestamp":"2026-10-10T10:00:05Z"}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"ok"}]},"timestamp":"2026-10-10T10:00:06Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"PBS is on 4.1. Reboot now?"}]},"timestamp":"2026-10-10T10:00:09Z"}
JSONL
jq --arg id "$id" '.sessions.alpha.claude_session_id = $id' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
run devdesk attach --peek alpha
[ "$status" -eq 0 ]
[ "$(jq -r '[.assistant.text, .user.text] | @tsv' <<<"$output")" = "$(printf 'PBS is on 4.1. Reboot now?\tPlease upgrade PBS')" ]
# reply: lands in the Claude window as one paste + Enter
tmux -S "$DEVDESK_SOCKET" respawn-window -k -t =alpha:claude "IFS= read -r line; printf '%s' \"\$line\" > '$TMP/got'; sleep 30"
sleep 0.3
printf 'yes, reboot it' >"$TMP/msg"
run bash -c "base64 -w0 '$TMP/msg' | '$REPO/bin/devdesk' attach --reply alpha 14"
[ "$status" -eq 0 ]
sleep 0.5
[ "$(cat "$TMP/got")" = "yes, reboot it" ]
# not while a permission prompt waits
mkdir -p "$DEVDESK_STATE_DIR/state"
printf '{"state":"waiting","since":1,"request":{"tool":"Bash"}}\n' >"$DEVDESK_STATE_DIR/state/alpha.json"
run bash -c "base64 -w0 '$TMP/msg' | '$REPO/bin/devdesk' attach --reply alpha 14"
[ "$status" -ne 0 ]
[[ $output == *"answer that first"* ]]
}
@test "the guard asks before destructive commands and lets the rest through (#27)" {
guard() { jq -cn --arg c "$1" '{hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: {command: $c}}' | "$REPO/libexec/devdesk-guard"; }
asks=("pct destroy 105" "ssh root@large1 'pct destroy 105 --purge'" "qm destroy 210" "zfs destroy rpool/data/vm-105-disk-0"
"zpool destroy tank" "ceph osd pool delete rbd rbd --yes-i-really-really-mean-it" "rbd rm vm-105-disk-0"
"rm -rf /" "rm -rf /etc" "sudo rm -r -f /var/" "dd if=/dev/zero of=/dev/sdb bs=1M" "mkfs.ext4 /dev/sdb1" "wipefs -a /dev/sdc"
"git push --force origin main" "git push -f" "psql -c 'drop table users'" "systemctl restart devdesk-tmux"
"ssh pve1 reboot" "systemctl poweroff" "cd /tmp && shutdown -h now")
for c in "${asks[@]}"; do
run guard "$c"
[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" = ask ] || { echo "should ask: $c"; false; }
done
passes=("pct list" "pct exec 105 -- df -h" "zfs list" "rm -rf /tmp/build-cache" "rm -rf node_modules" "rm -rf ./dist"
"git push origin main" "dd if=a.img of=b.img" "echo reboot later" "systemctl restart devdesk" "ceph -s" "ls -la /")
for c in "${passes[@]}"; do
run guard "$c"
[ -z "$output" ] || { echo "should pass: $c -> $output"; false; }
done
# Local rules and the off switch
printf 'nuke-it\tis a local rule\n' >"$TMP/guard.local"
run env DEVDESK_GUARD_LOCAL="$TMP/guard.local" bash -c "jq -cn '{tool_input: {command: \"nuke-it now\"}}' | '$REPO/libexec/devdesk-guard'"
[[ $output == *"is a local rule"* ]]
run env DEVDESK_GUARD=0 bash -c "jq -cn '{tool_input: {command: \"pct destroy 1\"}}' | '$REPO/libexec/devdesk-guard'"
[ -z "$output" ]
}
@test "Bash commands are logged and tag the session with the hosts it works on (#27)" {
start_server
devdesk new alpha
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
bash_cmd() { jq -cn --arg c "$1" '{hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: {command: $c}}' |
TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
bash_cmd "ssh -o BatchMode=yes root@large1 'pct exec 167 -- systemctl status authelia'"
bash_cmd "ls -la"
bash_cmd "ssh pve2 qm status 210"
[ "$(wc -l <"$DEVDESK_STATE_DIR/commands/alpha.jsonl")" -eq 3 ]
run devdesk attach --list
[ "$(jq -r '.sessions[] | select(.name == "alpha") | .tags | join(",")' <<<"$output")" = "pve2,VM 210,large1,CT 167" ]
run devdesk attach --peek alpha
[ "$(jq -r '.commands | map(.cmd) | .[1]' <<<"$output")" = "ls -la" ]
devdesk rename alpha beta
[ -f "$DEVDESK_STATE_DIR/commands/beta.jsonl" ] && [ -f "$DEVDESK_STATE_DIR/tags/beta.json" ]
devdesk end beta
[ ! -e "$DEVDESK_STATE_DIR/commands/beta.jsonl" ] && [ ! -e "$DEVDESK_STATE_DIR/tags/beta.json" ]
}
# A fake Claude history: one conversation in $1 with id $2.
fake_convo() {
mkdir -p "$CLAUDE_CONFIG_DIR/projects/-p"
jq -cn --arg cwd "$1" --arg id "$2" '
{type: "user", cwd: $cwd, sessionId: $id, timestamp: "2026-08-01T10:00:00Z", message: {role: "user", content: "Fix the NFS mount on pbs"}},
{type: "assistant", timestamp: "2026-08-01T10:00:05Z", message: {role: "assistant", content: [{type: "tool_use", name: "Bash", input: {command: "mount -t nfs 10.10.10.9:/backup /mnt/pbs"}}]}},
{type: "assistant", timestamp: "2026-08-01T10:00:09Z", message: {role: "assistant", content: [{type: "text", text: "The NFS export needed no_root_squash."}]}}' \
>"$CLAUDE_CONFIG_DIR/projects/-p/$2.jsonl"
}
@test "search finds past conversations by what was said and run (#24)" {
export CLAUDE_CONFIG_DIR="$TMP/claude"
fake_convo "$DEVDESK_PROJECTS_ROOT/alpha" 11111111-1111-1111-1111-111111111111
run devdesk attach --search "no_root_squash"
[ "$status" -eq 0 ]
[ "$(jq -r '.results[0] | [.id, .title, .cwd, .matches[0].role] | @tsv' <<<"$output")" = \
"$(printf '11111111-1111-1111-1111-111111111111\tFix the NFS mount on pbs\t%s\tclaude' "$DEVDESK_PROJECTS_ROOT/alpha")" ]
run devdesk attach --search "MOUNT -t NFS"
[ "$(jq -r '.results[0].matches[0].role' <<<"$output")" = command ]
run devdesk attach --search "nothing like this anywhere"
[ "$(jq -r '.results | length' <<<"$output")" = 0 ]
run devdesk attach --search "x"
[ "$status" -ne 0 ]
}
@test "resume-convo opens a past conversation in its folder; fork is passed once (#24)" {
export CLAUDE_CONFIG_DIR="$TMP/claude"
id=22222222-2222-2222-2222-222222222222
fake_convo "$DEVDESK_PROJECTS_ROOT/alpha" "$id"
start_server
run devdesk attach --resume-convo nfs-fix "$id" fork
[ "$status" -eq 0 ]
claude_args nfs-fix >/dev/null
[[ "$(claude_args nfs-fix)" == "--resume $id --fork-session --settings"* ]]
[ "$(tmux -S "$DEVDESK_SOCKET" display -p -t =nfs-fix:claude '#{pane_current_path}')" = "$DEVDESK_PROJECTS_ROOT/alpha" ]
[ "$(jq -r '.sessions["nfs-fix"].fork // "cleared"' "$DEVDESK_STATE_DIR/registry.json")" = cleared ]
# Resuming (not forking) the same conversation while it's open elsewhere is refused.
jq --arg id "$id" '.sessions["nfs-fix"].claude_session_id = $id' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
run devdesk attach --resume-convo nfs-again "$id"
[ "$status" -ne 0 ]
[[ $output == *"open in session nfs-fix"* ]]
run devdesk attach --resume-convo nfs-fix "$id" fork
[ "$status" -ne 0 ]
[[ $output == *"already running"* ]]
}
@test "presence marks a session while a tab shows it (#26)" {
export DEVDESK_PRESENCE_DIR="$TMP/presence"
start_server
devdesk new alpha
claude_args alpha >/dev/null
sid=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha: '#{session_id}')
run devdesk attach --presence alpha on
[ "$status" -eq 0 ]
[ -e "$TMP/presence/${sid#\$}" ]
# The Claude window was told where the marker lives.
[ "$(tmux -S "$DEVDESK_SOCKET" show-environment -t =alpha CLAUDE_CLIENT_PRESENCE_FILE 2>/dev/null || true)" = "" ] # set per process, not per session
run devdesk attach --presence alpha off
[ ! -e "$TMP/presence/${sid#\$}" ]
}
+12
View File
@@ -86,3 +86,15 @@ test('untrusted text loses control characters before it is typed (#2)', () => {
assert.equal(pasteSafe('plain text'), 'plain text');
assert.equal(pasteSafe(null), '');
});
test('file paths in terminal output are found, URLs and plain words are not (#21)', async () => {
const { findPaths } = await import('../web/src/links.js');
const paths = (text) => findPaths(text).map((p) => `${p.path}${p.line ? `:${p.line}` : ''}`);
assert.deepEqual(paths('● Update(web/src/files.js)'), ['web/src/files.js']);
assert.deepEqual(paths(' ⎿ Wrote 12 lines to notes/todo.md'), ['notes/todo.md']);
assert.deepEqual(paths('error at src/app.py:42:7 and ./README.md:3'), ['src/app.py:42', './README.md:3']);
assert.deepEqual(paths('see /root/code/devdesk/Makefile'), ['/root/code/devdesk/Makefile']);
assert.deepEqual(paths('open https://code.lotusguild.org/a/b.js now'), []);
// Candidates only: they become links only if they're files of the session.
assert.deepEqual(paths('just some words, v1.2 and e.g. stuff'), ['v1.2']);
});
+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) => {
+195
View File
@@ -0,0 +1,195 @@
// 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, indentWithTab } 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 : [],
];
}
// Put the cursor on a line (1-based) and scroll it to the middle.
function gotoLine(view, line) {
const target = view.state.doc.line(Math.max(1, Math.min(line, view.state.doc.lines)));
view.dispatch({ selection: { anchor: target.from, head: target.to }, effects: EditorView.scrollIntoView(target.from, { y: 'center' }) });
view.focus();
}
// Read-only 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, goto: (line) => gotoLine(view, line), destroy: () => view.destroy() };
}
// Editable file (devdesk#20): onChange(text) after every edit, onSave() on
// Ctrl/Cmd+S (which never reaches the browser or the terminal).
export function editor(parent, { doc, path, wrap, onChange, onSave }) {
const view = new EditorView({
parent,
state: EditorState.create({
doc,
extensions: [
keymap.of([{ key: 'Mod-s', preventDefault: true, run: () => { onSave?.(); return true; } }, indentWithTab]),
baseExtensions(path, { readOnly: false, wrap }),
EditorView.updateListener.of((update) => {
if (update.docChanged) onChange?.(update.state.doc.toString());
}),
],
}),
});
return {
view,
goto: (line) => gotoLine(view, line),
text: () => view.state.doc.toString(),
// Replace the whole document (reload from disk) keeping the cursor roughly where it was.
setText: (text) => {
const pos = Math.min(view.state.selection.main.head, text.length);
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text }, selection: { anchor: pos } });
},
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 } });
}
+55 -2
View File
@@ -3,11 +3,20 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
<meta name="color-scheme" content="dark">
<meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#030508">
<meta name="generator" content="devdesk">
<title>DevDesk</title>
<link rel="icon" id="favicon" href="__FAVICON__">
<script>
// Theme before first paint (same key as web_template's lt.theme), no dark flash.
try {
var t = localStorage.getItem('lt_theme');
if (t !== 'light' && t !== 'dark') t = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', t);
document.documentElement.style.colorScheme = t;
} catch (e) { /* storage blocked: stay dark */ }
</script>
<!--STYLES-->
</head>
<body class="dd-app">
@@ -31,6 +40,16 @@
<span class="lt-ws-status dd-ws" id="ws-status" data-state="connecting" role="status">
<span class="lt-dot"></span><span id="ws-label">connecting</span>
</span>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm dd-cmd-btn" id="cmd-toggle" title="Command palette (Ctrl+Shift+P)" aria-label="Open command palette">⌕<span class="dd-cmd-kbd">&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>
@@ -42,12 +61,18 @@
<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>
<div class="dd-menu-row dd-usage-row" id="usage-row" hidden><span>Plan usage</span><span class="dd-usage" id="usage-menu"></span></div>
<button type="button" class="dd-menu-item" id="history-menu" role="menuitem">Search past conversations</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-panels" id="sidebar-toggle" aria-controls="sidebar" aria-expanded="false">Panels<span class="dd-badge" id="panels-badge" hidden></span></button>
<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>
@@ -67,6 +92,30 @@
</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-new" title="New file" aria-label="New file">+</button>
<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">
@@ -128,7 +177,11 @@
<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-usage dd-footer-usage" id="usage-footer" hidden></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>
+7 -2
View File
@@ -2,7 +2,7 @@
// each with an "Ask Claude" button that types an investigation prompt into the
// current session (without pressing Enter).
import { el } from './sessions.js';
import { el } from './dom.js';
const SEVERITY = {
critical: 'lt-chip-critical',
@@ -60,5 +60,10 @@ export function renderAlerts(root, data) {
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'))));
el('div', { class: 'dd-ask-row' },
el('button', { type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a), title: 'Type the prompt into the current session (not sent)' }, 'Ask Claude'),
el('button', {
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a),
'data-ask-session': `alert-${a.name}`, title: 'Open a separate ops session for this alert and type the prompt there (not sent)',
}, 'In a new session')))));
}
+149 -19
View File
@@ -121,6 +121,19 @@ export function runCommand(args, timeoutMs = 8000) {
return limited(() => runCommandNow(args, timeoutMs));
}
// A full JSON reply, or devdesk's one-line error.
function isComplete(text) {
const clean = stripAnsi(text);
if (/^devdesk: .*\n/m.test(clean)) return true;
if (!/\}\s*$/.test(clean)) return false;
try {
parseListOutput(clean);
return true;
} catch {
return false;
}
}
async function runCommandNow(args, timeoutMs) {
const token = await fetchToken();
return new Promise((resolve, reject) => {
@@ -132,15 +145,34 @@ async function runCommandNow(args, timeoutMs) {
reject(new Error('devdesk did not answer'));
}, timeoutMs);
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
socket.onmessage = (event) => {
const { type, payload } = decodeServer(event.data);
if (type === Server.OUTPUT) chunks.push(payload.slice());
};
socket.onerror = () => {};
socket.onclose = () => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(new TextDecoder().decode(concat(chunks)));
};
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;
});
}
@@ -177,11 +209,43 @@ export function uploadFile(session, file, onProgress) {
}
async function uploadNow(session, file, onProgress) {
const token = await fetchToken();
const name = (file.name || 'pasted-image.png').slice(-100);
const result = await streamIn(['--upload', session, name, String(file.size)], 'DEVDESK-UPLOAD-READY', file, onProgress);
if (!result.ok || !result.path) throw new Error('upload failed');
return result.path;
}
// Answer a session from the inbox (devdesk#22, #23).
export function answerPrompt(session, since, choice) {
return sessionAction('answer', session, String(since), choice);
}
export async function peekSession(session) {
return parseListOutput(await runCommand(['--peek', session], 15000));
}
export async function replyTo(session, text) {
const bytes = new TextEncoder().encode(text);
const result = await limited(() => streamIn(['--reply', session, String(bytes.length)], 'DEVDESK-REPLY-READY', new Blob([bytes])));
if (!result.ok) throw new Error('not sent');
}
// Save a file from the editor (devdesk#20): `--fs-write SESSION PATH BYTES BASE`
// with the content streamed after the ready line. Resolves the server's JSON:
// {ok: true, sha, size, mtime} or {ok: false, conflict: true, sha} when the file
// changed since `base` (a sha256, or "new" to create).
export function writeFile(session, path, bytes, base) {
return limited(() => streamIn(['--fs-write', session, path, String(bytes.length), base], 'DEVDESK-WRITE-READY', new Blob([bytes])));
}
// Run a devdesk command that reads base64 from the terminal after printing
// `ready`: stream `blob` in slices (never all in memory), then resolve the JSON
// reply. Fails on 20 s of inactivity rather than a fixed total (devdesk#9).
async function streamIn(args, ready, blob, onProgress) {
const token = await fetchToken();
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']);
const socket = new WebSocket(wsUrl(window.location, args), ['tty']);
socket.binaryType = 'arraybuffer';
const decoder = new TextDecoder();
let output = '';
@@ -192,15 +256,16 @@ async function uploadNow(session, file, onProgress) {
idle = setTimeout(() => socket.close(), 20000);
};
const send = async () => {
for (let offset = 0; offset < file.size; offset += slice) {
for (let offset = 0; offset < blob.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());
const bytes = new Uint8Array(await blob.slice(offset, offset + slice).arrayBuffer());
socket.send(encodeInput(toBase64(bytes)));
touch();
onProgress?.(Math.min(1, (offset + slice) / file.size));
onProgress?.(Math.min(1, (offset + slice) / blob.size));
}
};
const after = () => output.slice(output.indexOf(ready) + ready.length);
touch();
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
socket.onmessage = (event) => {
@@ -208,26 +273,91 @@ async function uploadNow(session, file, onProgress) {
const { type, payload } = decodeServer(event.data);
if (type !== Server.OUTPUT) return;
output += decoder.decode(payload, { stream: true });
if (!sending && output.includes('DEVDESK-UPLOAD-READY')) {
if (!sending && output.includes(ready)) {
sending = true;
send();
}
if (sending && isComplete(after())) {
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/, ''));
const text = stripAnsi(output.includes(ready) ? after() : output);
try {
const result = parseListOutput(text);
if (result.ok && result.path) {
resolve(result.path);
return;
}
resolve(parseListOutput(text));
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*/, '')));
reject(new Error((message || 'no reply').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();
};
});
}
+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;
}
+1305
View File
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
// Search past conversations (devdesk#24): every Claude conversation on this host
// (DevDesk and code-server), by what you asked, what Claude said, the commands
// it ran and their output. Resume one in a new session, or fork it.
import { runCommand, sessionAction } from './connection.js';
import { parseListOutput, slugify } from './protocol.js';
import { el } from './dom.js';
import { ago } from './sessions.js';
import { openDialog, toast } from './ui.js';
const ROLE = { you: 'You', claude: 'Claude', command: 'Ran', output: 'Output' };
// Text with every case-insensitive occurrence of `query` in <mark>.
function highlight(text, query) {
const nodes = [];
const lower = text.toLowerCase();
const q = query.toLowerCase();
let at = 0;
for (let i = lower.indexOf(q); i >= 0 && q; i = lower.indexOf(q, at)) {
if (i > at) nodes.push(text.slice(at, i));
nodes.push(el('mark', {}, text.slice(i, i + q.length)));
at = i + q.length;
}
nodes.push(text.slice(at));
return nodes;
}
const day = (iso) => (iso ? new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '');
// hooks: sessions() -> session list, open(name), refresh()
export function openHistory(hooks) {
const input = el('input', { class: 'lt-input dd-history-input', type: 'search', placeholder: 'What did you work on? e.g. "NFS mount", "pct exec 167", "no_root_squash"', spellcheck: 'false', autocomplete: 'off' });
const status = el('div', { class: 'dd-dialog-hint' }, 'Searches every Claude conversation on this host: your prompts, Claude\'s answers, the commands it ran and their output.');
const results = el('div', { class: 'dd-history-results' });
let seq = 0;
let timer = 0;
let close = null;
const liveSessionFor = (id) => hooks.sessions().find((s) => s.running && s.claude_session_id === id)?.name;
// Resume or fork: confirm the new session's name inline, then open it.
const start = (result, fork, holder) => {
const name = el('input', { class: 'lt-input', value: slugify(`${fork ? 'fork-' : ''}${result.title}`).slice(0, 40) || 'resumed', spellcheck: 'false' });
const go = async () => {
const value = slugify(name.value);
if (!value) return;
try {
await sessionAction('resume-convo', value, result.id, ...(fork ? ['fork'] : []));
close?.();
toast.success(`${fork ? 'Forked' : 'Resumed'} “${result.title.slice(0, 40)}” in ${value}`);
hooks.refresh();
hooks.open(value);
} catch (error) {
toast.error(error.message);
}
};
name.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
go();
}
});
holder.replaceChildren(el('span', { class: 'dd-dialog-hint' }, `New session for ${fork ? 'a fork of ' : ''}this conversation:`), name,
el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm', onclick: go }, fork ? 'Fork' : 'Resume'));
name.focus();
name.select();
};
const render = (data) => {
if (!data.results.length) {
results.replaceChildren(el('div', { class: 'dd-files-msg' }, `Nothing found for “${data.query}”.`));
return;
}
results.replaceChildren(...data.results.map((r) => {
const live = liveSessionFor(r.id);
const actions = el('div', { class: 'dd-history-actions' });
const fill = () => actions.replaceChildren(
live
? el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm', onclick: () => { close?.(); hooks.open(live); } }, `Open ${live}`)
: el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm', onclick: () => start(r, false, actions) }, 'Resume…'),
el('button', { type: 'button', class: 'lt-btn lt-btn-sm', onclick: () => start(r, true, actions), title: 'Continue as a new conversation; the original stays as it is' }, 'Fork…'),
el('button', {
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm',
onclick: () => navigator.clipboard?.writeText(`claude --resume ${r.id}`).then(() => toast.success('Copied the resume command')),
}, 'Copy command'));
fill();
return el('div', { class: 'dd-history-item' },
el('div', { class: 'dd-history-title' }, r.title),
el('div', { class: 'dd-history-meta' },
[r.cwd, day(r.started), `updated ${ago(r.updated)}${ago(r.updated) === 'now' ? '' : ' ago'}`, `${r.hits} match${r.hits === 1 ? '' : 'es'}`, live ? `open in ${live}` : '']
.filter(Boolean).join(' · ')),
...r.matches.map((m) => el('div', { class: `dd-history-snippet is-${m.role}` },
el('span', { class: 'dd-history-role' }, ROLE[m.role] ?? m.role), el('span', {}, ...highlight(m.snippet, data.query)))),
actions);
}));
};
const search = async () => {
const query = input.value.trim();
if (query.length < 2) return;
const mine = ++seq;
status.textContent = `Searching for “${query}”…`;
try {
const data = parseListOutput(await runCommand(['--search', query], 30000));
if (mine !== seq) return;
const n = data.results.length;
status.textContent = `${n}${n === 25 ? '+' : ''} conversation${n === 1 ? ' mentions' : 's mention'} “${query}”, newest first.`;
render(data);
} catch (error) {
if (mine === seq) status.textContent = error.message;
}
};
input.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(search, 500);
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
clearTimeout(timer);
search();
}
});
openDialog({
title: 'Search past conversations',
className: 'is-wide',
body: el('div', { class: 'dd-history' }, input, status, results),
actions: [{ label: 'Close', value: null }],
onOpen: (overlay) => {
close = () => overlay.querySelector('.lt-modal-close')?.click();
return input;
},
});
}
+289
View File
@@ -0,0 +1,289 @@
// 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';
import { answerPrompt, peekSession, replyTo } from './connection.js';
import { toast } from './ui.js';
const KEY = 'devdesk.inbox';
const MAX = 30;
const MAX_AGE = 7 * 86400 * 1000;
function load() {
try {
const list = JSON.parse(localStorage.getItem(KEY) || '[]');
return Array.isArray(list) ? list.filter((e) => Date.now() - e.at < MAX_AGE) : [];
} 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.known = null; // session names that exist, once the list has loaded
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.peeking = null;
this.panel.classList.toggle('is-peek', false);
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, sessions }) {
if (waiting) this.waiting = waiting;
if (alerts) this.alerts = alerts;
if (sessions) this.known = new Set(sessions);
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' && !(this.known && e.session && !e.url && !this.known.has(e.session))).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();
}
// ------------------------------------------------------------ answer from here
async answer(session, since, choice, button) {
button.disabled = true;
try {
await answerPrompt(session, since, choice);
toast.success(choice === 'allow' ? `Allowed in ${session}` : `Denied in ${session}`);
this.hooks.refresh?.();
} catch (error) {
toast.error(`${session}: ${error.message}`);
this.hooks.refresh?.();
} finally {
button.disabled = false;
}
}
// Peek: the last exchange of a session, and a reply box.
async peek(session) {
this.peeking = session;
this.panel.classList.add('is-peek');
const body = el('div', { class: 'dd-peek' }, el('div', { class: 'dd-notif-empty' }, 'Loading…'));
this.list.replaceChildren(body);
let data;
try {
data = await peekSession(session);
} catch (error) {
body.replaceChildren(el('div', { class: 'dd-notif-empty' }, error.message));
return;
}
if (this.peeking !== session) return;
const input = el('textarea', { class: 'lt-input dd-peek-input', rows: '3', placeholder: `Reply to ${session}… (Ctrl+Enter sends)`, 'aria-label': `Reply to ${session}` });
const send = el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm' }, 'Send');
const submit = async () => {
const text = input.value.trim();
if (!text) return;
send.disabled = true;
try {
await replyTo(session, text);
toast.success(`Sent to ${session}`);
this.peeking = null;
this.setOpen(false);
this.hooks.refresh?.();
} catch (error) {
toast.error(`${session}: ${error.message}`);
} finally {
send.disabled = false;
}
};
send.addEventListener('click', submit);
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
submit();
}
event.stopPropagation(); // keys belong to the reply box, not the terminal or shortcuts
});
const status = data.status;
const waitingRequest = status?.state === 'waiting' && status.request;
body.replaceChildren(
el('div', { class: 'dd-peek-head' },
el('button', { type: 'button', class: 'dd-peek-back', onclick: () => { this.peeking = null; this.panel.classList.remove('is-peek'); this.renderPanel(); } }, '←'),
el('span', { class: 'dd-peek-title' }, session),
el('span', { class: 'dd-peek-state' }, status?.state ? `${status.state}${status.since ? ` · ${ago(status.since)}` : ''}` : '')),
data.user ? el('div', { class: 'dd-peek-user' }, el('span', { class: 'dd-peek-label' }, 'You'), data.user.text) : null,
el('div', { class: 'dd-peek-assistant' },
el('span', { class: 'dd-peek-label' }, 'Claude'),
data.assistant ? `${data.assistant.truncated ? '…' : ''}${data.assistant.text}` : 'Nothing yet.'),
data.commands?.length ? el('div', { class: 'dd-peek-commands' },
el('span', { class: 'dd-peek-label' }, 'Recent commands'),
...data.commands.slice(-8).map((c) => el('div', { class: 'dd-peek-command', title: c.cmd },
el('span', { class: 'dd-peek-when' }, ago(c.at)), el('code', {}, c.cmd)))) : null,
waitingRequest
? el('div', { class: 'dd-notif-empty' }, 'Claude is asking for a permission: answer it from the list, or open the session.')
: el('div', { class: 'dd-peek-reply' }, input, el('div', { class: 'dd-peek-actions' },
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: () => { this.setOpen(false); this.hooks.openSession(session); } }, 'Open session'),
send)));
if (!waitingRequest) input.focus();
body.querySelector('.dd-peek-assistant').scrollTop = 1e9;
}
renderPanel() {
if (this.peeking) return; // the peek view stays until you go back
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) {
const request = s.status?.request;
const node = 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)),
});
const stop = (run) => (event) => {
event.stopPropagation();
run(event.currentTarget);
};
const body = node.querySelector('.lt-notif-item-body');
if (request) {
body.append(
request.description ? el('div', { class: 'dd-request-desc' }, request.description) : null,
el('code', { class: 'dd-request' }, request.detail || request.tool),
el('div', { class: 'dd-request-actions' },
el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm', onclick: stop((b) => this.answer(s.name, s.status.since, 'allow', b)) }, 'Allow'),
el('button', { type: 'button', class: 'lt-btn lt-btn-danger lt-btn-sm', onclick: stop((b) => this.answer(s.name, s.status.since, 'deny', b)) }, 'Deny'),
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: stop(() => { this.setOpen(false); this.hooks.openSession(s.name); }) }, 'Open')));
} else {
body.append(el('div', { class: 'dd-request-actions' },
el('button', { type: 'button', class: 'lt-btn lt-btn-sm', onclick: stop(() => this.peek(s.name)) }, 'Peek & reply'),
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: stop(() => { this.setOpen(false); this.hooks.openSession(s.name); }) }, 'Open')));
}
nodes.push(node);
}
}
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' };
// An entry about a session that has since ended must not bring it back (devdesk#38).
const ended = !e.url && e.session && this.known && !this.known.has(e.session);
const recentNode = item({
dot: e.read || ended ? 'read' : kind.tone, unread: !e.read && !ended, title: e.text,
meta: `${when(e.at)}${ended ? ' · session ended' : ''}`,
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 (ended) this.hooks.ended?.(e.session);
else if (e.session) this.hooks.openSession(e.session);
}),
});
if (e.kind === 'finished' && !ended) {
recentNode.querySelector('.lt-notif-item-body').append(el('div', { class: 'dd-request-actions' },
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: (event) => { event.stopPropagation(); this.peek(e.session); } }, 'Peek & reply')));
}
nodes.push(recentNode);
}
}
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);
}
}
+45
View File
@@ -0,0 +1,45 @@
// File paths in the terminal become links (devdesk#21): click one in Claude's
// output (web/src/files.js, ./notes.md:12, /root/code/x/y.py:3:9) to open it in
// the Files pane, at that line if there is one. Only paths that are files of
// the session become links, so words and URLs are left alone.
// A path: optional ./ ../ or /, segments, and either a slash or an extension;
// then an optional :line[:col].
const PATH_RE = /(?<![\w/.@~+-])((?:\.{1,2}\/|\/)?(?:[\w@+~-][\w.@+~-]*\/)*[\w@+~-][\w.@+~-]*(?:\.[A-Za-z0-9]{1,10})?)(?::(\d+)(?::(\d+))?)?/g;
const URL_RE = /\b[a-z][a-z0-9+.-]*:\/\/\S+/gi;
export function findPaths(text) {
const urls = [...text.matchAll(URL_RE)].map((m) => [m.index, m.index + m[0].length]);
const out = [];
for (const m of text.matchAll(PATH_RE)) {
const candidate = m[1];
if (!candidate.includes('/') && !/\.[A-Za-z0-9]{1,10}$/.test(candidate)) continue;
if (urls.some(([a, b]) => m.index >= a && m.index < b)) continue;
out.push({ index: m.index, length: m[0].length, path: candidate, line: m[2] ? Number(m[2]) : undefined });
}
return out;
}
export function registerPathLinks(term, { resolve, open }) {
term.registerLinkProvider({
provideLinks(y, callback) {
const line = term.buffer.active.getLine(y - 1);
if (!line) {
callback(undefined);
return;
}
const links = [];
for (const found of findPaths(line.translateToString(true))) {
const path = resolve(found.path);
if (!path) continue;
links.push({
range: { start: { x: found.index + 1, y }, end: { x: found.index + found.length, y } },
text: path,
decorations: { underline: true, pointerCursor: true },
activate: () => open(path, found.line),
});
}
callback(links.length ? links : undefined);
},
});
}
+497 -44
View File
@@ -1,14 +1,21 @@
/* global __DEVDESK_VERSION__ */
import { createTerminal, loadFonts } from './term.js';
import { THEMES, createTerminal, loadFonts } from './term.js';
import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
import {
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
TtydConnection, fetchAlerts, fetchRepo, fetchToken, sessionAction, uploadFile, AuthExpiredError, UnauthorizedError,
} from './connection.js';
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
import { SessionsPanel, el, recentNames, rememberSession, sessionStatus } from './sessions.js';
import { parseTitle, pasteSafe, sessionFromSearch, slugify } 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 { registerPathLinks } from './links.js';
import { openHistory } from './history.js';
import { el } from './dom.js';
import * as notify from './notify.js';
const $ = (id) => document.getElementById(id);
@@ -49,11 +56,12 @@ const ui = {
moreMenu: $('more-menu'),
notifyButton: $('notify-toggle'),
sidebarButton: $('sidebar-toggle'),
panelsBadge: $('panels-badge'),
backdrop: $('backdrop'),
};
const urlSession = sessionFromSearch(window.location.search);
// Notification links (Matrix) add existing=1: open, never re-create (devdesk#38).
const urlExisting = new URLSearchParams(window.location.search).get('existing') === '1';
const state = {
session: urlSession, // null = picker
conn: null,
@@ -70,6 +78,10 @@ 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 = '';
@@ -125,6 +137,7 @@ function renderSessionChip() {
// 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');
@@ -132,6 +145,7 @@ function renderAttention(fetched) {
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');
@@ -139,20 +153,35 @@ function renderAttention(fetched) {
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() {
const alerts = lastAlerts?.alerts ?? [];
const count = waitingOthers.length + alerts.length;
const urgent = waitingOthers.length > 0 || alerts.some((a) => a.severity === 'critical');
ui.panelsBadge.hidden = count === 0 || ui.body.classList.contains('dd-sidebar-open');
ui.panelsBadge.textContent = String(count);
ui.panelsBadge.classList.toggle('is-urgent', urgent);
ui.panelsBadge.title = [
waitingOthers.length ? `${waitingOthers.length} session(s) need you` : '',
alerts.length ? `${alerts.length} Grafana alert(s)` : '',
].filter(Boolean).join(' · ');
inbox?.setLive({
waiting: waitingOthers,
alerts: lastAlerts?.alerts ?? [],
sessions: sessionsPanel?.data ? sessionsPanel.data.sessions.map((x) => x.name) : null,
});
}
function onSessionData({ fetched = false } = {}) {
renderUsage();
renderSessionChip();
renderAttention(fetched);
if (!ui.switcher.hidden) renderSwitcher();
@@ -193,7 +222,7 @@ function uploadMessage(text, isError = false, sticky = false) {
async function attachFiles(files) {
if (!files.length) return;
if (!state.session || !state.conn?.isOpen) {
uploadMessage('Open a session first: files are attached to the current session.', true);
toast.warning('Open a session first: files are attached to the current session.');
return;
}
const session = state.session;
@@ -204,15 +233,17 @@ async function attachFiles(files) {
uploadMessage(`Uploading ${file.name || 'image'}… ${Math.round(p * 100)}%`, false, true));
paths.push(path);
} catch (error) {
uploadMessage(`${file.name || 'file'}: ${error.message}`, true);
uploadUi.hidden = true;
toast.error(`${file.name || 'file'}: ${error.message}`);
return;
}
}
uploadUi.hidden = true;
if (session !== state.session) return;
// A pasted image path makes Claude attach the image itself.
term.paste(`${paths.join(' ')} `);
term.focus();
uploadMessage(paths.length === 1 ? `Attached ${paths[0]}` : `Attached ${paths.length} files`);
toast.success(paths.length === 1 ? `Attached ${paths[0].split('/').pop()}` : `Attached ${paths.length} files`);
}
function wireUploads() {
@@ -257,6 +288,7 @@ let repoBusy = false;
async function refreshRepo() {
const session = state.session;
if (!session) {
lastRepo = null;
renderRepo($('widget-repo'), null);
return;
}
@@ -266,10 +298,14 @@ async function refreshRepo() {
const repo = await fetchRepo(session);
if (session !== state.session) return;
renderRepo($('widget-repo'), repo);
lastRepo = repo;
const key = `${session}@${repo.sha}`;
const ci = repo.ci?.total ? repo.ci.state : '';
if (key === lastCi.key && lastCi.state === 'pending' && ['success', 'failure', 'error'].includes(ci)) {
notify.ring(session, `CI ${ci === 'success' ? 'passed' : 'failed'} on ${repo.branch} @ ${repo.sha}`);
const text = `CI ${ci === 'success' ? 'passed' : 'failed'} on ${repo.branch} @ ${repo.sha}`;
notify.ring(session, text);
toast[ci === 'success' ? 'success' : 'error'](text);
inbox.record({ kind: ci === 'success' ? 'ci-pass' : 'ci-fail', session, text: `${session}: ${text}`, url: repo.ci?.url });
}
lastCi = { key, state: ci };
} catch {
@@ -289,14 +325,19 @@ function updateUrl(session) {
const url = new URL(window.location.href);
if (session) url.searchParams.set('arg', session);
else url.searchParams.delete('arg');
url.searchParams.delete('existing');
if (url.href !== window.location.href) window.history.replaceState(null, '', url);
}
// ---------------------------------------------------------------- connection
// 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 } = {}) {
// existing: go to a session that should already exist (reconnects, the
// switcher, inbox entries, notification links). It must not re-create one that
// was ended or renamed meanwhile, so it asks for --existing (devdesk#4, #38).
// Creating is explicit: a typed name, the New dialog, the picker, recents.
function connect(session, { existing = false, reconnect = false } = {}) {
const existingOnly = Boolean(session) && (existing || reconnect);
state.existingOnly = existingOnly;
clearTimeout(state.retryTimer);
state.conn?.dispose();
state.session = session;
@@ -305,19 +346,23 @@ function connect(session, { reconnect = false } = {}) {
paneTitleRaw = '';
renderPaneTitle();
onSessionData();
files?.sessionChanged();
hideOverlay();
setWsState('connecting', 'connecting');
state.openedAt = 0;
term.reset();
const conn = new TtydConnection({
arg: session && reconnect ? ['--existing', session] : session,
arg: existingOnly ? ['--existing', session] : session,
size: () => ({ cols: term.cols, rows: term.rows }),
onOpen: () => {
state.openedAt = Date.now();
state.attempts = 0;
setWsState('connected', 'live');
if (session) rememberSession(session);
if (session) files?.knownFiles(); // so paths in the output are links from the first hover
presenceTick();
refreshSoon(800); // opening ?arg=<new name> just created this session
refreshRepo();
fit.fit();
term.focus();
@@ -344,6 +389,25 @@ function onClosed(code) {
// few seconds of opening means the session could not start (the reason is
// printed in the terminal), so don't retry forever.
const failedFast = code !== 1000 && Date.now() - state.openedAt < 4000;
if (failedFast && state.existingOnly) {
// Most likely ended or renamed since the link was made: say so, and only
// start it again when asked.
const name = state.session;
sessionsPanel?.reload().then(() => {
const known = sessionsPanel.data?.sessions?.some((x) => x.name === name);
if (known) {
scheduleReconnect();
return;
}
setWsState('disconnected', 'ended');
showOverlay({
title: `${name} has ended`,
text: `The session "${name}" no longer exists: it was ended or renamed. Its conversation is still in Claude's history.`,
actions: [pickerAction, { label: `Start ${name} again`, run: () => connect(name) }],
});
});
return;
}
if (failedFast) {
showOverlay({
title: 'Session did not start',
@@ -439,6 +503,7 @@ function applyTitle() {
sessionsPanel.setCurrent(parsed.session);
onSessionData();
refreshRepo();
files?.sessionChanged();
}
paneTitleRaw = parsed.pane;
renderPaneTitle();
@@ -468,6 +533,7 @@ function closeMenus() {
menu.hidden = true;
button.setAttribute('aria-expanded', 'false');
}
if (inbox?.isOpen) inbox.setOpen(false);
}
// Session switcher on the session chip (devdesk#16).
@@ -484,10 +550,11 @@ function renderSwitcher() {
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)),
title: s.dir || '', onclick: choose(() => connect(s.name, { existing: true })),
},
el('span', { class: `lt-dot ${status.dot}` }),
el('span', { class: 'dd-switch-name' }, s.name),
el('span', { class: 'dd-switch-name' }, s.name,
s.tags?.length ? el('span', { class: 'dd-tags' }, ...s.tags.slice(0, 2).map((t) => el('span', { class: 'dd-tag' }, t))) : null),
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…'));
@@ -496,10 +563,7 @@ function renderSwitcher() {
el('div', { class: 'dd-menu-sep' }),
el('button', {
type: 'button', role: 'menuitem', class: 'dd-menu-item',
onclick: choose(() => {
setSidebar(true);
sessionsPanel.toggleForm(true);
}),
onclick: choose(() => sessionsPanel.newDialog()),
}, '+ New session…'),
el('button', { type: 'button', role: 'menuitem', class: 'dd-menu-item', onclick: choose(() => connect(null)) }, 'Open picker'),
);
@@ -508,13 +572,309 @@ function renderSwitcher() {
// ---------------------------------------------------------------- chrome controls
function setFontSize(size) {
if (!Number.isFinite(size)) return;
const clamped = Math.min(24, Math.max(10, size));
term.options.fontSize = clamped;
store.set('fontSize', clamped);
document.documentElement.style.setProperty('--dd-editor-font', `${clamped - 1}px`);
$('font-size').textContent = String(clamped);
fit.fit();
}
async function toggleNotifications() {
const current = notify.notificationsState();
if (current === 'default') {
const result = await notify.requestNotifications();
if (result === 'granted') toast.success('Desktop notifications on');
else if (result === 'denied') toast.warning('The browser blocked notifications for this site');
} else if (current === 'granted') {
notify.setMuted(!notify.isMuted());
toast.info(notify.isMuted() ? 'Desktop notifications muted in this browser' : 'Desktop notifications unmuted');
} else {
toast.warning(current === 'denied' ? 'Notifications are blocked in the browser\'s site settings' : 'This browser has no notifications');
}
}
// "In a new session" (devdesk#18): open (or reuse) a session for this alert,
// wait until its Claude is up (SessionStart sets it idle), then type the
// prompt without sending it.
let pendingAsk = null;
function askInSession(name, text) {
closeDrawerIfNarrow();
pendingAsk = { session: name, text, until: Date.now() + 60000 };
connect(name);
toast.info(`Opening ${name}; the prompt will be typed when Claude is ready`);
const tick = async () => {
if (!pendingAsk || pendingAsk.session !== name) return;
if (Date.now() > pendingAsk.until) {
toast.warning(`${name}: Claude didn't start in time; the prompt was not typed`);
pendingAsk = null;
return;
}
await sessionsPanel.reload();
const s = sessionsPanel.data?.sessions?.find((x) => x.name === name);
const ready = s && ['idle', 'waiting', 'working'].includes(sessionStatus(s).key);
if (ready && state.session === name && state.conn?.isOpen) {
setTimeout(() => {
term.paste(pasteSafe(pendingAsk.text));
term.focus();
pendingAsk = null;
}, 800);
return;
}
setTimeout(tick, 1500);
};
setTimeout(tick, 1500);
}
// ---------------------------------------------------------------- history & usage
function showHistory() {
closeMenus();
openHistory({
sessions: () => sessionsPanel?.data?.sessions ?? [],
open: (name) => connect(name, { existing: true }),
refresh: () => sessionsPanel.reload(),
});
}
// Plan usage (devdesk#25): the 5-hour and weekly windows, from the newest
// statusLine snapshot of any session (they're account-wide). A toast when the
// 5-hour window passes 90 %, once per window.
let usageWarned = 0;
function renderUsage() {
const snapshots = (sessionsPanel?.data?.sessions ?? []).map((s) => s.context).filter((c) => c?.rate_limits);
const latest = snapshots.sort((a, b) => (b.updated || 0) - (a.updated || 0))[0]?.rate_limits;
const footer = $('usage-footer');
const menuRow = $('usage-row');
if (!latest || (!latest.five_hour && !latest.seven_day)) {
footer.hidden = true;
menuRow.hidden = true;
return;
}
const at = (epoch) => (epoch ? new Date(epoch * 1000).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' }) : '?');
const meter = (label, w) => {
if (!w || w.pct === null || w.pct === undefined) return null;
const pct = Math.round(w.pct);
return el('span', { class: `dd-meter${pct >= 95 ? ' is-full' : pct >= 80 ? ' is-high' : ''}`, title: `${label}: ${pct}% used, resets ${at(w.resets_at)}` },
el('span', { class: 'dd-meter-label' }, label),
el('span', { class: 'dd-meter-bar' }, el('span', { class: 'dd-meter-fill', style: `width:${Math.min(100, pct)}%` })),
`${pct}%`);
};
const parts = () => [meter('5h', latest.five_hour), meter('week', latest.seven_day)].filter(Boolean);
footer.replaceChildren(...parts());
$('usage-menu').replaceChildren(...parts());
footer.hidden = false;
menuRow.hidden = false;
const five = latest.five_hour;
if (five?.pct >= 90 && five.resets_at !== usageWarned) {
usageWarned = five.resets_at;
toast.warning(`Plan usage: ${Math.round(five.pct)}% of the 5-hour window (resets ${at(five.resets_at)})`, 10000);
}
}
// Remote Control presence (devdesk#26): while this tab shows a session and is
// visible, its Claude knows a client is present and holds back phone pushes.
let presenceFor = null;
function presence(on) {
const session = state.session;
if (presenceFor && presenceFor !== session) sessionAction('presence', presenceFor, 'off').catch(() => {});
presenceFor = on ? session : null;
if (session) sessionAction('presence', session, on ? 'on' : 'off').catch(() => {});
}
function presenceTick() {
presence(Boolean(state.session && state.conn?.isOpen && document.visibilityState === 'visible'));
}
// ---------------------------------------------------------------- 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, ...(s.tags ?? []).slice(0, 2)].join(' · '),
keywords: `${s.dir} ${(s.tags ?? []).join(' ')}`,
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name, { existing: true }),
});
}
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) });
add({ id: 'history', group: 'Sessions', icon: '⌕', label: 'Search past conversations…', keywords: 'history transcript resume fork find old', run: showHistory });
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, edit (Esc closes)'],
['Ctrl+P', 'Files: go to file'],
['Ctrl+S', 'Files: save the open file'],
['Ctrl+⇧ C / V', 'Copy selection / paste'],
['⇧ drag', 'Select text in the browser'],
['Drop or paste a file', 'Upload it and attach it for Claude'],
['Click a file path', 'Open it in Files (at the line, if given)'],
['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 = {
@@ -528,6 +888,7 @@ function renderNotifyButton() {
}
function setSidebar(open) {
if (open && files?.isOpen) files.close();
ui.body.classList.toggle('dd-sidebar-open', open);
ui.sidebarButton.setAttribute('aria-expanded', String(open));
ui.backdrop.hidden = !(open && isNarrow());
@@ -577,21 +938,92 @@ function wireChrome() {
$('font-down').addEventListener('click', () => setFontSize(term.options.fontSize - 1));
$('font-up').addEventListener('click', () => setFontSize(term.options.fontSize + 1));
ui.notifyButton.addEventListener('click', async () => {
if (notify.notificationsState() === 'default') await notify.requestNotifications();
else if (notify.notificationsState() === 'granted') notify.setMuted(!notify.isMuted());
await toggleNotifications();
renderNotifyButton();
});
$('fullscreen-toggle').addEventListener('click', async () => {
$('fullscreen-toggle').addEventListener('click', () => {
closeMenus();
if (document.fullscreenElement) {
await document.exitFullscreen();
toggleFullscreen();
});
$('help-open').addEventListener('click', () => {
closeMenus();
showHelp();
});
$('footer-help').addEventListener('click', showHelp);
$('history-menu').addEventListener('click', showHistory);
setInterval(presenceTick, 45 * 1000);
document.addEventListener('visibilitychange', presenceTick);
$('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());
registerPathLinks(term, {
resolve: (printed) => (state.session ? files.resolvePrinted(printed) : null),
open: (path, line) => {
if (!files.isOpen) files.open();
files.openFile(path, { pin: true, line });
},
});
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';
});
wireMenu(ui.moreToggle, ui.moreMenu, renderNotifyButton);
$('theme-menu').addEventListener('click', () => {
closeMenus();
toggleTheme();
});
inbox = new Inbox({
button: $('inbox-toggle'),
badge: $('inbox-badge'),
panel: $('inbox-panel'),
hooks: {
openSession: (name) => connect(name, { existing: true }),
ended: (name) => toast.info(`${name} has ended; its conversation is still in Claude's history`),
refresh: () => sessionsPanel.reload(),
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();
@@ -607,8 +1039,16 @@ function wireChrome() {
// Esc closes menus and the narrow-screen drawer before the terminal sees it.
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
const menuOpen = menus.some(({ menu }) => !menu.hidden);
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();
@@ -625,7 +1065,7 @@ function wireChrome() {
// Ctrl+W closes the tab before the terminal sees it; ask first.
window.addEventListener('beforeunload', (event) => {
if (state.conn?.isOpen && state.session) {
if ((state.conn?.isOpen && state.session) || files?.hasDirty()) {
event.preventDefault();
event.returnValue = '';
}
@@ -661,7 +1101,7 @@ function wireChrome() {
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);
if (ui.attention.dataset.target) connect(ui.attention.dataset.target, { existing: true });
});
}
@@ -679,6 +1119,7 @@ function defaultSession(data) {
// ---------------------------------------------------------------- boot
async function boot() {
initTheme();
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
$('font-size').textContent = String(fontSize);
@@ -686,8 +1127,14 @@ async function boot() {
({ 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));
@@ -699,6 +1146,7 @@ async function boot() {
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;
@@ -741,7 +1189,11 @@ async function boot() {
onData: (_data, meta) => onSessionData(meta),
},
});
bindAskClaude($('sidebar'), (text) => {
bindAskClaude($('sidebar'), (text, session) => {
if (session) {
askInSession(slugify(session).slice(0, 48) || 'alert', text);
return;
}
// 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));
@@ -753,7 +1205,8 @@ async function boot() {
wireUploads();
refreshAlerts();
await sessionsPanel.refresh();
connect(urlSession ?? defaultSession(sessionsPanel.data));
if (urlSession) connect(urlSession, { existing: urlExisting });
else connect(defaultSession(sessionsPanel.data), { existing: true });
}
boot();
+113 -41
View File
@@ -4,6 +4,10 @@
import { fetchSessionList, sessionAction } from './connection.js';
import { slugify } from './protocol.js';
import { share } from './shared.js';
import { el } from './dom.js';
import { confirmDialog, openDialog, toast } from './ui.js';
export { el };
const RECENTS_KEY = 'devdesk.recents';
const MAX_RECENTS = 12;
@@ -51,17 +55,6 @@ export function ago(epochSeconds) {
return `${Math.floor(s / 86400)}d`;
}
export function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (value === undefined || value === null || value === false) continue;
if (key === 'class') node.className = value;
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else node.setAttribute(key, value === true ? '' : value);
}
node.append(...children.filter((c) => c !== null && c !== undefined));
return node;
}
// What Claude is doing in a session, from the devdesk-state-hook snapshot.
// Returns { key, label, dot } for display; key is one of
@@ -114,6 +107,12 @@ export class SessionsPanel {
return this.pending;
}
// After an action: a poll already in flight may predate it, so fetch again.
async reload() {
await this.pending;
return this.refresh();
}
async load() {
let fetched = false;
try {
@@ -135,14 +134,13 @@ export class SessionsPanel {
this.hooks.onData?.(this.data, { fetched: false });
}
async act(label, action, ...args) {
this.setStatus(`${label}…`);
// Side-channel action; failures become an error toast (devdesk#34).
async act(action, ...args) {
try {
await sessionAction(action, ...args);
this.setStatus('');
return true;
} catch (err) {
this.setStatus(err.message, true);
toast.error(err.message);
return false;
}
}
@@ -151,43 +149,116 @@ export class SessionsPanel {
const project = this.select.value;
const name = slugify(this.nameInput.value) || project;
if (!name) {
this.setStatus('Type a session name or pick a project.', true);
toast.warning('Type a session name or pick a project.');
this.nameInput.focus();
return;
}
// With a project the session starts in that folder; without one, in the default folder.
if (project && !(await this.act('creating', 'new', name, project))) return;
if (!(await this.create(name, project))) return;
this.nameInput.value = '';
this.select.value = '';
this.toggleForm(false);
}
// With a project the session starts in that folder; without one, opening it
// creates it in the default folder (or its own project folder, by name).
async create(name, project) {
if (project && !(await this.act('new', name, project))) return false;
this.hooks.onOpen(name);
return true;
}
projectOptions(select) {
const projects = this.data?.projects ?? [];
const chosen = select.value;
select.replaceChildren(
el('option', { value: '' }, `No project (${this.data?.root ?? 'default folder'})`),
...projects.filter((p) => p.name !== this.data?.default)
.map((p) => el('option', { value: p.name, title: p.dir }, p.name)));
select.value = chosen;
}
// "+ New session…" from the switcher or the command palette (devdesk#33).
async newDialog() {
const name = el('input', { class: 'lt-input', maxlength: '64', placeholder: 'e.g. pbs-upgrade', spellcheck: 'false', autocomplete: 'off', 'aria-describedby': 'dd-new-hint' });
const project = el('select', { class: 'lt-select' });
this.projectOptions(project);
const hint = el('p', { class: 'dd-dialog-hint', id: 'dd-new-hint' }, 'Letters, numbers, - and _. Any name works; pick a project to start in its folder.');
project.addEventListener('change', () => {
if (!name.value.trim()) name.placeholder = project.value || 'e.g. pbs-upgrade';
});
const result = await openDialog({
title: 'New session',
body: el('div', { class: 'dd-dialog-fields' },
el('label', { class: 'dd-field' }, el('span', {}, 'Name'), name),
el('label', { class: 'dd-field' }, el('span', {}, 'Project folder'), project),
hint),
actions: [{ label: 'Cancel' }, { label: 'Open', primary: true }],
submit: () => {
const value = slugify(name.value) || project.value;
if (!value) {
hint.textContent = 'Type a name or pick a project.';
hint.classList.add('is-error');
name.focus();
return null;
}
return { name: value, project: project.value };
},
});
if (result) await this.create(result.name, result.project);
}
async rename(name) {
const answer = window.prompt(`Rename session "${name}" to:`, name);
if (answer === null) return;
const next = slugify(answer);
if (!next || next === name) {
if (!next) this.setStatus('Names use letters, numbers, - and _.', true);
return;
}
if (await this.act('renaming', 'rename', name, next)) {
const input = el('input', { class: 'lt-input', maxlength: '64', value: name, spellcheck: 'false', autocomplete: 'off', 'aria-describedby': 'dd-rename-hint' });
const hint = el('p', { class: 'dd-dialog-hint', id: 'dd-rename-hint' }, 'Claude keeps running; the conversation moves with the session.');
const next = await openDialog({
title: `Rename ${name}`,
size: 'sm',
body: el('div', { class: 'dd-dialog-fields' }, el('label', { class: 'dd-field' }, el('span', {}, 'New name'), input), hint),
actions: [{ label: 'Cancel' }, { label: 'Rename', primary: true }],
onOpen: () => {
input.select();
return input;
},
submit: () => {
const value = slugify(input.value);
if (!value) {
hint.textContent = 'Names use letters, numbers, - and _.';
hint.classList.add('is-error');
return null;
}
if (value !== name && this.data?.sessions?.some((x) => x.name === value)) {
hint.textContent = `A session called ${value} already exists.`;
hint.classList.add('is-error');
return null;
}
return value;
},
});
if (!next || next === name) return;
if (await this.act('rename', name, next)) {
renameRecent(name, next);
this.hooks.onRenamed(name, next);
await this.refresh();
toast.success(`Renamed ${name} → ${next}`);
await this.reload();
}
}
async end(session) {
const question = session.running
? `End session "${session.name}"?\n\nClaude in this session stops. The conversation stays in Claude's history (claude --resume).`
: `Forget dormant session "${session.name}"?`;
if (!window.confirm(question)) return;
const ok = await confirmDialog({
title: session.running ? `End ${session.name}?` : `Forget ${session.name}?`,
text: session.running
? 'Claude in this session stops and the session closes. The conversation stays in Claude\'s history (claude --resume).'
: 'This dormant session is removed from the list. Its conversation stays in Claude\'s history.',
confirmLabel: session.running ? 'End session' : 'Forget',
danger: true,
});
if (!ok) return;
if (session.name === this.current) this.hooks.onEnding?.(session.name);
if (await this.act(session.running ? 'ending' : 'forgetting', 'end', session.name)) {
if (await this.act('end', session.name)) {
forgetRecent(session.name);
this.hooks.onEnded(session.name);
await this.refresh();
toast.success(session.running ? `Ended ${session.name}` : `Forgot ${session.name}`);
await this.reload();
} else if (session.name === this.current) {
this.hooks.onEndAborted?.();
}
@@ -214,7 +285,14 @@ export class SessionsPanel {
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-name' }, s.name,
s.tags?.length ? el('span', { class: 'dd-tags', title: 'Hosts this session worked on' }, ...s.tags.slice(0, 3).map((t) => el('span', { class: 'dd-tag' }, t))) : null,
// The pull request of this branch (devdesk#25)
s.context?.pr?.number ? el('a', {
class: `dd-pr is-${s.context.pr.review_state || 'open'}`, href: s.context.pr.url, target: '_blank', rel: 'noopener',
title: `PR #${s.context.pr.number}${s.context.pr.review_state ? ` · ${s.context.pr.review_state.replace(/_/g, ' ')}` : ''}`,
onclick: (event) => event.stopPropagation(),
}, `PR #${s.context.pr.number}${{ approved: ' ✓', changes_requested: ' ✗' }[s.context.pr.review_state] ?? ''}`) : null),
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),
@@ -250,12 +328,6 @@ export class SessionsPanel {
if (!items.length) items.push(el('li', { class: 'dd-empty' }, 'No sessions yet.'));
this.list.replaceChildren(...items);
const projects = this.data?.projects ?? [];
const chosen = this.select.value;
this.select.replaceChildren(
el('option', { value: '' }, `No project (${this.data?.root ?? 'default folder'})`),
...projects.filter((p) => p.name !== this.data?.default)
.map((p) => el('option', { value: p.name, title: p.dir }, p.name)));
this.select.value = chosen;
this.projectOptions(this.select);
}
}
+61 -26
View File
@@ -15,30 +15,65 @@ const writeOnlyClipboard = {
},
};
// LotusGuild TDS tokens (web_template base.css :root) mapped onto the ANSI palette.
export const THEME = Object.freeze({
background: '#010304',
foreground: '#c4d9ee',
cursor: '#FF6B00',
cursorAccent: '#010304',
selectionBackground: 'rgba(0, 212, 255, 0.30)',
selectionInactiveBackground: 'rgba(0, 212, 255, 0.15)',
black: '#0d1520',
red: '#FF2D55',
green: '#00FF88',
yellow: '#FFB300',
blue: '#3A8DFF',
magenta: '#BF5FFF',
cyan: '#00D4FF',
white: '#c4d9ee',
brightBlack: '#3e607a',
brightRed: '#FF5C7A',
brightGreen: '#33FFAA',
brightYellow: '#FFD700',
brightBlue: '#6AA8FF',
brightMagenta: '#D08CFF',
brightCyan: '#33DFFF',
brightWhite: '#FFFFFF',
// Terminal palettes from the LotusGuild TDS tokens (devdesk#31).
//
// DevDesk runs Claude with its `dark-ansi` theme, so Claude draws with these 16
// colours instead of fixed RGB: body text is bright white, secondary text is
// white, the band behind your messages is bright black, and text on coloured
// backgrounds is black. Claude's theme is per process and printed text keeps its
// colours, so instead of switching Claude, the light palette inverts the grey
// ramp: bright white is ink, white is muted ink, bright black a light band and
// black the page. Anything written for dark terminals, Claude's scrollback
// included, stays readable, and toggling is instant.
export const THEMES = Object.freeze({
dark: Object.freeze({
background: '#010304',
foreground: '#c4d9ee',
cursor: '#FF6B00',
cursorAccent: '#010304',
selectionBackground: 'rgba(0, 212, 255, 0.30)',
selectionInactiveBackground: 'rgba(0, 212, 255, 0.15)',
black: '#0d1520',
red: '#FF2D55',
green: '#00FF88',
yellow: '#FFB300',
blue: '#3A8DFF',
magenta: '#BF5FFF',
cyan: '#00D4FF',
white: '#9fb6cc',
brightBlack: '#3e607a',
brightRed: '#FF5C7A',
brightGreen: '#33FFAA',
brightYellow: '#FFD700',
brightBlue: '#6AA8FF',
brightMagenta: '#D08CFF',
brightCyan: '#33DFFF',
brightWhite: '#FFFFFF',
}),
light: Object.freeze({
background: '#f4f6fa',
foreground: '#16202e',
cursor: '#c44e00',
cursorAccent: '#f4f6fa',
selectionBackground: 'rgba(0, 98, 184, 0.22)',
selectionInactiveBackground: 'rgba(0, 98, 184, 0.12)',
black: '#eef1f6',
red: '#b5001f',
green: '#006d35',
yellow: '#8a5a00',
blue: '#1f4fbf',
magenta: '#6b2fb8',
cyan: '#006b86',
white: '#556578',
brightBlack: '#c9d1dc',
brightRed: '#cc1f3d',
brightGreen: '#00803e',
brightYellow: '#9a6400',
brightBlue: '#0062b8',
brightMagenta: '#7e3fd0',
brightCyan: '#007f9e',
brightWhite: '#16202e',
}),
});
export const FONT_FAMILY = '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace';
@@ -55,7 +90,7 @@ export async function loadFonts(size) {
}
}
export function createTerminal(container, { fontSize, onShiftEnter }) {
export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnter }) {
const term = new Terminal({
fontFamily: FONT_FAMILY,
fontSize,
@@ -65,7 +100,7 @@ export function createTerminal(container, { fontSize, onShiftEnter }) {
allowProposedApi: true, // unicode11
macOptionIsMeta: true,
drawBoldTextInBrightColors: false,
theme: THEME,
theme: THEMES[theme],
});
const fit = new FitAddon();
+62
View File
@@ -0,0 +1,62 @@
// Dark / light theme, interchangeable with web_template's lt.theme (devdesk#31):
// same localStorage key (lt_theme), same data-theme attribute on <html>, the OS
// preference while nothing is saved, and tabs follow each other via `storage`.
// The inline script in index.template.html applies it before first paint.
const KEY = 'lt_theme';
const prefersLight = window.matchMedia('(prefers-color-scheme: light)');
const listeners = new Set();
function saved() {
try {
const value = localStorage.getItem(KEY);
return value === 'light' || value === 'dark' ? value : null;
} catch {
return null;
}
}
export function getTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
}
function apply(theme) {
const root = document.documentElement;
root.setAttribute('data-theme', theme);
root.style.colorScheme = theme;
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme === 'light' ? '#edf0f5' : '#030508');
const next = theme === 'light' ? 'dark' : 'light';
for (const button of document.querySelectorAll('.lt-theme-btn')) {
button.textContent = theme === 'light' ? '◐' : '☀';
button.setAttribute('aria-label', `Switch to ${next} mode`);
button.title = `Switch to ${next} mode`;
}
for (const listener of listeners) listener(theme);
}
export function setTheme(theme) {
try {
localStorage.setItem(KEY, theme);
} catch {
// Private mode: the choice lasts until the tab closes.
}
apply(theme);
}
export function toggleTheme() {
setTheme(getTheme() === 'light' ? 'dark' : 'light');
}
export function onThemeChange(listener) {
listeners.add(listener);
}
export function initTheme() {
apply(saved() ?? (prefersLight.matches ? 'light' : 'dark'));
prefersLight.addEventListener('change', (event) => {
if (!saved()) apply(event.matches ? 'light' : 'dark');
});
window.addEventListener('storage', (event) => {
if (event.key === KEY) apply(saved() ?? (prefersLight.matches ? 'light' : 'dark'));
});
}
+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 = '', className = '', 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${className ? ` ${className}` : ''}`, 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();
}
}
+4 -3
View File
@@ -1,11 +1,12 @@
// "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.
// prompt into a session without pressing Enter, so you can review it before
// sending: the current session, or (data-ask-session) a separate one, e.g. an
// ops session per alert (devdesk#18).
export function bindAskClaude(root, send) {
root.addEventListener('click', (event) => {
const button = event.target.closest('[data-ask-claude]');
if (!button || button.disabled) return;
send(button.dataset.askClaude);
send(button.dataset.askClaude, button.dataset.askSession || null);
});
}
+464 -14
View File
@@ -100,20 +100,7 @@ body.dd-app::after {
color: var(--accent-orange);
border-color: var(--accent-orange-border);
}
.dd-badge {
margin-left: 6px;
min-width: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--accent-amber);
color: var(--bg-primary);
font-size: 0.62rem;
font-weight: 700;
line-height: 16px;
text-align: center;
}
.dd-badge.is-urgent { background: var(--accent-red); box-shadow: 0 0 8px var(--accent-red); }
.dd-badge[hidden] { display: none; }
/* ---- menus (session switcher, ⋯) ----------------------------------------------- */
.dd-session,
@@ -569,3 +556,466 @@ body.dd-touch .dd-footer {
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 5),
.dd-footer .lt-footer-sep:nth-child(n + 4) { display: none; }
}
/* ---- light theme (devdesk#31) ---------------------------------------------------
base.css restyles every lt- component; these cover DevDesk's own pieces. */
html[data-theme="light"] .dd-sidebar,
html[data-theme="light"] .dd-footer { background-image: none; }
html[data-theme="light"] .dd-overlay { background: rgba(237, 240, 245, 0.82); }
html[data-theme="light"] .dd-backdrop { background: rgba(17, 24, 39, 0.28); }
html[data-theme="light"] .dd-menu { box-shadow: 0 8px 28px rgba(50, 80, 130, 0.22), var(--box-glow-cyan); }
html[data-theme="light"] .dd-drop-overlay { background: var(--accent-cyan-dim); }
html[data-theme="light"] .dd-upload-status { box-shadow: 0 4px 16px rgba(50, 80, 130, 0.18); }
html[data-theme="light"] .dd-session-item.is-waiting,
html[data-theme="light"] .dd-session-chip[data-state="waiting"],
html[data-theme="light"] .dd-upload-status.is-error { border-color: rgba(181, 0, 31, 0.35); }
html[data-theme="light"] .dd-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; }
}
/* ---- editing (devdesk#20) ------------------------------------------------------------- */
.dd-tab-view:not(.is-dirty) .dd-dirty-only { display: none; }
.dd-unsaved { color: var(--accent-amber); }
.dd-tab.is-dirty .dd-tab-close { opacity: 1; color: var(--accent-amber); }
.dd-tab.is-dirty .dd-tab-close:hover { color: var(--accent-red); }
.dd-viewer-btn.is-primary { border-color: var(--accent-orange); color: var(--accent-orange); }
.dd-viewer-btn.is-primary:hover { background: var(--accent-orange-dim); }
.dd-viewer-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
padding: 5px 10px;
border-bottom: 1px solid var(--accent-amber-dim);
background: var(--accent-amber-dim);
color: var(--accent-amber);
font-size: 0.72rem;
}
.dd-dialog.is-wide .lt-modal { width: min(1100px, 94vw); max-height: 88vh; }
.dd-dialog.is-wide .lt-modal-body { padding: 0; }
.dd-compare { display: flex; height: min(64vh, 640px); }
.dd-compare > .cm-editor { flex: 1; min-width: 0; }
/* ---- inbox: answer & peek (devdesk#22, #23) --------------------------------------------- */
.dd-inbox-panel.is-peek { width: min(460px, calc(100vw - 16px)); }
.dd-request-desc { margin-top: 4px; color: var(--text-secondary); font-size: 0.7rem; }
.dd-request {
display: block;
max-height: 7.5em;
margin-top: 4px;
padding: 4px 6px;
overflow: auto;
border-left: 2px solid var(--accent-amber);
background: var(--bg-terminal);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.7rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.dd-request-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.dd-peek { display: flex; flex-direction: column; gap: 8px; padding: 8px 10px 10px; }
.dd-peek-head { display: flex; align-items: center; gap: 8px; }
.dd-peek-back { padding: 0 6px; border: 1px solid var(--border-color); background: none; color: var(--accent-cyan); cursor: pointer; }
.dd-peek-title { flex: 1; color: var(--text-primary); font-weight: 700; font-size: 0.8rem; }
.dd-peek-state { color: var(--text-muted); font-size: 0.66rem; }
.dd-peek-label { display: block; margin-bottom: 2px; color: var(--accent-orange); font-size: 0.6rem; letter-spacing: 0.1em; text-transform: uppercase; }
.dd-peek-user { padding: 6px 8px; border-left: 2px solid var(--border-color); color: var(--text-secondary); font-size: 0.72rem; white-space: pre-wrap; overflow-wrap: anywhere; }
.dd-peek-assistant { max-height: 38vh; padding: 6px 8px; overflow: auto; border-left: 2px solid var(--accent-cyan); background: var(--bg-terminal); color: var(--text-primary); font-size: 0.74rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
.dd-peek-input { width: 100%; min-height: 4.5em; resize: vertical; font-family: var(--font-mono); font-size: 0.76rem; }
.dd-peek-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
.dd-ask-row { display: flex; flex-wrap: wrap; gap: 6px; align-self: flex-start; margin-top: 2px; }
.dd-alert .dd-ask-row .dd-ask { margin-top: 0; }
/* ---- ops trail (devdesk#27) ------------------------------------------------------------- */
.dd-tags { display: inline-flex; flex-wrap: wrap; gap: 3px; margin-left: 6px; vertical-align: middle; }
.dd-tag { padding: 0 4px; border: 1px solid var(--accent-cyan-border); color: var(--accent-cyan); font-size: 0.58rem; font-weight: 400; letter-spacing: 0; }
.dd-peek-commands { display: flex; flex-direction: column; gap: 2px; }
.dd-peek-command { display: flex; gap: 8px; min-width: 0; font-size: 0.68rem; }
.dd-peek-command code { overflow: hidden; color: var(--text-secondary); font-family: var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
.dd-peek-when { flex: 0 0 2.6em; color: var(--text-muted); text-align: right; }
/* ---- history, usage, PR (devdesk#24-#26) ------------------------------------------- */
.dd-history { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; }
.dd-history-input { width: 100%; font-size: 0.82rem; }
.dd-history-results { display: flex; flex-direction: column; gap: 8px; max-height: 60vh; overflow: auto; }
.dd-history-item { padding: 8px 10px; border: 1px solid var(--border-dim); border-left: 2px solid var(--accent-cyan); background: var(--bg-terminal); }
.dd-history-title { color: var(--text-primary); font-size: 0.8rem; font-weight: 700; overflow-wrap: anywhere; }
.dd-history-meta { margin: 2px 0 6px; color: var(--text-muted); font-size: 0.66rem; overflow-wrap: anywhere; }
.dd-history-snippet { display: flex; gap: 8px; margin-top: 3px; color: var(--text-secondary); font-size: 0.72rem; line-height: 1.45; overflow-wrap: anywhere; }
.dd-history-snippet mark { background: var(--accent-amber-dim); color: var(--accent-amber); padding: 0 1px; }
.dd-history-role { flex: 0 0 4.2em; color: var(--accent-orange); font-size: 0.6rem; letter-spacing: 0.08em; text-transform: uppercase; padding-top: 2px; }
.dd-history-snippet.is-command span:last-child,
.dd-history-snippet.is-output span:last-child { font-family: var(--font-mono); }
.dd-history-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; }
.dd-history-actions .lt-input { width: 220px; font-size: 0.74rem; }
.dd-footer-usage { margin-left: auto; padding-left: 12px; }
.dd-footer-usage[hidden],
.dd-usage-row[hidden] { display: none; }
.dd-usage { display: inline-flex; align-items: center; gap: 10px; }
.dd-meter { display: inline-flex; align-items: center; gap: 5px; color: var(--text-secondary); font-size: 0.66rem; white-space: nowrap; }
.dd-meter-label { color: var(--text-muted); }
.dd-meter-bar { width: 42px; height: 4px; background: var(--bg-tertiary); }
.dd-meter-fill { display: block; height: 100%; background: var(--accent-cyan); }
.dd-meter.is-high .dd-meter-fill { background: var(--accent-amber); }
.dd-meter.is-high { color: var(--accent-amber); }
.dd-meter.is-full .dd-meter-fill { background: var(--accent-red); }
.dd-meter.is-full { color: var(--accent-red); }
body.dd-touch .dd-footer-usage { display: none; }
.dd-pr { margin-left: 6px; padding: 0 4px; border: 1px solid var(--border-color); color: var(--text-secondary); font-size: 0.58rem; font-weight: 400; text-decoration: none; vertical-align: middle; }
.dd-pr.is-approved { border-color: var(--accent-green-border); color: var(--accent-green); }
.dd-pr.is-changes_requested { border-color: rgba(255, 45, 85, 0.4); color: var(--accent-red); }