From 527cb1fda2480c925e71f30454a88ec0c48f48be Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 7 Oct 2026 03:03:09 -0400 Subject: [PATCH] feat: free-form sessions, rename/end from the panel, live Claude Context (0.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sessions no longer need a project folder: any name works and starts in DEVDESK_DEFAULT_DIR unless it matches a project; `devdesk new NAME [PROJECT]`. - Rename (✎) and end/forget (×) sessions from the Sessions panel, plus `devdesk rename`. The browser runs them as `devdesk attach --new|--rename|--end` through ttyd. Renames keep the running Claude and its recorded conversation: the hook, the run-wrapper and the statusline ask tmux for the current name. - Claude Context panel is live: devdesk-statusline snapshots model, context-window use, cost and edits per session, which --list reports; Claude's footer gets a one-line summary. The panel now sits under Sessions. Co-Authored-By: Claude Opus 5.5 --- .eslintrc.json | 2 +- Makefile | 2 +- README.md | 17 +++- bin/devdesk | 117 +++++++++++++++------- debian/changelog | 15 +++ docs/DEPLOY.md | 4 +- docs/devdesk.1 | 20 ++-- etc/env | 3 + libexec/devdesk-claude-run | 6 +- libexec/devdesk-lib.sh | 63 ++++++++---- libexec/devdesk-session-hook | 7 +- libexec/devdesk-statusline | 48 +++++++++ share/claude-settings.json | 5 + tests/attach.bats | 110 ++++++++++++++++++++- tests/protocol.test.mjs | 15 ++- web/index.template.html | 42 ++++---- web/src/connection.js | 32 +++--- web/src/context.js | 51 ++++++++++ web/src/main.js | 52 +++++++++- web/src/protocol.js | 20 +++- web/src/sessions.js | 186 ++++++++++++++++++++++++++--------- web/styles.css | 57 ++++++++--- 22 files changed, 703 insertions(+), 171 deletions(-) create mode 100755 libexec/devdesk-statusline create mode 100644 web/src/context.js diff --git a/.eslintrc.json b/.eslintrc.json index 7ed5883..3ac4fbd 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -8,6 +8,6 @@ "no-undef": "error", "no-empty": "warn", "semi": ["error", "always"], - "eqeqeq": "error" + "eqeqeq": ["error", "always", { "null": "ignore" }] } } diff --git a/Makefile b/Makefile index 1b514dc..63ae6a9 100644 --- a/Makefile +++ b/Makefile @@ -39,7 +39,7 @@ install: all install -m 0755 bin/devdesk $(DESTDIR)$(PREFIX)/bin/devdesk install -m 0644 libexec/devdesk-lib.sh $(DESTDIR)$(LIBDIR)/devdesk-lib.sh install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \ - libexec/devdesk-tmux-ready libexec/devdesk-serve $(DESTDIR)$(LIBDIR)/ + libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline $(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 -m 0644 etc/env $(DESTDIR)$(SYSCONFDIR)/devdesk/env diff --git a/README.md b/README.md index cb84808..1adf777 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,19 @@ The tmux server runs in its own unit, so every Claude process lives in **that** - **Only `systemctl stop devdesk-tmux` (or removing the package) ends the sessions.** ## Sessions -- **How the session is chosen:** - - `https://dev.lotusguild.org/?arg=` opens session `` and creates it on demand in `$DEVDESK_PROJECTS_ROOT/`. `main` uses the root itself, and folders with spaces are reached by slug (`test code` → `test-code`). +- **Any name works.** Sessions don't have to be repo work: + - A name matching a project folder (`tinker_tickets`, or `test-code` for `test code`) starts there. + - Anything else (`pbs-upgrade`, `ceph-check`) starts in `$DEVDESK_DEFAULT_DIR`, which is `/root/code` by default. + - `main` is the projects root. +- **Opening one:** + - `https://dev.lotusguild.org/?arg=` opens or creates that session. - With no `?arg=`, an fzf picker opens, listing running (●), dormant (○) and new (+) sessions. + - The **Sessions** panel's form takes any name plus an optional project folder. +- **Rename / end:** the ✎ and × buttons on each session in the panel. Renaming keeps the session, its running Claude and its recorded conversation. Ending stops Claude; the conversation stays in Claude's history. - **Windows:** each session has a `claude` window (0) and a `shell` window (1). - **Conversation tracking:** a Claude `SessionStart` hook records each session's conversation ID in `/var/lib/devdesk/registry.json`. After a reboot the session resumes *that* conversation, even when other Claude instances, such as code-server, use the same directory. -- **SSH:** `devdesk attach `, `devdesk ls`, `devdesk pick`, `devdesk end `. +- **Claude Context panel:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use, cost and edits to `/var/lib/devdesk/context/.json`. The panel shows the current session's numbers, and Claude's own footer shows a one-line summary. Sessions started before 0.2.0 need Claude restarted (`/exit`, then ⏎ in the menu) before they report. +- **SSH:** `devdesk attach `, `devdesk new [project]`, `devdesk rename `, `devdesk end `, `devdesk ls`, `devdesk pick`. ## Keys | Key | Action | @@ -44,7 +51,7 @@ Use the **Full** button for fullscreen. In Chromium it also captures Ctrl+W/Ctrl ## Configuration - `/etc/devdesk/env` (conffile): - - port, bind address and projects root + - port, bind address, projects root and `DEVDESK_DEFAULT_DIR` - `CLAUDE_BIN` and `CLAUDE_ARGS` - `DEVDESK_REMOTE_CONTROL=1`, which also starts each session with Claude Remote Control so it can be continued from the Claude app - `/etc/devdesk/secret.env` is generated on install. It holds the ttyd credential that NPM must inject; see [docs/DEPLOY.md](docs/DEPLOY.md). @@ -65,5 +72,5 @@ CI (`.gitea/workflows/build-deb.yml`): - A `v*` tag also publishes to `https://code.lotusguild.org/api/packages/LotusGuild/debian` and creates a release. ## Roadmap -- **v1.1:** `devdesk-api` sidecar for the Repo/CI and Claude Context panels, Matrix notifications when you're away, and auto-deploy. +- **v1.1:** `devdesk-api` sidecar for the Repo/CI panel, Matrix notifications when you're away, and auto-deploy. - **v2:** Ops Alerts (Grafana) with "Ask Claude" prompts, and file drop. diff --git a/bin/devdesk b/bin/devdesk index c588223..f4b5ef0 100755 --- a/bin/devdesk +++ b/bin/devdesk @@ -1,18 +1,22 @@ #!/bin/bash # devdesk — persistent Claude Code sessions in tmux, shared by the browser UI and SSH. # -# devdesk attach [NAME] attach to session NAME (created on demand); no NAME opens the picker -# devdesk attach --list print sessions/projects as JSON (used by the web UI over ttyd) -# devdesk new NAME create session NAME detached, if it does not exist -# devdesk ls list sessions -# devdesk end NAME end session NAME and forget it +# devdesk attach [NAME] attach to session NAME, creating it if needed; no NAME opens the picker +# devdesk new NAME [PROJECT] create session NAME (detached) in PROJECT's folder, or the default folder +# devdesk rename OLD NEW rename a session (running or dormant) +# devdesk end NAME stop session NAME and forget it +# devdesk ls list sessions +# devdesk pick interactive picker +# +# The web UI calls `devdesk attach --list | --new NAME [PROJECT] | --rename OLD NEW | --end NAME` +# through ttyd; those print JSON. set -euo pipefail # shellcheck source=libexec/devdesk-lib.sh . "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" usage() { - sed -n '2,9s/^# \{0,1\}//p' "$0" + sed -n '2,10s/^# \{0,1\}//p' "$0" } # Tab-separated running sessions: name, dir, attached clients, last activity, windows. @@ -36,6 +40,17 @@ projects_tsv() { done } +# {"": {model, used_pct, ...}} from the status-line snapshots. +context_json() { + local f + if [ -d "$DEVDESK_CONTEXT_DIR" ]; then + for f in "$DEVDESK_CONTEXT_DIR"/*.json; do + [ -f "$f" ] || continue + jq -cM --arg n "$(basename "$f" .json)" '{($n): .}' "$f" 2>/dev/null || true + done + fi | jq -cMs 'add // {}' +} + cmd_list() { local reg='{"sessions":{}}' [ -s "$DEVDESK_REGISTRY" ] && reg=$(cat "$DEVDESK_REGISTRY") @@ -43,6 +58,7 @@ cmd_list() { --argjson reg "$reg" \ --arg run "$(running_tsv)" \ --arg proj "$(projects_tsv)" \ + --argjson ctx "$(context_json)" \ --arg dflt "$DEVDESK_DEFAULT_SESSION" \ --arg root "$DEVDESK_PROJECTS_ROOT" ' def rows($s): $s | split("\n") | map(select(length > 0) | split("\t")); @@ -59,7 +75,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)}), projects: ([{name: $dflt, dir: $root}] + [rows($proj)[] | {name: .[0], dir: .[1]} | select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}' @@ -68,21 +84,14 @@ cmd_list() { cmd_ls() { cmd_list | jq -Mr ' .sessions | if length == 0 then "no sessions" else - (["", "SESSION", "CLIENTS", "DIRECTORY"] | @tsv), - (.[] | [(if .running then "●" else "○" end), .name, (.attached | tostring), .dir] | @tsv) + (["", "SESSION", "CLIENTS", "CONTEXT", "DIRECTORY"] | @tsv), + (.[] | [(if .running then "●" else "○" end), .name, (.attached | tostring), + (if .context.used_pct then "\(.context.used_pct | floor)%" else "-" end), .dir] | @tsv) end' | column -t -s $'\t' } -# Prints the project directory for NAME, or "" if NAME already runs and has none we can resolve. -session_dir() { - local name=$1 dir - if dir=$(dd_resolve_dir "$name"); then - printf '%s\n' "$dir" - elif dd_tmux has-session -t "=$name" 2>/dev/null; then - printf '%s\n' "$DEVDESK_PROJECTS_ROOT" - else - dd_die "no project directory for '$name' under $DEVDESK_PROJECTS_ROOT" - fi +session_exists() { + { dd_server_up && dd_tmux has-session -t "=$1" 2>/dev/null; } || [ -n "$(dd_registry_get "$1" dir)" ] } create_detached() { @@ -96,27 +105,73 @@ create_detached() { } cmd_new() { - [ $# -eq 1 ] || dd_die "usage: devdesk new NAME" + { [ $# -ge 1 ] && [ $# -le 2 ]; } || dd_die "usage: devdesk new NAME [PROJECT]" dd_valid_name "$1" || dd_die "invalid session name: $1" - dd_ensure_server local dir - dir=$(session_dir "$1") + if [ $# -eq 2 ]; then + dd_valid_name "$2" || dd_die "invalid project name: $2" + dir=$(dd_project_dir "$2") || dd_die "no project '$2' under $DEVDESK_PROJECTS_ROOT" + ! session_exists "$1" || dd_die "a session named '$1' already exists" + else + dir=$(dd_resolve_dir "$1") + fi + dd_ensure_server create_detached "$1" "$dir" } +cmd_rename() { + [ $# -eq 2 ] || dd_die "usage: devdesk rename OLD NEW" + local old=$1 new=$2 running=0 + dd_valid_name "$old" || dd_die "invalid session name: $old" + dd_valid_name "$new" || dd_die "invalid session name: $new" + [ "$old" != "$new" ] || return 0 + if dd_server_up && dd_tmux has-session -t "=$old" 2>/dev/null; then running=1; fi + [ "$running" = 1 ] || [ -n "$(dd_registry_get "$old" dir)" ] || dd_die "no session named '$old'" + ! session_exists "$new" || dd_die "a session named '$new' already exists" + if [ "$running" = 1 ]; then + dd_tmux rename-session -t "=$old" "$new" + dd_tmux set-environment -t "=$new" DEVDESK_SESSION "$new" + fi + dd_registry_rename "$old" "$new" +} + +cmd_end() { + [ $# -eq 1 ] || dd_die "usage: devdesk end NAME" + dd_valid_name "$1" || dd_die "invalid session name: $1" + if dd_server_up; then + dd_tmux kill-session -t "=$1" 2>/dev/null || true + fi + dd_registry_remove "$1" +} + +ok_json() { + printf '{"ok":true}\n' +} + cmd_attach() { if [ $# -eq 0 ]; then exec "$DEVDESK_LIBEXEC/devdesk-picker" fi - [ $# -eq 1 ] || dd_die "attach takes exactly one session name" - if [ "$1" = "--list" ]; then + case $1 in + --list) + [ $# -eq 1 ] || dd_die "--list takes no arguments" cmd_list return - fi + ;; + --new | --rename | --end) + local action=${1#--} + shift + "cmd_$action" "$@" + ok_json + return + ;; + -*) dd_die "unknown option: $1" ;; + esac + [ $# -eq 1 ] || dd_die "attach takes exactly one session name" local name=$1 dir args dd_valid_name "$name" || dd_die "invalid session name: $name" dd_ensure_server - dir=$(session_dir "$name") + dir=$(dd_resolve_dir "$name") # Already inside a DevDesk pane: switch this client instead of nesting. local outer=${TMUX:-} @@ -134,20 +189,12 @@ cmd_attach() { exec tmux -N -u -S "$DEVDESK_SOCKET" new-session -A "${args[@]}" } -cmd_end() { - [ $# -eq 1 ] || dd_die "usage: devdesk end NAME" - dd_valid_name "$1" || dd_die "invalid session name: $1" - if dd_server_up; then - dd_tmux kill-session -t "=$1" 2>/dev/null || true - fi - dd_registry_remove "$1" -} - cmd=${1:-help} [ $# -gt 0 ] && shift case $cmd in attach | a) cmd_attach "$@" ;; new) cmd_new "$@" ;; +rename | mv) cmd_rename "$@" ;; ls | list) cmd_ls ;; --list) cmd_list ;; end) cmd_end "$@" ;; diff --git a/debian/changelog b/debian/changelog index d6f8767..c1ec7f5 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,18 @@ +devdesk (0.2.0) bookworm; urgency=medium + + * Sessions no longer need a project folder: any name works and starts in + DEVDESK_DEFAULT_DIR unless it matches a project; new NAME [PROJECT]. + * Rename and end sessions from the Sessions panel (and devdesk rename). + Renames keep the running Claude and its recorded conversation. + * Claude Context panel is live: a statusLine command snapshots model, + context-window use, cost and edits per session; Claude's footer shows a + one-line summary. + * Drop GANDALF from the Ops Alerts panel. + * CI: bash shell, download retries, Node 22 test invocation, + build-essential, smoke-test pipe fix. + + -- Jared Vititoe Wed, 07 Oct 2026 03:01:21 -0400 + devdesk (0.1.0) bookworm; urgency=medium * Initial release: persistent browser terminal for Claude Code diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 357f789..3af2d22 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -44,7 +44,9 @@ Enable the Proxmox firewall on CT 126 (`firewall=1` on net0) and allow tcp/7681 |---|---| | Sessions | `devdesk ls` | | Attach over SSH | `devdesk attach ` (or `devdesk pick`) | -| End a session | `devdesk end ` (or `[x]` in the Claude window's exit menu) | +| New session (optionally in a project) | `devdesk new [project]`, or the Sessions panel form | +| Rename a session | `devdesk rename `, or ✎ in the Sessions panel | +| End a session | `devdesk end `, × in the Sessions panel, or `[x]` in the Claude window's exit menu | | Restart the web side (safe) | `systemctl restart devdesk` | | **Stop every session** | `systemctl stop devdesk-tmux` | | Logs | `journalctl -u devdesk -u devdesk-tmux` | diff --git a/docs/devdesk.1 b/docs/devdesk.1 index d06d990..1d480e1 100644 --- a/docs/devdesk.1 +++ b/docs/devdesk.1 @@ -7,6 +7,10 @@ devdesk \- persistent Claude Code sessions in tmux, shared by the browser and SS .br .B devdesk new .I NAME +.RI [ PROJECT ] +.br +.B devdesk rename +.I OLD NEW .br .B devdesk ls .br @@ -26,16 +30,20 @@ resumes its own conversation. .SH COMMANDS .TP .B attach \fR[\fINAME\fR] -Attach to session \fINAME\fR, creating it if needed. The default session -(\fBmain\fR) uses the projects root; other names map to the project directory of -that name or slug (\(lqtest code\(rq becomes \fBtest-code\fR). Without \fINAME\fR, -opens the picker. +Attach to session \fINAME\fR, creating it if needed. Names matching a project +folder (or its slug: \(lqtest code\(rq becomes \fBtest-code\fR) start there; any +other name starts in \fBDEVDESK_DEFAULT_DIR\fR. Without \fINAME\fR, opens the picker. .TP .B attach --list Print sessions and projects as JSON (used by the web UI). .TP -.B new \fINAME\fR -Create session \fINAME\fR detached, if it does not exist. +.B new \fINAME\fR [\fIPROJECT\fR] +Create session \fINAME\fR detached, in \fIPROJECT\fR's folder if given, otherwise +in the folder its name maps to (a project of that name, or \fBDEVDESK_DEFAULT_DIR\fR). +.TP +.B rename \fIOLD NEW\fR +Rename a running or dormant session; Claude keeps running and its recorded +conversation moves with it. .TP .B ls List running (\(bu) and dormant (\(ci) sessions. diff --git a/etc/env b/etc/env index 9424738..0314408 100644 --- a/etc/env +++ b/etc/env @@ -12,6 +12,9 @@ DEVDESK_BIND= DEVDESK_PROJECTS_ROOT=/root/code DEVDESK_DEFAULT_SESSION=main +# Where sessions that are not a project folder start (e.g. ops sessions); empty = projects root. +DEVDESK_DEFAULT_DIR= + # Claude Code binary and extra arguments for every session. CLAUDE_BIN=/root/.local/bin/claude CLAUDE_ARGS= diff --git a/libexec/devdesk-claude-run b/libexec/devdesk-claude-run index a6a9736..fe6c738 100755 --- a/libexec/devdesk-claude-run +++ b/libexec/devdesk-claude-run @@ -7,7 +7,9 @@ set -uo pipefail # shellcheck source=libexec/devdesk-lib.sh . "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" -NAME=${DEVDESK_SESSION:?DEVDESK_SESSION is not set} +NAME=$(dd_current_session) +[ -n "$NAME" ] || dd_die "not running inside a DevDesk session" + CLAUDE_BIN=${CLAUDE_BIN:-claude} SETTINGS="$DEVDESK_SHARE/claude-settings.json" read -r -a EXTRA <<<"${CLAUDE_ARGS:-}" @@ -63,8 +65,10 @@ menu() { printf '\033]2;%s\033\134' "$NAME" # pane title (ST = ESC \\) mode=resume while :; do + NAME=$(dd_current_session) # follow renames if [ "$mode" = new ]; then run_new; else run_resume; fi rc=$? + NAME=$(dd_current_session) while :; do menu "$rc" IFS= read -rsn1 key || exit 0 diff --git a/libexec/devdesk-lib.sh b/libexec/devdesk-lib.sh index eca0305..817b182 100644 --- a/libexec/devdesk-lib.sh +++ b/libexec/devdesk-lib.sh @@ -11,6 +11,7 @@ if [ -r "$DEVDESK_ENV" ]; then fi : "${DEVDESK_PROJECTS_ROOT:=/root/code}" +: "${DEVDESK_DEFAULT_DIR:=$DEVDESK_PROJECTS_ROOT}" : "${DEVDESK_DEFAULT_SESSION:=main}" : "${DEVDESK_SOCKET:=/run/devdesk/tmux.sock}" : "${DEVDESK_STATE_DIR:=/var/lib/devdesk}" @@ -19,6 +20,7 @@ fi : "${DEVDESK_TMUX_UNIT:=devdesk-tmux.service}" DEVDESK_REGISTRY="$DEVDESK_STATE_DIR/registry.json" +DEVDESK_CONTEXT_DIR="$DEVDESK_STATE_DIR/context" DEVDESK_NAME_RE='^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$' dd_die() { @@ -99,29 +101,30 @@ dd_registry_set_claude() { dd_registry_remove() { dd_registry_update 'del(.sessions[$n])' --arg n "$1" + rm -f "$DEVDESK_CONTEXT_DIR/$1.json" } -# Session name -> absolute project directory (must resolve inside the projects root). -dd_resolve_dir() { +dd_registry_rename() { + dd_registry_update 'if .sessions[$old] then .sessions[$new] = .sessions[$old] | del(.sessions[$old]) else . end' \ + --arg old "$1" --arg new "$2" + if [ -f "$DEVDESK_CONTEXT_DIR/$1.json" ]; then + mv -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_CONTEXT_DIR/$2.json" + fi +} + +# Project name (directory or its slug) -> absolute directory inside the projects root. +dd_project_dir() { local name=$1 dir="" d root - if [ "$name" = "$DEVDESK_DEFAULT_SESSION" ]; then - dir=$DEVDESK_PROJECTS_ROOT + if [ -d "$DEVDESK_PROJECTS_ROOT/$name" ]; then + dir="$DEVDESK_PROJECTS_ROOT/$name" else - dir=$(dd_registry_get "$name" dir) - if [ -z "$dir" ] || [ ! -d "$dir" ]; then - dir="" - if [ -d "$DEVDESK_PROJECTS_ROOT/$name" ]; then - dir="$DEVDESK_PROJECTS_ROOT/$name" - else - for d in "$DEVDESK_PROJECTS_ROOT"/*/; do - d=${d%/} - if [ "$(dd_slugify "${d##*/}")" = "$name" ]; then - dir=$d - break - fi - done + for d in "$DEVDESK_PROJECTS_ROOT"/*/; do + d=${d%/} + if [ "$(dd_slugify "${d##*/}")" = "$name" ]; then + dir=$d + break fi - fi + done fi [ -n "$dir" ] || return 1 dir=$(realpath -e -- "$dir" 2>/dev/null) || return 1 @@ -132,6 +135,30 @@ dd_resolve_dir() { esac } +# Session name -> working directory. Sessions don't need a project: the +# registry wins, then a project of the same name, then DEVDESK_DEFAULT_DIR. +dd_resolve_dir() { + local name=$1 dir + dir=$(dd_registry_get "$name" dir) + if [ -n "$dir" ] && [ -d "$dir" ]; then + printf '%s\n' "$dir" + elif [ "$name" != "$DEVDESK_DEFAULT_SESSION" ] && dir=$(dd_project_dir "$name"); then + printf '%s\n' "$dir" + else + realpath -e -- "$DEVDESK_DEFAULT_DIR" 2>/dev/null || printf '%s\n' "$HOME" + fi +} + +# Name of the DevDesk session this process runs in. Asks tmux, so it stays +# right after a rename; falls back to the DEVDESK_SESSION set at creation. +dd_current_session() { + local name="" + if [ -n "${TMUX:-}" ] && [ -n "${TMUX_PANE:-}" ]; then + name=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null) + fi + printf '%s\n' "${name:-${DEVDESK_SESSION:-}}" +} + # Arguments for `tmux new-session` that create session NAME in DIR running Claude. dd_new_session_args() { printf '%s\0' -s "$1" -c "$2" -n claude -e "DEVDESK_SESSION=$1" "$DEVDESK_LIBEXEC/devdesk-claude-run" diff --git a/libexec/devdesk-session-hook b/libexec/devdesk-session-hook index 7df47d8..418680a 100755 --- a/libexec/devdesk-session-hook +++ b/libexec/devdesk-session-hook @@ -7,13 +7,14 @@ # shellcheck source=libexec/devdesk-lib.sh . "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" || exit 0 -[ -n "${DEVDESK_SESSION:-}" ] || exit 0 -dd_valid_name "$DEVDESK_SESSION" || exit 0 +session=$(dd_current_session) +[ -n "$session" ] || exit 0 +dd_valid_name "$session" || exit 0 input=$(cat) id=$(jq -r '.session_id // empty' <<<"$input" 2>/dev/null) source=$(jq -r '.source // empty' <<<"$input" 2>/dev/null) [ -n "$id" ] || exit 0 -dd_registry_set_claude "$DEVDESK_SESSION" "$id" "$source" 2>/dev/null +dd_registry_set_claude "$session" "$id" "$source" 2>/dev/null exit 0 diff --git a/libexec/devdesk-statusline b/libexec/devdesk-statusline new file mode 100755 index 0000000..f484c6c --- /dev/null +++ b/libexec/devdesk-statusline @@ -0,0 +1,48 @@ +#!/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. +# Runs often while Claude streams, so it stays cheap and never fails loudly. + +input=$(cat) + +session="" +if [ -n "${TMUX:-}" ] && [ -n "${TMUX_PANE:-}" ]; then + session=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null) +fi +session=${session:-${DEVDESK_SESSION:-}} + +if [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then + dir="${DEVDESK_STATE_DIR:-/var/lib/devdesk}/context" + if mkdir -p "$dir" 2>/dev/null && tmp=$(mktemp "$dir/.$session.XXXXXX" 2>/dev/null); then + if jq -cM '{ + updated: (now | floor), + claude_session_id: .session_id, + model: .model.display_name, + model_id: .model.id, + effort: .effort.level, + used_pct: .context_window.used_percentage, + window: .context_window.context_window_size, + used_tokens: (.context_window.current_usage + | if . then ((.input_tokens // 0) + (.cache_creation_input_tokens // 0) + + (.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 + }' <<<"$input" >"$tmp" 2>/dev/null; then + mv -f "$tmp" "$dir/$session.json" + else + rm -f "$tmp" + fi + fi +fi + +# Footer line, in TDS colours: session · model · context % · cost +jq -rM --arg s "$session" ' + def c($rgb; $t): "\u001b[38;2;\($rgb)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 diff --git a/share/claude-settings.json b/share/claude-settings.json index a01e548..00c475c 100644 --- a/share/claude-settings.json +++ b/share/claude-settings.json @@ -12,5 +12,10 @@ ] } ] + }, + "statusLine": { + "type": "command", + "command": "/usr/lib/devdesk/devdesk-statusline", + "padding": 0 } } diff --git a/tests/attach.bats b/tests/attach.bats index 4f11ab0..1f79441 100644 --- a/tests/attach.bats +++ b/tests/attach.bats @@ -46,11 +46,14 @@ claude_args() { @test "rejects invalid session names" { start_server - for bad in ../etc "a b" -x .hidden "$(printf 'x%.0s' {1..70})"; do + for bad in ../etc "a b" .hidden "$(printf 'x%.0s' {1..70})"; do run devdesk attach "$bad" [ "$status" -ne 0 ] [[ $output == *"invalid session name"* ]] done + run devdesk attach -x + [ "$status" -ne 0 ] + [[ $output == *"unknown option"* ]] run devdesk attach one two [ "$status" -ne 0 ] } @@ -98,12 +101,113 @@ claude_args() { [ "$output" = "$DEVDESK_PROJECTS_ROOT/test code" ] } +@test "sessions without a project start in the default folder" { + start_server + run devdesk new pbs-upgrade + [ "$status" -eq 0 ] + run tmux -S "$DEVDESK_SOCKET" display -p -t =pbs-upgrade:claude '#{pane_current_path}' + [ "$output" = "$DEVDESK_PROJECTS_ROOT" ] +} + @test "symlinks may not escape the projects root" { start_server run devdesk new escape + [ "$status" -eq 0 ] + run tmux -S "$DEVDESK_SOCKET" display -p -t =escape:claude '#{pane_current_path}' + [ "$output" = "$DEVDESK_PROJECTS_ROOT" ] + run devdesk new other escape [ "$status" -ne 0 ] - [[ $output == *"no project directory"* ]] - run devdesk new nonexistent + [[ $output == *"no project 'escape'"* ]] +} + +@test "new NAME PROJECT starts the session in that project" { + start_server + run devdesk new ops-alpha alpha + [ "$status" -eq 0 ] + run tmux -S "$DEVDESK_SOCKET" display -p -t =ops-alpha:claude '#{pane_current_path}' + [ "$output" = "$DEVDESK_PROJECTS_ROOT/alpha" ] + run devdesk new ops-alpha alpha + [ "$status" -ne 0 ] + [[ $output == *"already exists"* ]] +} + +@test "rename moves a running session, its registry entry and its context" { + start_server + devdesk new alpha + echo '{"session_id":"abc-123","source":"startup"}' | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook" + mkdir -p "$DEVDESK_STATE_DIR/context" && echo '{"used_pct":12}' >"$DEVDESK_STATE_DIR/context/alpha.json" + run devdesk rename alpha ops + [ "$status" -eq 0 ] + run tmux -S "$DEVDESK_SOCKET" has-session -t =ops + [ "$status" -eq 0 ] + run tmux -S "$DEVDESK_SOCKET" show-environment -t =ops DEVDESK_SESSION + [ "$output" = "DEVDESK_SESSION=ops" ] + [ "$(jq -r '.sessions.ops.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = abc-123 ] + [ "$(jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json")" = false ] + [ -f "$DEVDESK_STATE_DIR/context/ops.json" ] + [ ! -e "$DEVDESK_STATE_DIR/context/alpha.json" ] +} + +@test "rename refuses clashes and unknown sessions" { + start_server + devdesk new alpha + devdesk new main + run devdesk rename alpha main + [ "$status" -ne 0 ] + [[ $output == *"already exists"* ]] + run devdesk rename nosuch other + [ "$status" -ne 0 ] + [[ $output == *"no session named"* ]] + run devdesk rename alpha "bad name" + [ "$status" -ne 0 ] +} + +@test "rename works for dormant sessions" { + echo '{"session_id":"old-1","source":"resume"}' | DEVDESK_SESSION=gone "$REPO/libexec/devdesk-session-hook" + jq '.sessions.gone.dir = "/tmp"' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json" + run devdesk rename gone archived + [ "$status" -eq 0 ] + [ "$(jq -r '.sessions.archived.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = old-1 ] +} + +@test "hooks follow the tmux session name after a rename" { + start_server + devdesk new alpha + devdesk rename alpha renamed + pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =renamed:shell '#{pane_id}') + echo '{"session_id":"after-rename","source":"clear"}' | + TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook" + [ "$(jq -r '.sessions.renamed.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = after-rename ] + [ "$(jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json")" = false ] +} + +@test "statusline snapshots context for the session and --list reports it" { + start_server + devdesk new alpha + pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}') + status_json='{"session_id":"s1","model":{"id":"m","display_name":"Opus"},"effort":{"level":"high"}, + "context_window":{"context_window_size":1000000,"used_percentage":34.6, + "current_usage":{"input_tokens":1000,"cache_creation_input_tokens":2000,"cache_read_input_tokens":343000}}, + "cost":{"total_cost_usd":1.5,"total_lines_added":10,"total_lines_removed":2}}' + run bash -c "echo '$status_json' | TMUX='$DEVDESK_SOCKET,1,0' TMUX_PANE='$pane' '$REPO/libexec/devdesk-statusline'" + [ "$status" -eq 0 ] + [[ $output == *"alpha"* && $output == *"ctx 34%"* ]] + run devdesk attach --list + [ "$(jq -r '.sessions[] | select(.name=="alpha") | .context.used_tokens' <<<"$output")" = 346000 ] + [ "$(jq -r '.sessions[] | select(.name=="alpha") | .context.model' <<<"$output")" = Opus ] +} + +@test "web actions run through attach and answer with JSON" { + start_server + run devdesk attach --new web1 alpha + [ "$output" = '{"ok":true}' ] + run devdesk attach --rename web1 web2 + [ "$output" = '{"ok":true}' ] + run devdesk attach --end web2 + [ "$output" = '{"ok":true}' ] + run tmux -S "$DEVDESK_SOCKET" has-session -t =web2 + [ "$status" -ne 0 ] + run devdesk attach --bogus [ "$status" -ne 0 ] } diff --git a/tests/protocol.test.mjs b/tests/protocol.test.mjs index 45388e8..1d049db 100644 --- a/tests/protocol.test.mjs +++ b/tests/protocol.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { Client, FlowControl, decodeServer, encodeBinaryInput, encodeInit, encodeInput, encodeResize, - parseListOutput, parseTitle, sessionFromSearch, tokenUrl, wsUrl, + parseListOutput, parseTitle, sessionFromSearch, slugify, stripAnsi, tokenUrl, wsUrl, } from '../web/src/protocol.js'; const text = (bytes) => new TextDecoder().decode(bytes); @@ -34,6 +34,19 @@ test('URLs follow the page location, including a path prefix', () => { assert.equal(tokenUrl(loc), 'https://dev.lotusguild.org/token'); const sub = { protocol: 'http:', host: 'localhost:7681', pathname: '/desk/index.html' }; assert.equal(wsUrl(sub, '--list'), 'ws://localhost:7681/desk/ws?arg=--list'); + assert.equal(wsUrl(loc, ['--rename', 'old', 'new']), 'wss://dev.lotusguild.org/ws?arg=--rename&arg=old&arg=new'); +}); + +test('free text becomes a valid session name', () => { + assert.equal(slugify(' PBS upgrade! '), 'PBS-upgrade'); + assert.equal(slugify('--../etc'), 'etc'); + assert.equal(slugify('tinker_tickets'), 'tinker_tickets'); + assert.equal(slugify('!!!'), ''); + assert.equal(slugify('x'.repeat(80)).length, 64); +}); + +test('ANSI colour and title sequences are stripped', () => { + assert.equal(stripAnsi('\x1b[1;31mdevdesk: no\x1b[0m\x1b]0;t\x07'), 'devdesk: no'); }); test('only valid session names are taken from the URL', () => { diff --git a/web/index.template.html b/web/index.template.html index e6a3184..6480ecd 100644 --- a/web/index.template.html +++ b/web/index.template.html @@ -59,17 +59,36 @@
    -
    - - -
    +
    + + + +
    - +
    +
    +
    Claude Context
    +
    +
    +
    +
    +
    + Context— + Model— + Cost— + Edits— + Updated— +
    +

    +
    +
    +
    Repo / CI v1.1
    @@ -99,19 +118,6 @@
    -
    -
    Claude Context v1.1
    -
    -
    -
    -
    -
    - Used— - Model— -
    -

    Fed by a Claude statusLine hook through devdesk-api.

    -
    -
    diff --git a/web/src/connection.js b/web/src/connection.js index a9b6d37..1c99550 100644 --- a/web/src/connection.js +++ b/web/src/connection.js @@ -1,6 +1,6 @@ import { Server, LIST_ARG, decodeServer, encodeBinaryInput, encodeCommand, encodeInit, - encodeInput, encodeResize, parseListOutput, tokenUrl, wsUrl, FlowControl, + encodeInput, encodeResize, parseListOutput, stripAnsi, tokenUrl, wsUrl, FlowControl, } from './protocol.js'; export class AuthExpiredError extends Error {} @@ -94,16 +94,17 @@ export class TtydConnection { } } -// Ask the server for sessions + projects: a short-lived `devdesk attach --list`. -export async function fetchSessionList(timeoutMs = 8000) { +// Run a short-lived `devdesk attach ` and return its output as text. +// ttyd is the only server-side endpoint, so session actions travel this way. +export async function runCommand(args, timeoutMs = 8000) { const token = await fetchToken(); return new Promise((resolve, reject) => { - const socket = new WebSocket(wsUrl(window.location, LIST_ARG), ['tty']); + const socket = new WebSocket(wsUrl(window.location, args), ['tty']); socket.binaryType = 'arraybuffer'; const chunks = []; const timer = setTimeout(() => { socket.close(); - reject(new Error('session list timed out')); + reject(new Error('devdesk did not answer')); }, timeoutMs); socket.onopen = () => socket.send(encodeInit(token, 240, 24)); socket.onmessage = (event) => { @@ -113,16 +114,25 @@ export async function fetchSessionList(timeoutMs = 8000) { socket.onerror = () => {}; socket.onclose = () => { clearTimeout(timer); - try { - const text = new TextDecoder().decode(concat(chunks)); - resolve(parseListOutput(text)); - } catch (err) { - reject(err); - } + resolve(new TextDecoder().decode(concat(chunks))); }; }); } +// Sessions, projects and per-session Claude context (`devdesk attach --list`). +export async function fetchSessionList() { + return parseListOutput(await runCommand([LIST_ARG])); +} + +// `devdesk attach --new|--rename|--end …`: resolves on {"ok":true}, otherwise +// rejects with devdesk's own error message. +export async function sessionAction(action, ...args) { + const output = stripAnsi(await runCommand([`--${action}`, ...args])).trim(); + if (/"ok"\s*:\s*true/.test(output)) return; + const message = output.split(/\r?\n/).filter(Boolean).pop() || 'no response'; + throw new Error(message.replace(/^devdesk:\s*/, '')); +} + function concat(chunks) { const total = chunks.reduce((n, c) => n + c.length, 0); const out = new Uint8Array(total); diff --git a/web/src/context.js b/web/src/context.js new file mode 100644 index 0000000..0466f2d --- /dev/null +++ b/web/src/context.js @@ -0,0 +1,51 @@ +// Claude Context panel: the current session's model, context-window use, cost +// and edits, from the snapshot devdesk-statusline writes on every Claude update. + +import { ago } from './sessions.js'; + +function compact(tokens) { + if (tokens == null) return '—'; + if (tokens >= 1e6) return `${+(tokens / 1e6).toFixed(tokens % 1e6 ? 1 : 0)}M`; + if (tokens >= 1e3) return `${Math.round(tokens / 1e3)}k`; + return String(tokens); +} + +export function renderContext(root, session, context) { + const set = (field, text) => { + const node = root.querySelector(`[data-field="${field}"]`); + if (node) node.textContent = text; + }; + const bar = root.querySelector('.lt-progress'); + const fill = root.querySelector('.lt-progress-bar'); + const note = root.querySelector('.dd-context-note'); + + if (!context) { + root.dataset.state = 'empty'; + fill.style.width = '0%'; + bar.setAttribute('aria-valuenow', '0'); + for (const f of ['used', 'model', 'cost', 'lines', 'updated']) set(f, '—'); + note.textContent = session + ? 'No data yet: it appears once Claude has redrawn its status line in this session.' + : 'Open a session to see its context.'; + return; + } + + const pct = context.used_pct; + root.dataset.state = 'live'; + fill.style.width = `${Math.min(100, pct ?? 0)}%`; + bar.setAttribute('aria-valuenow', String(Math.round(pct ?? 0))); + bar.classList.remove('lt-progress--cyan', 'lt-progress--red'); + if (pct == null || pct < 70) bar.classList.add('lt-progress--cyan'); + else if (pct >= 90) bar.classList.add('lt-progress--red'); + + set('used', pct == null + ? `— / ${compact(context.window)}` + : `${compact(context.used_tokens)} / ${compact(context.window)} (${pct.toFixed(pct < 10 ? 1 : 0)}%)`); + set('model', [context.model, context.effort].filter(Boolean).join(' · ') || '—'); + set('cost', context.cost_usd == null ? '—' : `$${context.cost_usd.toFixed(2)}`); + set('lines', `+${context.lines_added ?? 0} −${context.lines_removed ?? 0}`); + set('updated', context.updated ? `${ago(context.updated)} ago`.replace('now ago', 'just now') : '—'); + note.textContent = pct != null && pct >= 80 + ? 'Context is filling up; consider /compact or a new conversation.' + : ''; +} diff --git a/web/src/main.js b/web/src/main.js index 402c3ad..e4b4ccb 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -3,6 +3,7 @@ import { createTerminal, loadFonts } from './term.js'; import { TtydConnection, fetchToken, AuthExpiredError, UnauthorizedError } from './connection.js'; import { parseTitle, sessionFromSearch } from './protocol.js'; import { SessionsPanel, rememberSession } from './sessions.js'; +import { renderContext } from './context.js'; import { bindAskClaude } from './widgets.js'; import * as notify from './notify.js'; @@ -47,6 +48,7 @@ const state = { attempts: 0, retryTimer: null, openedAt: 0, + endingCurrent: false, lastActivity: Date.now(), }; @@ -88,6 +90,18 @@ function hideOverlay() { ui.overlay.hidden = true; } +function showContext() { + const data = sessionsPanel?.data; + const current = data?.sessions?.find((s) => s.name === state.session); + renderContext($('widget-context'), state.session, current?.context ?? null); +} + +let refreshTimer = 0; +function refreshSoon(delay = 1500) { + clearTimeout(refreshTimer); + refreshTimer = setTimeout(() => sessionsPanel?.refresh(), delay); +} + function updateUrl(session) { const url = new URL(window.location.href); if (session) url.searchParams.set('arg', session); @@ -103,6 +117,7 @@ function connect(session) { state.session = session; updateUrl(session); sessionsPanel?.setCurrent(session); + showContext(); ui.sessionChip.textContent = session || 'picker'; hideOverlay(); setWsState('connecting', 'connecting'); @@ -132,6 +147,12 @@ const pickerAction = { label: 'Sessions…', run: () => connect(null) }; function onClosed(code) { setWsState('disconnected', 'offline'); + if (state.endingCurrent) { + // We just ended the session we were attached to: go back to the picker. + state.endingCurrent = false; + connect(null); + return; + } // ttyd reports a non-zero exit of `devdesk attach` with an invalid close code, // which browsers surface as 1006 — same as a dropped network. A close within a // few seconds of opening means the session could not start (the reason is @@ -229,6 +250,7 @@ function applyTitle() { updateUrl(parsed.session); rememberSession(parsed.session); sessionsPanel.setCurrent(parsed.session); + showContext(); } ui.sessionChip.textContent = parsed.session; ui.paneTitle.textContent = parsed.pane; @@ -311,7 +333,7 @@ function wireChrome() { }); setInterval(() => { if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) sessionsPanel.refresh(); - }, 30 * 1000); + }, 15 * 1000); } // ---------------------------------------------------------------- boot @@ -332,7 +354,10 @@ async function boot() { pendingTitle = title; if (!titleFrame) titleFrame = requestAnimationFrame(applyTitle); }); - term.onBell(() => notify.ring(state.session, ui.paneTitle.textContent)); + term.onBell(() => { + notify.ring(state.session, ui.paneTitle.textContent); + refreshSoon(); // Claude finished a turn: context numbers just changed + }); let fitFrame = 0; new ResizeObserver(() => { @@ -347,13 +372,30 @@ async function boot() { sessionsPanel = new SessionsPanel({ list: $('sessions-list'), + form: $('new-session-form'), + nameInput: $('new-session-name'), select: $('project-select'), - openButton: $('project-open'), refreshButton: $('sessions-refresh'), pickerButton: $('open-picker'), status: $('sessions-status'), - onOpen: (name) => connect(name), - onPicker: () => connect(null), + hooks: { + onOpen: (name) => connect(name), + onPicker: () => connect(null), + onEnding: () => { state.endingCurrent = true; }, + onEnded: (name) => { + if (name !== state.session) state.endingCurrent = false; + }, + onEndAborted: () => { state.endingCurrent = false; }, + onRenamed: (from, to) => { + if (from !== state.session) return; + state.session = to; + updateUrl(to); + rememberSession(to); + ui.sessionChip.textContent = to; + sessionsPanel.setCurrent(to); + }, + onData: () => showContext(), + }, }); bindAskClaude($('sidebar'), (text) => { state.conn?.sendInput(text); diff --git a/web/src/protocol.js b/web/src/protocol.js index d148c5e..b477481 100644 --- a/web/src/protocol.js +++ b/web/src/protocol.js @@ -53,12 +53,25 @@ export function tokenUrl(location) { return `${location.protocol}//${location.host}${basePath(location)}token`; } -export function wsUrl(location, arg) { +// ttyd --url-arg appends each ?arg= to the command line: `devdesk attach `. +export function wsUrl(location, args) { const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const query = arg ? `?arg=${encodeURIComponent(arg)}` : ''; + const list = Array.isArray(args) ? args : args ? [args] : []; + const query = list.length ? `?${list.map((a) => `arg=${encodeURIComponent(a)}`).join('&')}` : ''; return `${scheme}//${location.host}${basePath(location)}ws${query}`; } +// Session names are [A-Za-z0-9_-]; turn free text ("PBS upgrade!") into one ("PBS-upgrade"). +export function slugify(text) { + return String(text || '').trim().replace(/[^A-Za-z0-9_-]+/g, '-') + .replace(/^[-_]+/, '').replace(/-+$/, '').slice(0, 64); +} + +export function stripAnsi(text) { + // eslint-disable-next-line no-control-regex + return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, ''); +} + export function sessionFromSearch(search) { const value = new URLSearchParams(search).get('arg'); return value && SESSION_RE.test(value) ? value : null; @@ -74,8 +87,7 @@ export function parseTitle(title) { // Output of `devdesk attach --list` arrives through a pty: CRLF line endings and // possibly split across frames. Pull the JSON object out of the joined text. export function parseListOutput(raw) { - // eslint-disable-next-line no-control-regex - const text = raw.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, ''); // tools may colorize on a tty + const text = stripAnsi(raw); // tools may colorize on a tty const start = text.indexOf('{'); const end = text.lastIndexOf('}'); if (start < 0 || end < start) throw new Error('no session list in output'); diff --git a/web/src/sessions.js b/web/src/sessions.js index 7b3ee96..399f063 100644 --- a/web/src/sessions.js +++ b/web/src/sessions.js @@ -1,7 +1,8 @@ -// Sessions panel: server state (via `devdesk attach --list`) plus recently used -// sessions remembered in this browser. +// Sessions panel: server state (via `devdesk attach --list`), recently used +// sessions remembered in this browser, and session actions (new/rename/end). -import { fetchSessionList } from './connection.js'; +import { fetchSessionList, sessionAction } from './connection.js'; +import { slugify } from './protocol.js'; const RECENTS_KEY = 'devdesk.recents'; const MAX_RECENTS = 12; @@ -15,18 +16,27 @@ function readRecents() { } } -export function rememberSession(name) { - if (!name) return; +function writeRecents(recents) { try { - const recents = readRecents().filter((r) => r.name !== name); - recents.unshift({ name, at: Date.now() }); localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, MAX_RECENTS))); } catch { // Storage unavailable (private mode): recents are a convenience only. } } -function ago(epochSeconds) { +export function rememberSession(name) { + if (name) writeRecents([{ name, at: Date.now() }, ...readRecents().filter((r) => r.name !== name)]); +} + +function forgetRecent(name) { + writeRecents(readRecents().filter((r) => r.name !== name)); +} + +function renameRecent(from, to) { + writeRecents(readRecents().map((r) => (r.name === from ? { ...r, name: to } : r))); +} + +export function ago(epochSeconds) { if (!epochSeconds) return ''; const s = Math.max(0, Date.now() / 1000 - epochSeconds); if (s < 90) return 'now'; @@ -35,26 +45,30 @@ function ago(epochSeconds) { return `${Math.floor(s / 86400)}d`; } -function el(tag, attrs = {}, ...children) { +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); + else node.setAttribute(key, value === true ? '' : value); } node.append(...children.filter((c) => c !== null && c !== undefined)); return node; } export class SessionsPanel { - constructor({ list, select, openButton, refreshButton, pickerButton, status, onOpen, onPicker }) { - Object.assign(this, { list, select, status, onOpen }); + // hooks: onOpen(name), onPicker(), onEnded(name), onRenamed(from, to), onData(data) + constructor({ list, form, nameInput, select, refreshButton, pickerButton, status, hooks }) { + Object.assign(this, { list, nameInput, select, status, hooks }); this.current = null; this.data = null; + this.busy = false; refreshButton.addEventListener('click', () => this.refresh()); - pickerButton.addEventListener('click', () => onPicker()); - openButton.addEventListener('click', () => { - if (select.value) onOpen(select.value); + pickerButton.addEventListener('click', () => hooks.onPicker()); + form.addEventListener('submit', (event) => { + event.preventDefault(); + this.createFromForm(); }); } @@ -63,56 +77,134 @@ export class SessionsPanel { if (this.data) this.render(); } + setStatus(text, isError = false) { + this.status.textContent = text; + this.status.classList.toggle('is-error', isError); + } + async refresh() { - this.status.textContent = 'refreshing…'; + if (this.busy) return; + this.busy = true; try { this.data = await fetchSessionList(); - this.status.textContent = ''; + if (!this.status.classList.contains('is-error')) this.setStatus(''); } catch (err) { - this.status.textContent = `list unavailable: ${err.message}`; + this.setStatus(`list unavailable: ${err.message}`, true); + } finally { + this.busy = false; } this.render(); + this.hooks.onData?.(this.data); + } + + async act(label, action, ...args) { + this.setStatus(`${label}…`); + try { + await sessionAction(action, ...args); + this.setStatus(''); + return true; + } catch (err) { + this.setStatus(err.message, true); + return false; + } + } + + async createFromForm() { + 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); + 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; + this.nameInput.value = ''; + this.select.value = ''; + this.hooks.onOpen(name); + } + + 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)) { + renameRecent(name, next); + this.hooks.onRenamed(name, next); + await this.refresh(); + } + } + + 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; + if (session.name === this.current) this.hooks.onEnding?.(session.name); + if (await this.act(session.running ? 'ending' : 'forgetting', 'end', session.name)) { + forgetRecent(session.name); + this.hooks.onEnded(session.name); + await this.refresh(); + } else if (session.name === this.current) { + this.hooks.onEndAborted?.(); + } + } + + renderSession(s) { + const isCurrent = s.name === this.current; + const ctx = s.context?.used_pct != null ? ` · ctx ${Math.floor(s.context.used_pct)}%` : ''; + const meta = s.running + ? `${s.attached} attached · ${ago(s.activity)}${ctx}` + : 'dormant · resumes Claude'; + const iconButton = (label, glyph, onclick, extra = '') => + el('button', { class: `dd-icon-btn ${extra}`, type: 'button', title: label, 'aria-label': `${label} ${s.name}`, onclick }, glyph); + return el('li', { class: `dd-session-item${isCurrent ? ' is-current' : ''}${s.running ? '' : ' is-dormant'}` }, + el('button', { class: 'dd-session-open', type: 'button', title: s.dir || '', onclick: () => this.hooks.onOpen(s.name) }, + el('span', { class: `lt-dot ${s.running ? 'lt-dot-up' : 'lt-dot-idle'}` }), + el('span', { class: 'dd-session-name' }, s.name), + el('span', { class: 'dd-session-meta' }, meta)), + el('span', { class: 'dd-session-actions' }, + iconButton('Rename', '✎', () => this.rename(s.name)), + el('a', { + class: 'dd-icon-btn', href: `?arg=${encodeURIComponent(s.name)}`, target: '_blank', rel: 'noopener', + title: 'Open in a new tab', 'aria-label': `Open ${s.name} in a new tab`, + }, '↗'), + iconButton(s.running ? 'End session' : 'Forget', '×', () => this.end(s), 'dd-danger'))); } render() { const sessions = this.data?.sessions ?? []; const known = new Set(sessions.map((s) => s.name)); const recentsOnly = readRecents().filter((r) => !known.has(r.name)).slice(0, 5); - this.list.replaceChildren(); const ordered = [...sessions].sort((a, b) => - (b.running - a.running) || ((b.activity || 0) - (a.activity || 0))); - for (const s of ordered) { - const isCurrent = s.name === this.current; - const meta = s.running - ? `${s.attached} attached · ${ago(s.activity)}` - : 'dormant · resumes Claude'; - this.list.append(el('li', { class: `dd-session-item${isCurrent ? ' is-current' : ''}` }, - el('button', { - class: 'dd-session-open', type: 'button', title: s.dir || '', - onclick: () => this.onOpen(s.name), - }, - el('span', { class: `lt-dot ${s.running ? 'lt-dot-up' : 'lt-dot-idle'}` }), - el('span', { class: 'dd-session-name' }, s.name), - el('span', { class: 'dd-session-meta' }, meta)), - el('a', { - class: 'dd-session-tab', href: `?arg=${encodeURIComponent(s.name)}`, - target: '_blank', rel: 'noopener', title: 'Open in a new tab', 'aria-label': `Open ${s.name} in a new tab`, - }, '↗'))); - } + (b.running - a.running) || ((b.activity || 0) - (a.activity || 0)) || a.name.localeCompare(b.name)); + const items = ordered.map((s) => this.renderSession(s)); for (const r of recentsOnly) { - this.list.append(el('li', { class: 'dd-session-item is-recent' }, - el('button', { class: 'dd-session-open', type: 'button', onclick: () => this.onOpen(r.name) }, + items.push(el('li', { class: 'dd-session-item is-recent' }, + el('button', { class: 'dd-session-open', type: 'button', onclick: () => this.hooks.onOpen(r.name) }, el('span', { class: 'lt-dot lt-dot-idle' }), el('span', { class: 'dd-session-name' }, r.name), - el('span', { class: 'dd-session-meta' }, 'recent')))); - } - if (!this.list.children.length) { - this.list.append(el('li', { class: 'dd-empty' }, 'No sessions yet.')); + el('span', { class: 'dd-session-meta' }, 'recent in this browser')), + el('span', { class: 'dd-session-actions' }, + el('button', { + class: 'dd-icon-btn dd-danger', type: 'button', title: 'Remove from recents', + onclick: () => { forgetRecent(r.name); this.render(); }, + }, '×')))); } + if (!items.length) items.push(el('li', { class: 'dd-empty' }, 'No sessions yet.')); + this.list.replaceChildren(...items); - const projects = (this.data?.projects ?? []).filter((p) => !known.has(p.name)); - this.select.replaceChildren(el('option', { value: '' }, projects.length ? 'New session in…' : 'No other projects')); - for (const p of projects) this.select.append(el('option', { value: p.name, title: p.dir }, p.name)); + 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; } } diff --git a/web/styles.css b/web/styles.css index 53022cf..0327d59 100644 --- a/web/styles.css +++ b/web/styles.css @@ -210,13 +210,47 @@ body.dd-sidebar-open .dd-sidebar { .dd-session-item.is-recent .dd-session-name { color: var(--text-secondary); font-weight: 400; } -.dd-session-tab { +.dd-session-item.is-dormant .dd-session-name { color: var(--text-secondary); } + +.dd-session-actions { display: flex; align-items: center; - padding: 0 8px; - color: var(--text-muted); + gap: 2px; + padding-right: 2px; + opacity: 0.55; + transition: var(--transition-fast); +} +.dd-session-item:hover .dd-session-actions, +.dd-session-item:focus-within .dd-session-actions, +.dd-session-item.is-current .dd-session-actions { opacity: 1; } + +.dd-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + background: none; + border: 1px solid transparent; + color: var(--text-muted); + font-size: 0.8rem; + line-height: 1; + text-decoration: none; +} +.dd-icon-btn:hover, +.dd-icon-btn:focus-visible { + color: var(--accent-cyan); + border-color: var(--accent-cyan-border); + text-shadow: var(--glow-cyan); + text-decoration: none; +} +.dd-icon-btn.dd-danger:hover, +.dd-icon-btn.dd-danger:focus-visible { + color: var(--accent-red); + border-color: rgba(255, 45, 85, 0.4); + text-shadow: var(--glow-red); } -.dd-session-tab:hover { color: var(--accent-orange); } .dd-empty { color: var(--text-muted); @@ -225,15 +259,15 @@ body.dd-sidebar-open .dd-sidebar { } .dd-new-session { - display: flex; + display: grid; + grid-template-columns: 1fr auto; gap: var(--space-sm); margin-bottom: var(--space-sm); } -.dd-new-session .lt-select { - flex: 1 1 auto; - min-width: 0; - font-size: 0.72rem; -} +.dd-new-session .lt-input { grid-column: 1 / -1; font-size: 0.74rem; } +.dd-new-session .lt-select { min-width: 0; font-size: 0.72rem; } + +.dd-status.is-error { color: var(--accent-red); } .dd-widget-foot { display: flex; @@ -253,7 +287,8 @@ body.dd-sidebar-open .dd-sidebar { .dd-context-grid { margin-top: var(--space-sm); } -[data-state="stub"] .lt-section-body { opacity: 0.75; } +[data-state="stub"] .lt-section-body, +#widget-context[data-state="empty"] .lt-kv-grid { opacity: 0.6; } /* ---- overlay ----------------------------------------------------------------- */ .dd-overlay {