feat: free-form sessions, rename/end from the panel, live Claude Context (0.2.0)
build-deb / Lint, test, package (push) Successful in 2m0s
build-deb / Notify on failure (push) Skipped

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 03:03:09 -04:00
co-authored by Claude Opus 5.5
parent 4b153c1960
commit 527cb1fda2
22 changed files with 703 additions and 171 deletions
+1 -1
View File
@@ -8,6 +8,6 @@
"no-undef": "error",
"no-empty": "warn",
"semi": ["error", "always"],
"eqeqeq": "error"
"eqeqeq": ["error", "always", { "null": "ignore" }]
}
}
+1 -1
View File
@@ -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
+12 -5
View File
@@ -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=<name>` opens session `<name>` and creates it on demand in `$DEVDESK_PROJECTS_ROOT/<name>`. `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=<name>` opens or creates that session.
- With no `?arg=`, an fzf picker opens, listing running (●), dormant (○) and new (+) sessions.
- The **Sessions** panel's form takes any name plus an optional project folder.
- **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 <name>`, `devdesk ls`, `devdesk pick`, `devdesk end <name>`.
- **Claude Context panel:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use, cost and edits to `/var/lib/devdesk/context/<session>.json`. The panel shows the current session's numbers, and Claude's own footer shows a one-line summary. Sessions started before 0.2.0 need Claude restarted (`/exit`, then ⏎ in the menu) before they report.
- **SSH:** `devdesk attach <name>`, `devdesk new <name> [project]`, `devdesk rename <old> <new>`, `devdesk end <name>`, `devdesk ls`, `devdesk pick`.
## 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.
+82 -35
View File
@@ -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
}
# {"<session>": {model, used_pct, ...}} from the status-line snapshots.
context_json() {
local f
if [ -d "$DEVDESK_CONTEXT_DIR" ]; then
for f in "$DEVDESK_CONTEXT_DIR"/*.json; do
[ -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 "$@" ;;
+15
View File
@@ -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 <jjvititoe1@gmail.com> Wed, 07 Oct 2026 03:01:21 -0400
devdesk (0.1.0) bookworm; urgency=medium
* Initial release: persistent browser terminal for Claude Code
+3 -1
View File
@@ -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 <name>` (or `devdesk pick`) |
| End a session | `devdesk end <name>` (or `[x]` in the Claude window's exit menu) |
| New session (optionally in a project) | `devdesk new <name> [project]`, or the Sessions panel form |
| Rename a session | `devdesk rename <old> <new>`, or ✎ in the Sessions panel |
| End a session | `devdesk end <name>`, × 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` |
+14 -6
View File
@@ -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.
+3
View File
@@ -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=
+5 -1
View File
@@ -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
+45 -18
View File
@@ -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"
+4 -3
View File
@@ -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
+48
View File
@@ -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
+5
View File
@@ -12,5 +12,10 @@
]
}
]
},
"statusLine": {
"type": "command",
"command": "/usr/lib/devdesk/devdesk-statusline",
"padding": 0
}
}
+107 -3
View File
@@ -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 ]
}
+14 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
Client, FlowControl, decodeServer, encodeBinaryInput, encodeInit, encodeInput, encodeResize,
parseListOutput, parseTitle, sessionFromSearch, 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', () => {
+24 -18
View File
@@ -59,17 +59,36 @@
</div>
<div class="lt-section-body">
<ul class="dd-session-list" id="sessions-list"></ul>
<div class="dd-new-session">
<select class="lt-select" id="project-select" aria-label="Project for a new session"></select>
<button type="button" class="lt-btn lt-btn-primary lt-btn-sm" id="project-open">Open</button>
</div>
<form class="dd-new-session" id="new-session-form" autocomplete="off">
<input class="lt-input" id="new-session-name" maxlength="64" placeholder="New session, e.g. pbs-upgrade"
aria-label="New session name" spellcheck="false">
<select class="lt-select" id="project-select" aria-label="Start the new session in a project folder (optional)"></select>
<button type="submit" class="lt-btn lt-btn-primary lt-btn-sm">Open</button>
</form>
<div class="dd-widget-foot">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="open-picker">Picker</button>
<span class="dd-muted" id="sessions-status"></span>
<span class="dd-muted dd-status" id="sessions-status" role="status"></span>
</div>
</div>
</section>
<section class="lt-frame dd-widget" id="widget-context" data-state="empty">
<div class="lt-section-header">Claude Context</div>
<div class="lt-section-body">
<div class="lt-progress lt-progress--cyan" role="progressbar" aria-label="Context window used" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="lt-progress-bar" style="width:0%"></div>
</div>
<div class="lt-kv-grid dd-context-grid">
<span class="lt-kv-key">Context</span><span class="lt-kv-val" data-field="used">—</span>
<span class="lt-kv-key">Model</span><span class="lt-kv-val" data-field="model">—</span>
<span class="lt-kv-key">Cost</span><span class="lt-kv-val" data-field="cost">—</span>
<span class="lt-kv-key">Edits</span><span class="lt-kv-val" data-field="lines">—</span>
<span class="lt-kv-key">Updated</span><span class="lt-kv-val" data-field="updated">—</span>
</div>
<p class="dd-muted dd-stub-note dd-context-note"></p>
</div>
</section>
<section class="lt-frame dd-widget" id="widget-repo" data-state="stub">
<div class="lt-section-header">Repo / CI <span class="lt-badge lt-badge-sm dd-soon">v1.1</span></div>
<div class="lt-section-body">
@@ -99,19 +118,6 @@
</div>
</section>
<section class="lt-frame dd-widget" id="widget-context" data-state="stub">
<div class="lt-section-header">Claude Context <span class="lt-badge lt-badge-sm dd-soon">v1.1</span></div>
<div class="lt-section-body">
<div class="lt-progress lt-progress--cyan" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="lt-progress-bar" style="width:0%"></div>
</div>
<div class="lt-kv-grid dd-context-grid">
<span class="lt-kv-key">Used</span><span class="lt-kv-val" data-field="used">—</span>
<span class="lt-kv-key">Model</span><span class="lt-kv-val" data-field="model">—</span>
</div>
<p class="dd-muted dd-stub-note">Fed by a Claude statusLine hook through devdesk-api.</p>
</div>
</section>
</aside>
</main>
+21 -11
View File
@@ -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 <args…>` and return its output as text.
// ttyd is the only server-side endpoint, so session actions travel this way.
export async function runCommand(args, timeoutMs = 8000) {
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);
+51
View File
@@ -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.'
: '';
}
+47 -5
View File
@@ -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);
+16 -4
View File
@@ -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 <args…>`.
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');
+139 -47
View File
@@ -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;
}
}
+46 -11
View File
@@ -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 {