feat: v0.9.0 conversation search, plan usage meters, quiet phone at the desk
- devdesk attach --search (grep + jq over ~/.claude/projects, six files at a time) and --resume-convo NAME ID [fork]; devdesk-claude-run passes --fork-session once. Search dialog with Resume / Fork / Open. - statusLine snapshots rate_limits and pr; footer/menu meters with a 90 % warning; PR chip on session rows. - CLAUDE_CLIENT_PRESENCE_FILE per tmux session id; --presence on|off with a self-expiring marker; the page heartbeats it while visible. Closes #24 Closes #25 Closes #26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,7 +41,7 @@ install: all
|
|||||||
install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \
|
install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \
|
||||||
libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \
|
libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \
|
||||||
libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix libexec/devdesk-guard $(DESTDIR)$(LIBDIR)/
|
libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix libexec/devdesk-guard $(DESTDIR)$(LIBDIR)/
|
||||||
install -m 0644 libexec/devdesk-files.sh libexec/devdesk-inbox.sh $(DESTDIR)$(LIBDIR)/
|
install -m 0644 libexec/devdesk-files.sh libexec/devdesk-inbox.sh libexec/devdesk-history.sh $(DESTDIR)$(LIBDIR)/
|
||||||
install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd
|
install -m 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 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/
|
||||||
install -d $(DESTDIR)$(SHAREDIR)/assets
|
install -d $(DESTDIR)$(SHAREDIR)/assets
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
|
|||||||
- **Editing:** text files are editable; ● marks unsaved tabs; Ctrl+S (or Save) writes atomically and keeps the file's mode and owner. If the file changed on disk since you opened it (Claude edited it), saving asks: Compare (a diff of disk vs yours), Overwrite or Cancel; a change on disk while you edit shows a banner instead of replacing your text. Unsaved edits are kept as drafts in the browser, so a reload or a session switch doesn't lose them. + creates a file (folders as needed). The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser.
|
- **Editing:** text files are editable; ● marks unsaved tabs; Ctrl+S (or Save) writes atomically and keeps the file's mode and owner. If the file changed on disk since you opened it (Claude edited it), saving asks: Compare (a diff of disk vs yours), Overwrite or Cancel; a change on disk while you edit shows a banner instead of replacing your text. Unsaved edits are kept as drafts in the browser, so a reload or a session switch doesn't lose them. + creates a file (folders as needed). The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser.
|
||||||
- **Paths are links:** a file path Claude prints (`web/src/files.js`, `notes.md:12`) opens in Files when clicked, at that line if given.
|
- **Paths are links:** a file path Claude prints (`web/src/files.js`, `notes.md:12`) opens in Files when clicked, at that line if given.
|
||||||
- **Ops guard:** `devdesk-guard` (a PreToolUse hook) makes Claude ask before destructive commands (`pct destroy`, `zfs destroy`, Ceph pool/OSD removal, `rm -r /…`, `git push --force`, `reboot`…), even in auto mode. `DEVDESK_GUARD=0` turns it off; `/etc/devdesk/guard.local` adds rules (`regex<TAB>reason`). Bash commands are logged per session, and sessions are tagged with the hosts they touch (ssh targets, CT/VM ids).
|
- **Ops guard:** `devdesk-guard` (a PreToolUse hook) makes Claude ask before destructive commands (`pct destroy`, `zfs destroy`, Ceph pool/OSD removal, `rm -r /…`, `git push --force`, `reboot`…), even in auto mode. `DEVDESK_GUARD=0` turns it off; `/etc/devdesk/guard.local` adds rules (`regex<TAB>reason`). Bash commands are logged per session, and sessions are tagged with the hosts they touch (ssh targets, CT/VM ids).
|
||||||
|
- **Search past conversations** (⋯ menu, palette): full text over every Claude conversation on the host; **Resume** it in a new session or **Fork** it.
|
||||||
|
- **Plan usage:** the 5-hour and weekly windows as meters in the footer (warning at 90 %); a **PR** chip on sessions whose branch has a pull request.
|
||||||
|
- **Quiet phone at the desk:** while a tab shows a session, Remote Control holds back phone pushes for it (client-presence marker, expires ~2 min after you leave).
|
||||||
- **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use and cost to `/var/lib/devdesk/context/<session>.json`. The Sessions panel shows it as a bar (cost in the tooltip), and Claude's own footer shows a one-line summary.
|
- **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use and cost to `/var/lib/devdesk/context/<session>.json`. The Sessions panel shows it as a bar (cost in the tooltip), and Claude's own footer shows a one-line summary.
|
||||||
- **Light mode:** the terminal switches palettes with the page. DevDesk starts Claude with its `dark-ansi` theme so Claude draws with the terminal's 16 colours, and the light palette maps bright white to ink; tmux and the status line use ANSI colours too. A session picks up Claude's theme the next time Claude starts in it (`/exit`, then ⏎ resumes the same conversation).
|
- **Light mode:** the terminal switches palettes with the page. DevDesk starts Claude with its `dark-ansi` theme so Claude draws with the terminal's 16 colours, and the light palette maps bright white to ink; tmux and the status line use ANSI colours too. A session picks up Claude's theme the next time Claude starts in it (`/exit`, then ⏎ resumes the same conversation).
|
||||||
- **Phone:** a key bar replaces the shortcut hints (Esc, Tab, ⇧Tab, ^C, ↑, ↓, ⏎, Claude/Shell window), and the panels open as a drawer.
|
- **Phone:** a key bar replaces the shortcut hints (Esc, Tab, ⇧Tab, ^C, ↑, ↓, ⏎, Claude/Shell window), and the panels open as a drawer.
|
||||||
|
|||||||
+13
-2
@@ -21,6 +21,8 @@ set -euo pipefail
|
|||||||
. "$DEVDESK_LIBEXEC/devdesk-files.sh"
|
. "$DEVDESK_LIBEXEC/devdesk-files.sh"
|
||||||
# shellcheck source=libexec/devdesk-inbox.sh
|
# shellcheck source=libexec/devdesk-inbox.sh
|
||||||
. "$DEVDESK_LIBEXEC/devdesk-inbox.sh"
|
. "$DEVDESK_LIBEXEC/devdesk-inbox.sh"
|
||||||
|
# shellcheck source=libexec/devdesk-history.sh
|
||||||
|
. "$DEVDESK_LIBEXEC/devdesk-history.sh"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
sed -n '2,12s/^# \{0,1\}//p' "$0"
|
sed -n '2,12s/^# \{0,1\}//p' "$0"
|
||||||
@@ -328,9 +330,18 @@ cmd_attach() {
|
|||||||
dd_linger
|
dd_linger
|
||||||
return
|
return
|
||||||
;;
|
;;
|
||||||
--peek)
|
--peek | --search)
|
||||||
|
local action=${1#--}
|
||||||
shift
|
shift
|
||||||
cmd_peek "$@"
|
"cmd_$action" "$@"
|
||||||
|
dd_linger
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
--resume-convo | --presence)
|
||||||
|
local action=${1#--}
|
||||||
|
shift
|
||||||
|
"cmd_${action//-/_}" "$@"
|
||||||
|
ok_json
|
||||||
dd_linger
|
dd_linger
|
||||||
return
|
return
|
||||||
;;
|
;;
|
||||||
|
|||||||
Vendored
+23
@@ -1,3 +1,26 @@
|
|||||||
|
devdesk (0.9.0) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* Search past conversations (#24): every Claude conversation on this host
|
||||||
|
(DevDesk and code-server), by what you asked, what Claude said, the
|
||||||
|
commands it ran and their output (⋯ menu or the palette). Results show
|
||||||
|
the title, folder, dates and highlighted snippets; Resume opens the
|
||||||
|
conversation in a new session in its folder, Fork continues it as a new
|
||||||
|
conversation (claude --fork-session) and leaves the original as it is.
|
||||||
|
A conversation already open in a running session offers "Open" instead,
|
||||||
|
and resuming it twice is refused.
|
||||||
|
* Plan usage and PRs (#25): the 5-hour and weekly plan windows from
|
||||||
|
Claude's statusLine, as meters in the footer (⋯ menu on phones), with a
|
||||||
|
warning at 90 % of the 5-hour window; a PR chip (with review state) on
|
||||||
|
sessions whose branch has a pull request. (The statusLine has no prompt
|
||||||
|
cache expiry, so there is no cache timer.)
|
||||||
|
* Phone pushes held back while you're at the desk (#26): while a DevDesk
|
||||||
|
tab shows a session, its Claude sees a client-presence marker
|
||||||
|
(CLAUDE_CLIENT_PRESENCE_FILE) and Remote Control holds back pushes; the
|
||||||
|
marker expires about 2 minutes after the tab stops showing it. Applies to
|
||||||
|
sessions whose Claude started on this version (/exit, then ⏎).
|
||||||
|
|
||||||
|
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 21:44:07 -0400
|
||||||
|
|
||||||
devdesk (0.8.2) bookworm; urgency=medium
|
devdesk (0.8.2) bookworm; urgency=medium
|
||||||
|
|
||||||
* Click file paths in the terminal (#21): a path Claude prints
|
* Click file paths in the terminal (#21): a path Claude prints
|
||||||
|
|||||||
@@ -42,15 +42,22 @@ run_new() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
run_resume() {
|
run_resume() {
|
||||||
local id args started rc
|
local id args started rc fork=()
|
||||||
id=$(dd_registry_get "$NAME" claude_session_id)
|
id=$(dd_registry_get "$NAME" claude_session_id)
|
||||||
if [ -z "$id" ]; then
|
if [ -z "$id" ]; then
|
||||||
run_new
|
run_new
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
mapfile -d '' args < <(common_args)
|
mapfile -d '' args < <(common_args)
|
||||||
|
# "Fork" from conversation search (devdesk#24): continue as a new
|
||||||
|
# conversation, once; the SessionStart hook then records the new id.
|
||||||
|
if [ "$(dd_registry_get "$NAME" fork)" = true ]; then
|
||||||
|
fork=(--fork-session)
|
||||||
|
# shellcheck disable=SC2016 # $n is a jq variable
|
||||||
|
dd_registry_update 'del(.sessions[$n].fork)' --arg n "$NAME" 2>/dev/null || true
|
||||||
|
fi
|
||||||
started=$SECONDS
|
started=$SECONDS
|
||||||
"$CLAUDE_BIN" --resume "$id" "${args[@]}"
|
"$CLAUDE_BIN" --resume "$id" "${fork[@]}" "${args[@]}"
|
||||||
rc=$?
|
rc=$?
|
||||||
# A resume that fails straight away means the transcript is gone; start fresh.
|
# A resume that fails straight away means the transcript is gone; start fresh.
|
||||||
if [ "$rc" -ne 0 ] && [ $((SECONDS - started)) -lt 10 ]; then
|
if [ "$rc" -ne 0 ] && [ $((SECONDS - started)) -lt 10 ]; then
|
||||||
@@ -69,6 +76,14 @@ menu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
printf '\033]2;%s\033\134' "$NAME" # pane title (ST = ESC \\)
|
printf '\033]2;%s\033\134' "$NAME" # pane title (ST = ESC \\)
|
||||||
|
|
||||||
|
# While a DevDesk tab shows this session, this marker exists and Claude tells
|
||||||
|
# Remote Control a client is present, which holds back phone pushes (devdesk#26).
|
||||||
|
# Keyed by tmux's session id, which survives renames.
|
||||||
|
sid=$(dd_tmux display -p -t "${TMUX_PANE:-}" '#{session_id}' 2>/dev/null)
|
||||||
|
if [ -n "$sid" ]; then
|
||||||
|
export CLAUDE_CLIENT_PRESENCE_FILE="$DEVDESK_PRESENCE_DIR/${sid#\$}"
|
||||||
|
fi
|
||||||
mode=resume
|
mode=resume
|
||||||
while :; do
|
while :; do
|
||||||
NAME=$(dd_current_session) # follow renames
|
NAME=$(dd_current_session) # follow renames
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables
|
||||||
|
# Past conversations (devdesk#24) and client presence (devdesk#26), sourced by
|
||||||
|
# bin/devdesk and reached through `devdesk attach --search|--resume-convo|--presence`.
|
||||||
|
|
||||||
|
dd_projects_dir() {
|
||||||
|
printf '%s/projects\n' "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
|
||||||
|
}
|
||||||
|
|
||||||
|
dd_convo_file() {
|
||||||
|
[[ $1 =~ ^[0-9a-f-]{36}$ ]] || return 1
|
||||||
|
compgen -G "$(dd_projects_dir)/*/$1.jsonl" | head -n1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --search QUERY: full-text search over every Claude conversation on this host
|
||||||
|
# (DevDesk and code-server alike). Case-insensitive, plain text. Newest first;
|
||||||
|
# per conversation: title, folder, dates and up to 3 snippets (what you asked,
|
||||||
|
# Claude's answers, commands it ran, their output).
|
||||||
|
cmd_search() {
|
||||||
|
[ $# -eq 1 ] || dd_die "usage: devdesk attach --search QUERY"
|
||||||
|
local query=$1 dir files f
|
||||||
|
if [ ${#query} -lt 2 ] || [ ${#query} -gt 200 ] || [[ $query == *$'\n'* ]]; then dd_die "search for 2 to 200 characters"; fi
|
||||||
|
dir=$(dd_projects_dir)
|
||||||
|
# Files that mention it at all (grep is fast even over hundreds of MB), newest first.
|
||||||
|
mapfile -t files < <({ LC_ALL=C grep -rlFi --include='*.jsonl' -- "$query" "$dir" 2>/dev/null || true; } |
|
||||||
|
while IFS= read -r f; do printf '%s\t%s\n' "$(stat -c %Y "$f")" "$f"; done | sort -rn | head -n 30 | cut -f2-)
|
||||||
|
# Six conversations at a time (each is a grep and a jq over large lines).
|
||||||
|
local tmp i=0
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
for f in "${files[@]}"; do
|
||||||
|
dd_search_one "$query" "$f" >"$tmp/$(printf '%03d' "$i")" &
|
||||||
|
i=$((i + 1))
|
||||||
|
if [ $((i % 6)) -eq 0 ]; then wait; fi
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
{ cat "$tmp"/* 2>/dev/null || true; } | jq -cMs --arg q "$query" '{query: $q, results: [.[] | select(. != null)][:25]}'
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
# One conversation's search result (or nothing), for cmd_search.
|
||||||
|
dd_search_one() {
|
||||||
|
local query=$1 f=$2
|
||||||
|
{ LC_ALL=C grep -Fi -m 30 -- "$query" "$f" || true; } | jq -cMs --arg q "$query" --arg id "$(basename "$f" .jsonl)" \
|
||||||
|
--arg mtime "$(stat -c %Y "$f")" --slurpfile head <(head -n 80 "$f" | jq -cM 'select(.type == "custom-title" or .type == "user" or .cwd)' 2>/dev/null | jq -cMs .) '
|
||||||
|
def texts:
|
||||||
|
if .type == "user" then
|
||||||
|
(.message.content | if type == "string" then [{role: "you", text: .}]
|
||||||
|
else [.[]? | if .type == "text" then {role: "you", text: .text}
|
||||||
|
elif .type == "tool_result" then {role: "output", text: (.content | if type == "string" then . else ([.[]? | .text? // empty] | join("\n")) end)}
|
||||||
|
else empty end] end)
|
||||||
|
elif .type == "assistant" then
|
||||||
|
[.message.content[]? | if .type == "text" then {role: "claude", text: .text}
|
||||||
|
elif .type == "tool_use" then {role: "command", text: (.input.command // .input.file_path // .input.url // .input.pattern // empty)}
|
||||||
|
else empty end]
|
||||||
|
else [] end;
|
||||||
|
($q | ascii_downcase) as $lq
|
||||||
|
| [.[] | select(.isSidechain != true) | . as $e | texts[] | select(.text | type == "string")
|
||||||
|
| (.text | ascii_downcase | index($lq)) as $i | select($i != null)
|
||||||
|
| {role, at: $e.timestamp,
|
||||||
|
snippet: ((if $i > 90 then "…" else "" end) + (.text[([$i - 90, 0] | max):($i + ($q | length) + 140)] | gsub("\\s+"; " "))
|
||||||
|
+ (if (.text | length) > $i + ($q | length) + 140 then "…" else "" end))}] as $m
|
||||||
|
| select($m | length > 0)
|
||||||
|
| ($head[0] // []) as $h
|
||||||
|
| {id: $id,
|
||||||
|
title: (([$h[] | select(.type == "custom-title") | .customTitle] | last)
|
||||||
|
// ([$h[] | select(.type == "user" and (.message.content | type == "string") and (.message.content | startswith("<") | not)) | .message.content] | first | .[0:120])
|
||||||
|
// "untitled"),
|
||||||
|
prompt: ([$h[] | select(.type == "user" and (.message.content | type == "string") and (.message.content | startswith("<") | not)) | .message.content] | first | .[0:160]),
|
||||||
|
cwd: ([$h[] | .cwd // empty] | first),
|
||||||
|
started: ([$h[] | .timestamp // empty] | first),
|
||||||
|
updated: ($mtime | tonumber),
|
||||||
|
hits: ($m | length), matches: ($m[:3])}' 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# --resume-convo NAME ID [fork]: open a past conversation in a new session NAME,
|
||||||
|
# in the folder it ran in. "fork" continues it as a new conversation and leaves
|
||||||
|
# the original untouched (claude --fork-session).
|
||||||
|
cmd_resume_convo() {
|
||||||
|
if [ $# -lt 2 ] || [ $# -gt 3 ]; then dd_die "usage: devdesk attach --resume-convo NAME ID [fork]"; fi
|
||||||
|
local name=$1 id=$2 fork=${3:-} file dir
|
||||||
|
dd_valid_name "$name" || dd_die "invalid session name: $name"
|
||||||
|
if [ -n "$fork" ] && [ "$fork" != fork ]; then dd_die "the third argument can only be: fork"; fi
|
||||||
|
file=$(dd_convo_file "$id") || dd_die "no conversation $id"
|
||||||
|
[ -n "$file" ] || dd_die "no conversation $id"
|
||||||
|
if dd_server_up && dd_tmux has-session -t "=$name" 2>/dev/null; then dd_die "a session called $name is already running"; fi
|
||||||
|
# Two Claudes on one transcript would interleave it: resuming one that is
|
||||||
|
# open elsewhere is refused (forking is fine).
|
||||||
|
if [ -z "$fork" ]; then
|
||||||
|
local other
|
||||||
|
for other in $(jq -rM --arg id "$id" '.sessions | to_entries[] | select(.value.claude_session_id == $id) | .key' "$DEVDESK_REGISTRY" 2>/dev/null); do
|
||||||
|
if [ "$other" != "$name" ] && dd_tmux has-session -t "=$other" 2>/dev/null; then
|
||||||
|
dd_die "that conversation is open in session $other: open it there, or fork it"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
dir=$(jq -rM 'select(.cwd) | .cwd' "$file" 2>/dev/null | head -n1)
|
||||||
|
[ -n "$dir" ] && [ -d "$dir" ] || dir=$(dd_resolve_dir "$name")
|
||||||
|
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {dir: $d, claude_session_id: $id, claude_source: "devdesk-history"}
|
||||||
|
+ (if $fork == "fork" then {fork: true} else {} end))' \
|
||||||
|
--arg n "$name" --arg d "$dir" --arg id "$id" --arg fork "$fork"
|
||||||
|
dd_ensure_server
|
||||||
|
create_detached "$name" "$dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --presence SESSION on|off (devdesk#26): while a DevDesk tab shows a session,
|
||||||
|
# its Claude sees a client-presence marker (CLAUDE_CLIENT_PRESENCE_FILE, set by
|
||||||
|
# devdesk-claude-run per tmux session) and Remote Control holds back phone
|
||||||
|
# pushes. The browser refreshes it every ~45 s; it disappears ~2 minutes after
|
||||||
|
# the last refresh, so pushes resume once you walk away.
|
||||||
|
cmd_presence() {
|
||||||
|
[ $# -eq 2 ] || dd_die "usage: devdesk attach --presence SESSION on|off"
|
||||||
|
local session=$1 state=$2 sid marker
|
||||||
|
dd_valid_name "$session" || dd_die "invalid session name: $session"
|
||||||
|
dd_server_up || return 0
|
||||||
|
sid=$(dd_tmux display -p -t "=$session:" '#{session_id}' 2>/dev/null) || return 0
|
||||||
|
marker="$DEVDESK_PRESENCE_DIR/${sid#\$}"
|
||||||
|
if [ "$state" = off ]; then
|
||||||
|
rm -f "$marker"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
[ "$state" = on ] || dd_die "presence is on or off"
|
||||||
|
mkdir -p "$DEVDESK_PRESENCE_DIR" && touch "$marker"
|
||||||
|
# Expire it unless refreshed meanwhile.
|
||||||
|
# (fd 3 closed too: a reaper must not hold a caller's extra descriptors open)
|
||||||
|
setsid -f bash -c 'sleep 120; [ -n "$(find "$1" -newermt "-110 seconds" 2>/dev/null)" ] || rm -f "$1"' _ "$marker" \
|
||||||
|
</dev/null >/dev/null 2>&1 3>&- || true
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ dd_load_env
|
|||||||
: "${DEVDESK_PUBLIC_URL:=}"
|
: "${DEVDESK_PUBLIC_URL:=}"
|
||||||
: "${DEVDESK_MATRIX_WEBHOOK_FILE:=/etc/devdesk/matrix-webhook}"
|
: "${DEVDESK_MATRIX_WEBHOOK_FILE:=/etc/devdesk/matrix-webhook}"
|
||||||
: "${DEVDESK_MATRIX_DELAY:=30}"
|
: "${DEVDESK_MATRIX_DELAY:=30}"
|
||||||
|
: "${DEVDESK_PRESENCE_DIR:=/run/devdesk/presence}"
|
||||||
|
|
||||||
DEVDESK_REGISTRY="$DEVDESK_STATE_DIR/registry.json"
|
DEVDESK_REGISTRY="$DEVDESK_STATE_DIR/registry.json"
|
||||||
DEVDESK_CONTEXT_DIR="$DEVDESK_STATE_DIR/context"
|
DEVDESK_CONTEXT_DIR="$DEVDESK_STATE_DIR/context"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Claude Code statusLine command (wired in via claude-settings.json). Claude pipes
|
# 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
|
# its status JSON in on every update; we keep a snapshot per DevDesk session for
|
||||||
# the web UI's Claude Context panel and print a one-line status for Claude's footer.
|
# the web UI (context, plan usage, PR) and print a one-line status for Claude's footer.
|
||||||
# Runs often while Claude streams, so it stays cheap and never fails loudly.
|
# Runs often while Claude streams, so it stays cheap and never fails loudly.
|
||||||
|
|
||||||
input=$(cat)
|
input=$(cat)
|
||||||
@@ -29,7 +29,12 @@ if [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then
|
|||||||
+ (.cache_read_input_tokens // 0)) else null end),
|
+ (.cache_read_input_tokens // 0)) else null end),
|
||||||
cost_usd: .cost.total_cost_usd,
|
cost_usd: .cost.total_cost_usd,
|
||||||
lines_added: .cost.total_lines_added,
|
lines_added: .cost.total_lines_added,
|
||||||
lines_removed: .cost.total_lines_removed
|
lines_removed: .cost.total_lines_removed,
|
||||||
|
# devdesk#25: plan usage (account-wide) and the pull request of the branch
|
||||||
|
rate_limits: (.rate_limits // null | if . then {
|
||||||
|
five_hour: (.five_hour | if . then {pct: .used_percentage, resets_at} else null end),
|
||||||
|
seven_day: (.seven_day | if . then {pct: .used_percentage, resets_at} else null end)} else null end),
|
||||||
|
pr: (.pr // null | if . then {number, url, review_state, kind} else null end)
|
||||||
}' <<<"$input" >"$tmp" 2>/dev/null; then
|
}' <<<"$input" >"$tmp" 2>/dev/null; then
|
||||||
mv -f "$tmp" "$dir/$session.json"
|
mv -f "$tmp" "$dir/$session.json"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -785,3 +785,64 @@ JSONL
|
|||||||
devdesk end beta
|
devdesk end beta
|
||||||
[ ! -e "$DEVDESK_STATE_DIR/commands/beta.jsonl" ] && [ ! -e "$DEVDESK_STATE_DIR/tags/beta.json" ]
|
[ ! -e "$DEVDESK_STATE_DIR/commands/beta.jsonl" ] && [ ! -e "$DEVDESK_STATE_DIR/tags/beta.json" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# A fake Claude history: one conversation in $1 with id $2.
|
||||||
|
fake_convo() {
|
||||||
|
mkdir -p "$CLAUDE_CONFIG_DIR/projects/-p"
|
||||||
|
jq -cn --arg cwd "$1" --arg id "$2" '
|
||||||
|
{type: "user", cwd: $cwd, sessionId: $id, timestamp: "2026-08-01T10:00:00Z", message: {role: "user", content: "Fix the NFS mount on pbs"}},
|
||||||
|
{type: "assistant", timestamp: "2026-08-01T10:00:05Z", message: {role: "assistant", content: [{type: "tool_use", name: "Bash", input: {command: "mount -t nfs 10.10.10.9:/backup /mnt/pbs"}}]}},
|
||||||
|
{type: "assistant", timestamp: "2026-08-01T10:00:09Z", message: {role: "assistant", content: [{type: "text", text: "The NFS export needed no_root_squash."}]}}' \
|
||||||
|
>"$CLAUDE_CONFIG_DIR/projects/-p/$2.jsonl"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "search finds past conversations by what was said and run (#24)" {
|
||||||
|
export CLAUDE_CONFIG_DIR="$TMP/claude"
|
||||||
|
fake_convo "$DEVDESK_PROJECTS_ROOT/alpha" 11111111-1111-1111-1111-111111111111
|
||||||
|
run devdesk attach --search "no_root_squash"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(jq -r '.results[0] | [.id, .title, .cwd, .matches[0].role] | @tsv' <<<"$output")" = \
|
||||||
|
"$(printf '11111111-1111-1111-1111-111111111111\tFix the NFS mount on pbs\t%s\tclaude' "$DEVDESK_PROJECTS_ROOT/alpha")" ]
|
||||||
|
run devdesk attach --search "MOUNT -t NFS"
|
||||||
|
[ "$(jq -r '.results[0].matches[0].role' <<<"$output")" = command ]
|
||||||
|
run devdesk attach --search "nothing like this anywhere"
|
||||||
|
[ "$(jq -r '.results | length' <<<"$output")" = 0 ]
|
||||||
|
run devdesk attach --search "x"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "resume-convo opens a past conversation in its folder; fork is passed once (#24)" {
|
||||||
|
export CLAUDE_CONFIG_DIR="$TMP/claude"
|
||||||
|
id=22222222-2222-2222-2222-222222222222
|
||||||
|
fake_convo "$DEVDESK_PROJECTS_ROOT/alpha" "$id"
|
||||||
|
start_server
|
||||||
|
run devdesk attach --resume-convo nfs-fix "$id" fork
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
claude_args nfs-fix >/dev/null
|
||||||
|
[[ "$(claude_args nfs-fix)" == "--resume $id --fork-session --settings"* ]]
|
||||||
|
[ "$(tmux -S "$DEVDESK_SOCKET" display -p -t =nfs-fix:claude '#{pane_current_path}')" = "$DEVDESK_PROJECTS_ROOT/alpha" ]
|
||||||
|
[ "$(jq -r '.sessions["nfs-fix"].fork // "cleared"' "$DEVDESK_STATE_DIR/registry.json")" = cleared ]
|
||||||
|
# Resuming (not forking) the same conversation while it's open elsewhere is refused.
|
||||||
|
jq --arg id "$id" '.sessions["nfs-fix"].claude_session_id = $id' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
|
||||||
|
run devdesk attach --resume-convo nfs-again "$id"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ $output == *"open in session nfs-fix"* ]]
|
||||||
|
run devdesk attach --resume-convo nfs-fix "$id" fork
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ $output == *"already running"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "presence marks a session while a tab shows it (#26)" {
|
||||||
|
export DEVDESK_PRESENCE_DIR="$TMP/presence"
|
||||||
|
start_server
|
||||||
|
devdesk new alpha
|
||||||
|
claude_args alpha >/dev/null
|
||||||
|
sid=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha: '#{session_id}')
|
||||||
|
run devdesk attach --presence alpha on
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -e "$TMP/presence/${sid#\$}" ]
|
||||||
|
# The Claude window was told where the marker lives.
|
||||||
|
[ "$(tmux -S "$DEVDESK_SOCKET" show-environment -t =alpha CLAUDE_CLIENT_PRESENCE_FILE 2>/dev/null || true)" = "" ] # set per process, not per session
|
||||||
|
run devdesk attach --presence alpha off
|
||||||
|
[ ! -e "$TMP/presence/${sid#\$}" ]
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="button" class="dd-menu-item" id="theme-menu" role="menuitem">Light mode</button>
|
<button type="button" class="dd-menu-item" id="theme-menu" role="menuitem">Light mode</button>
|
||||||
<button type="button" class="dd-menu-item" id="notify-toggle" role="menuitem">Desktop notifications</button>
|
<button type="button" class="dd-menu-item" id="notify-toggle" role="menuitem">Desktop notifications</button>
|
||||||
|
<div class="dd-menu-row dd-usage-row" id="usage-row" hidden><span>Plan usage</span><span class="dd-usage" id="usage-menu"></span></div>
|
||||||
|
<button type="button" class="dd-menu-item" id="history-menu" role="menuitem">Search past conversations</button>
|
||||||
<button type="button" class="dd-menu-item" id="palette-menu" role="menuitem">Command palette</button>
|
<button type="button" class="dd-menu-item" id="palette-menu" role="menuitem">Command palette</button>
|
||||||
<button type="button" class="dd-menu-item" id="help-open" role="menuitem">Keyboard shortcuts</button>
|
<button type="button" class="dd-menu-item" id="help-open" role="menuitem">Keyboard shortcuts</button>
|
||||||
<button type="button" class="dd-menu-item" id="fullscreen-toggle" role="menuitem" title="In Chromium, fullscreen also captures Ctrl+W/Ctrl+T for the terminal">Fullscreen</button>
|
<button type="button" class="dd-menu-item" id="fullscreen-toggle" role="menuitem" title="In Chromium, fullscreen also captures Ctrl+W/Ctrl+T for the terminal">Fullscreen</button>
|
||||||
@@ -179,6 +181,7 @@
|
|||||||
<span class="lt-footer-hint"><span class="lt-footer-key">^⇧P</span>commands</span>
|
<span class="lt-footer-hint"><span class="lt-footer-key">^⇧P</span>commands</span>
|
||||||
<button type="button" class="dd-footer-help" id="footer-help">all keys</button>
|
<button type="button" class="dd-footer-help" id="footer-help">all keys</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dd-usage dd-footer-usage" id="usage-footer" hidden></div>
|
||||||
<div class="dd-keybar" id="keybar" role="toolbar" aria-label="Terminal keys" hidden>
|
<div class="dd-keybar" id="keybar" role="toolbar" aria-label="Terminal keys" hidden>
|
||||||
<button type="button" data-keys="esc">Esc</button>
|
<button type="button" data-keys="esc">Esc</button>
|
||||||
<button type="button" data-keys="tab">Tab</button>
|
<button type="button" data-keys="tab">Tab</button>
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Search past conversations (devdesk#24): every Claude conversation on this host
|
||||||
|
// (DevDesk and code-server), by what you asked, what Claude said, the commands
|
||||||
|
// it ran and their output. Resume one in a new session, or fork it.
|
||||||
|
|
||||||
|
import { runCommand, sessionAction } from './connection.js';
|
||||||
|
import { parseListOutput, slugify } from './protocol.js';
|
||||||
|
import { el } from './dom.js';
|
||||||
|
import { ago } from './sessions.js';
|
||||||
|
import { openDialog, toast } from './ui.js';
|
||||||
|
|
||||||
|
const ROLE = { you: 'You', claude: 'Claude', command: 'Ran', output: 'Output' };
|
||||||
|
|
||||||
|
// Text with every case-insensitive occurrence of `query` in <mark>.
|
||||||
|
function highlight(text, query) {
|
||||||
|
const nodes = [];
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
let at = 0;
|
||||||
|
for (let i = lower.indexOf(q); i >= 0 && q; i = lower.indexOf(q, at)) {
|
||||||
|
if (i > at) nodes.push(text.slice(at, i));
|
||||||
|
nodes.push(el('mark', {}, text.slice(i, i + q.length)));
|
||||||
|
at = i + q.length;
|
||||||
|
}
|
||||||
|
nodes.push(text.slice(at));
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const day = (iso) => (iso ? new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '');
|
||||||
|
|
||||||
|
// hooks: sessions() -> session list, open(name), refresh()
|
||||||
|
export function openHistory(hooks) {
|
||||||
|
const input = el('input', { class: 'lt-input dd-history-input', type: 'search', placeholder: 'What did you work on? e.g. "NFS mount", "pct exec 167", "no_root_squash"', spellcheck: 'false', autocomplete: 'off' });
|
||||||
|
const status = el('div', { class: 'dd-dialog-hint' }, 'Searches every Claude conversation on this host: your prompts, Claude\'s answers, the commands it ran and their output.');
|
||||||
|
const results = el('div', { class: 'dd-history-results' });
|
||||||
|
let seq = 0;
|
||||||
|
let timer = 0;
|
||||||
|
let close = null;
|
||||||
|
|
||||||
|
const liveSessionFor = (id) => hooks.sessions().find((s) => s.running && s.claude_session_id === id)?.name;
|
||||||
|
|
||||||
|
// Resume or fork: confirm the new session's name inline, then open it.
|
||||||
|
const start = (result, fork, holder) => {
|
||||||
|
const name = el('input', { class: 'lt-input', value: slugify(`${fork ? 'fork-' : ''}${result.title}`).slice(0, 40) || 'resumed', spellcheck: 'false' });
|
||||||
|
const go = async () => {
|
||||||
|
const value = slugify(name.value);
|
||||||
|
if (!value) return;
|
||||||
|
try {
|
||||||
|
await sessionAction('resume-convo', value, result.id, ...(fork ? ['fork'] : []));
|
||||||
|
close?.();
|
||||||
|
toast.success(`${fork ? 'Forked' : 'Resumed'} “${result.title.slice(0, 40)}” in ${value}`);
|
||||||
|
hooks.refresh();
|
||||||
|
hooks.open(value);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
name.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
go();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
holder.replaceChildren(el('span', { class: 'dd-dialog-hint' }, `New session for ${fork ? 'a fork of ' : ''}this conversation:`), name,
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm', onclick: go }, fork ? 'Fork' : 'Resume'));
|
||||||
|
name.focus();
|
||||||
|
name.select();
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = (data) => {
|
||||||
|
if (!data.results.length) {
|
||||||
|
results.replaceChildren(el('div', { class: 'dd-files-msg' }, `Nothing found for “${data.query}”.`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
results.replaceChildren(...data.results.map((r) => {
|
||||||
|
const live = liveSessionFor(r.id);
|
||||||
|
const actions = el('div', { class: 'dd-history-actions' });
|
||||||
|
const fill = () => actions.replaceChildren(
|
||||||
|
live
|
||||||
|
? el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm', onclick: () => { close?.(); hooks.open(live); } }, `Open ${live}`)
|
||||||
|
: el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm', onclick: () => start(r, false, actions) }, 'Resume…'),
|
||||||
|
el('button', { type: 'button', class: 'lt-btn lt-btn-sm', onclick: () => start(r, true, actions), title: 'Continue as a new conversation; the original stays as it is' }, 'Fork…'),
|
||||||
|
el('button', {
|
||||||
|
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm',
|
||||||
|
onclick: () => navigator.clipboard?.writeText(`claude --resume ${r.id}`).then(() => toast.success('Copied the resume command')),
|
||||||
|
}, 'Copy command'));
|
||||||
|
fill();
|
||||||
|
return el('div', { class: 'dd-history-item' },
|
||||||
|
el('div', { class: 'dd-history-title' }, r.title),
|
||||||
|
el('div', { class: 'dd-history-meta' },
|
||||||
|
[r.cwd, day(r.started), `updated ${ago(r.updated)}${ago(r.updated) === 'now' ? '' : ' ago'}`, `${r.hits} match${r.hits === 1 ? '' : 'es'}`, live ? `open in ${live}` : '']
|
||||||
|
.filter(Boolean).join(' · ')),
|
||||||
|
...r.matches.map((m) => el('div', { class: `dd-history-snippet is-${m.role}` },
|
||||||
|
el('span', { class: 'dd-history-role' }, ROLE[m.role] ?? m.role), el('span', {}, ...highlight(m.snippet, data.query)))),
|
||||||
|
actions);
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const search = async () => {
|
||||||
|
const query = input.value.trim();
|
||||||
|
if (query.length < 2) return;
|
||||||
|
const mine = ++seq;
|
||||||
|
status.textContent = `Searching for “${query}”…`;
|
||||||
|
try {
|
||||||
|
const data = parseListOutput(await runCommand(['--search', query], 30000));
|
||||||
|
if (mine !== seq) return;
|
||||||
|
const n = data.results.length;
|
||||||
|
status.textContent = `${n}${n === 25 ? '+' : ''} conversation${n === 1 ? ' mentions' : 's mention'} “${query}”, newest first.`;
|
||||||
|
render(data);
|
||||||
|
} catch (error) {
|
||||||
|
if (mine === seq) status.textContent = error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(search, 500);
|
||||||
|
});
|
||||||
|
input.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
clearTimeout(timer);
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
openDialog({
|
||||||
|
title: 'Search past conversations',
|
||||||
|
className: 'is-wide',
|
||||||
|
body: el('div', { class: 'dd-history' }, input, status, results),
|
||||||
|
actions: [{ label: 'Close', value: null }],
|
||||||
|
onOpen: (overlay) => {
|
||||||
|
close = () => overlay.querySelector('.lt-modal-close')?.click();
|
||||||
|
return input;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
+67
-1
@@ -2,7 +2,7 @@
|
|||||||
import { THEMES, createTerminal, loadFonts } from './term.js';
|
import { THEMES, createTerminal, loadFonts } from './term.js';
|
||||||
import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
|
import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
|
||||||
import {
|
import {
|
||||||
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
|
TtydConnection, fetchAlerts, fetchRepo, fetchToken, sessionAction, uploadFile, AuthExpiredError, UnauthorizedError,
|
||||||
} from './connection.js';
|
} from './connection.js';
|
||||||
import { parseTitle, pasteSafe, sessionFromSearch, slugify } from './protocol.js';
|
import { parseTitle, pasteSafe, sessionFromSearch, slugify } from './protocol.js';
|
||||||
import { SessionsPanel, recentNames, rememberSession, sessionStatus } from './sessions.js';
|
import { SessionsPanel, recentNames, rememberSession, sessionStatus } from './sessions.js';
|
||||||
@@ -14,6 +14,7 @@ import { CommandPalette, dialogOpen, openDialog, toast } from './ui.js';
|
|||||||
import { Inbox } from './inbox.js';
|
import { Inbox } from './inbox.js';
|
||||||
import { FilesPane } from './files.js';
|
import { FilesPane } from './files.js';
|
||||||
import { registerPathLinks } from './links.js';
|
import { registerPathLinks } from './links.js';
|
||||||
|
import { openHistory } from './history.js';
|
||||||
import { el } from './dom.js';
|
import { el } from './dom.js';
|
||||||
import * as notify from './notify.js';
|
import * as notify from './notify.js';
|
||||||
|
|
||||||
@@ -180,6 +181,7 @@ function renderPanelsBadge() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onSessionData({ fetched = false } = {}) {
|
function onSessionData({ fetched = false } = {}) {
|
||||||
|
renderUsage();
|
||||||
renderSessionChip();
|
renderSessionChip();
|
||||||
renderAttention(fetched);
|
renderAttention(fetched);
|
||||||
if (!ui.switcher.hidden) renderSwitcher();
|
if (!ui.switcher.hidden) renderSwitcher();
|
||||||
@@ -359,6 +361,7 @@ function connect(session, { existing = false, reconnect = false } = {}) {
|
|||||||
setWsState('connected', 'live');
|
setWsState('connected', 'live');
|
||||||
if (session) rememberSession(session);
|
if (session) rememberSession(session);
|
||||||
if (session) files?.knownFiles(); // so paths in the output are links from the first hover
|
if (session) files?.knownFiles(); // so paths in the output are links from the first hover
|
||||||
|
presenceTick();
|
||||||
refreshSoon(800); // opening ?arg=<new name> just created this session
|
refreshSoon(800); // opening ?arg=<new name> just created this session
|
||||||
refreshRepo();
|
refreshRepo();
|
||||||
fit.fit();
|
fit.fit();
|
||||||
@@ -624,6 +627,65 @@ function askInSession(name, text) {
|
|||||||
setTimeout(tick, 1500);
|
setTimeout(tick, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- history & usage
|
||||||
|
|
||||||
|
function showHistory() {
|
||||||
|
closeMenus();
|
||||||
|
openHistory({
|
||||||
|
sessions: () => sessionsPanel?.data?.sessions ?? [],
|
||||||
|
open: (name) => connect(name, { existing: true }),
|
||||||
|
refresh: () => sessionsPanel.reload(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan usage (devdesk#25): the 5-hour and weekly windows, from the newest
|
||||||
|
// statusLine snapshot of any session (they're account-wide). A toast when the
|
||||||
|
// 5-hour window passes 90 %, once per window.
|
||||||
|
let usageWarned = 0;
|
||||||
|
function renderUsage() {
|
||||||
|
const snapshots = (sessionsPanel?.data?.sessions ?? []).map((s) => s.context).filter((c) => c?.rate_limits);
|
||||||
|
const latest = snapshots.sort((a, b) => (b.updated || 0) - (a.updated || 0))[0]?.rate_limits;
|
||||||
|
const footer = $('usage-footer');
|
||||||
|
const menuRow = $('usage-row');
|
||||||
|
if (!latest || (!latest.five_hour && !latest.seven_day)) {
|
||||||
|
footer.hidden = true;
|
||||||
|
menuRow.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const at = (epoch) => (epoch ? new Date(epoch * 1000).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' }) : '?');
|
||||||
|
const meter = (label, w) => {
|
||||||
|
if (!w || w.pct === null || w.pct === undefined) return null;
|
||||||
|
const pct = Math.round(w.pct);
|
||||||
|
return el('span', { class: `dd-meter${pct >= 95 ? ' is-full' : pct >= 80 ? ' is-high' : ''}`, title: `${label}: ${pct}% used, resets ${at(w.resets_at)}` },
|
||||||
|
el('span', { class: 'dd-meter-label' }, label),
|
||||||
|
el('span', { class: 'dd-meter-bar' }, el('span', { class: 'dd-meter-fill', style: `width:${Math.min(100, pct)}%` })),
|
||||||
|
`${pct}%`);
|
||||||
|
};
|
||||||
|
const parts = () => [meter('5h', latest.five_hour), meter('week', latest.seven_day)].filter(Boolean);
|
||||||
|
footer.replaceChildren(...parts());
|
||||||
|
$('usage-menu').replaceChildren(...parts());
|
||||||
|
footer.hidden = false;
|
||||||
|
menuRow.hidden = false;
|
||||||
|
const five = latest.five_hour;
|
||||||
|
if (five?.pct >= 90 && five.resets_at !== usageWarned) {
|
||||||
|
usageWarned = five.resets_at;
|
||||||
|
toast.warning(`Plan usage: ${Math.round(five.pct)}% of the 5-hour window (resets ${at(five.resets_at)})`, 10000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote Control presence (devdesk#26): while this tab shows a session and is
|
||||||
|
// visible, its Claude knows a client is present and holds back phone pushes.
|
||||||
|
let presenceFor = null;
|
||||||
|
function presence(on) {
|
||||||
|
const session = state.session;
|
||||||
|
if (presenceFor && presenceFor !== session) sessionAction('presence', presenceFor, 'off').catch(() => {});
|
||||||
|
presenceFor = on ? session : null;
|
||||||
|
if (session) sessionAction('presence', session, on ? 'on' : 'off').catch(() => {});
|
||||||
|
}
|
||||||
|
function presenceTick() {
|
||||||
|
presence(Boolean(state.session && state.conn?.isOpen && document.visibilityState === 'visible'));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- palette & help
|
// ---------------------------------------------------------------- palette & help
|
||||||
|
|
||||||
const sendKeys = (keys) => state.conn?.sendInput(keys);
|
const sendKeys = (keys) => state.conn?.sendInput(keys);
|
||||||
@@ -670,6 +732,7 @@ function buildCommands() {
|
|||||||
add({ id: 'copy-link', group: 'Sessions', icon: '⧉', label: 'Copy link to this session', keywords: 'url share', run: copyLink });
|
add({ id: 'copy-link', group: 'Sessions', icon: '⧉', label: 'Copy link to this session', keywords: 'url share', run: copyLink });
|
||||||
}
|
}
|
||||||
add({ id: 'picker', group: 'Sessions', icon: '≡', label: 'Open the session picker', keywords: 'fzf list', run: () => connect(null) });
|
add({ id: 'picker', group: 'Sessions', icon: '≡', label: 'Open the session picker', keywords: 'fzf list', run: () => connect(null) });
|
||||||
|
add({ id: 'history', group: 'Sessions', icon: '⌕', label: 'Search past conversations…', keywords: 'history transcript resume fork find old', run: showHistory });
|
||||||
|
|
||||||
if (state.session && state.conn?.isOpen) {
|
if (state.session && state.conn?.isOpen) {
|
||||||
add({ id: 'win-claude', group: 'Terminal', icon: '0', label: 'Claude window', kbd: '^b 0', run: () => sendKeys('\x020') });
|
add({ id: 'win-claude', group: 'Terminal', icon: '0', label: 'Claude window', kbd: '^b 0', run: () => sendKeys('\x020') });
|
||||||
@@ -887,6 +950,9 @@ function wireChrome() {
|
|||||||
showHelp();
|
showHelp();
|
||||||
});
|
});
|
||||||
$('footer-help').addEventListener('click', showHelp);
|
$('footer-help').addEventListener('click', showHelp);
|
||||||
|
$('history-menu').addEventListener('click', showHistory);
|
||||||
|
setInterval(presenceTick, 45 * 1000);
|
||||||
|
document.addEventListener('visibilitychange', presenceTick);
|
||||||
$('palette-menu').addEventListener('click', () => {
|
$('palette-menu').addEventListener('click', () => {
|
||||||
closeMenus();
|
closeMenus();
|
||||||
palette.open();
|
palette.open();
|
||||||
|
|||||||
+7
-1
@@ -286,7 +286,13 @@ export class SessionsPanel {
|
|||||||
el('button', { class: 'dd-session-open', type: 'button', title, onclick: () => this.hooks.onOpen(s.name) },
|
el('button', { class: 'dd-session-open', type: 'button', title, onclick: () => this.hooks.onOpen(s.name) },
|
||||||
el('span', { class: `lt-dot ${status.dot}` }),
|
el('span', { class: `lt-dot ${status.dot}` }),
|
||||||
el('span', { class: 'dd-session-name' }, s.name,
|
el('span', { class: 'dd-session-name' }, s.name,
|
||||||
s.tags?.length ? el('span', { class: 'dd-tags', title: 'Hosts this session worked on' }, ...s.tags.slice(0, 3).map((t) => el('span', { class: 'dd-tag' }, t))) : null),
|
s.tags?.length ? el('span', { class: 'dd-tags', title: 'Hosts this session worked on' }, ...s.tags.slice(0, 3).map((t) => el('span', { class: 'dd-tag' }, t))) : null,
|
||||||
|
// The pull request of this branch (devdesk#25)
|
||||||
|
s.context?.pr?.number ? el('a', {
|
||||||
|
class: `dd-pr is-${s.context.pr.review_state || 'open'}`, href: s.context.pr.url, target: '_blank', rel: 'noopener',
|
||||||
|
title: `PR #${s.context.pr.number}${s.context.pr.review_state ? ` · ${s.context.pr.review_state.replace(/_/g, ' ')}` : ''}`,
|
||||||
|
onclick: (event) => event.stopPropagation(),
|
||||||
|
}, `PR #${s.context.pr.number}${{ approved: ' ✓', changes_requested: ' ✗' }[s.context.pr.review_state] ?? ''}`) : null),
|
||||||
el('span', { class: 'dd-session-meta' }, meta),
|
el('span', { class: 'dd-session-meta' }, meta),
|
||||||
pct != null ? el('span', { class: `dd-ctx${pct >= 90 ? ' is-full' : pct >= 70 ? ' is-high' : ''}`, 'aria-hidden': 'true' },
|
pct != null ? el('span', { class: `dd-ctx${pct >= 90 ? ' is-full' : pct >= 70 ? ' is-high' : ''}`, 'aria-hidden': 'true' },
|
||||||
el('span', { class: 'dd-ctx-fill', style: `width:${Math.min(100, pct)}%` })) : null),
|
el('span', { class: 'dd-ctx-fill', style: `width:${Math.min(100, pct)}%` })) : null),
|
||||||
|
|||||||
@@ -986,3 +986,36 @@ html[data-theme="light"] .dd-files.is-narrow .dd-explorer { box-shadow: 8px 0 24
|
|||||||
.dd-peek-command { display: flex; gap: 8px; min-width: 0; font-size: 0.68rem; }
|
.dd-peek-command { display: flex; gap: 8px; min-width: 0; font-size: 0.68rem; }
|
||||||
.dd-peek-command code { overflow: hidden; color: var(--text-secondary); font-family: var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
|
.dd-peek-command code { overflow: hidden; color: var(--text-secondary); font-family: var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.dd-peek-when { flex: 0 0 2.6em; color: var(--text-muted); text-align: right; }
|
.dd-peek-when { flex: 0 0 2.6em; color: var(--text-muted); text-align: right; }
|
||||||
|
|
||||||
|
/* ---- history, usage, PR (devdesk#24-#26) ------------------------------------------- */
|
||||||
|
.dd-history { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; }
|
||||||
|
.dd-history-input { width: 100%; font-size: 0.82rem; }
|
||||||
|
.dd-history-results { display: flex; flex-direction: column; gap: 8px; max-height: 60vh; overflow: auto; }
|
||||||
|
.dd-history-item { padding: 8px 10px; border: 1px solid var(--border-dim); border-left: 2px solid var(--accent-cyan); background: var(--bg-terminal); }
|
||||||
|
.dd-history-title { color: var(--text-primary); font-size: 0.8rem; font-weight: 700; overflow-wrap: anywhere; }
|
||||||
|
.dd-history-meta { margin: 2px 0 6px; color: var(--text-muted); font-size: 0.66rem; overflow-wrap: anywhere; }
|
||||||
|
.dd-history-snippet { display: flex; gap: 8px; margin-top: 3px; color: var(--text-secondary); font-size: 0.72rem; line-height: 1.45; overflow-wrap: anywhere; }
|
||||||
|
.dd-history-snippet mark { background: var(--accent-amber-dim); color: var(--accent-amber); padding: 0 1px; }
|
||||||
|
.dd-history-role { flex: 0 0 4.2em; color: var(--accent-orange); font-size: 0.6rem; letter-spacing: 0.08em; text-transform: uppercase; padding-top: 2px; }
|
||||||
|
.dd-history-snippet.is-command span:last-child,
|
||||||
|
.dd-history-snippet.is-output span:last-child { font-family: var(--font-mono); }
|
||||||
|
.dd-history-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; }
|
||||||
|
.dd-history-actions .lt-input { width: 220px; font-size: 0.74rem; }
|
||||||
|
|
||||||
|
.dd-footer-usage { margin-left: auto; padding-left: 12px; }
|
||||||
|
.dd-footer-usage[hidden],
|
||||||
|
.dd-usage-row[hidden] { display: none; }
|
||||||
|
.dd-usage { display: inline-flex; align-items: center; gap: 10px; }
|
||||||
|
.dd-meter { display: inline-flex; align-items: center; gap: 5px; color: var(--text-secondary); font-size: 0.66rem; white-space: nowrap; }
|
||||||
|
.dd-meter-label { color: var(--text-muted); }
|
||||||
|
.dd-meter-bar { width: 42px; height: 4px; background: var(--bg-tertiary); }
|
||||||
|
.dd-meter-fill { display: block; height: 100%; background: var(--accent-cyan); }
|
||||||
|
.dd-meter.is-high .dd-meter-fill { background: var(--accent-amber); }
|
||||||
|
.dd-meter.is-high { color: var(--accent-amber); }
|
||||||
|
.dd-meter.is-full .dd-meter-fill { background: var(--accent-red); }
|
||||||
|
.dd-meter.is-full { color: var(--accent-red); }
|
||||||
|
body.dd-touch .dd-footer-usage { display: none; }
|
||||||
|
|
||||||
|
.dd-pr { margin-left: 6px; padding: 0 4px; border: 1px solid var(--border-color); color: var(--text-secondary); font-size: 0.58rem; font-weight: 400; text-decoration: none; vertical-align: middle; }
|
||||||
|
.dd-pr.is-approved { border-color: var(--accent-green-border); color: var(--accent-green); }
|
||||||
|
.dd-pr.is-changes_requested { border-color: rgba(255, 45, 85, 0.4); color: var(--accent-red); }
|
||||||
|
|||||||
Reference in New Issue
Block a user