feat: v0.8.2 clickable paths, alert sessions, ops guard and trail
- Terminal link provider: printed paths that are files of the session open in Files (at the line); the file list is preloaded so the first hover works. - Alerts: "In a new session" opens alert-<name> and types the prompt once Claude is up. - devdesk-guard PreToolUse(Bash) hook: permissionDecision "ask" for destructive commands (verified to prompt in auto mode); DEVDESK_GUARD and guard.local. - State hook logs Bash commands and derives host tags (ssh targets, CT/VM ids); --list carries tags, --peek the last commands; UI shows both. Closes #21 Closes #18 Closes #27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,7 @@ install: all
|
||||
install -m 0644 libexec/devdesk-lib.sh $(DESTDIR)$(LIBDIR)/devdesk-lib.sh
|
||||
install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \
|
||||
libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \
|
||||
libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix $(DESTDIR)$(LIBDIR)/
|
||||
libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix libexec/devdesk-guard $(DESTDIR)$(LIBDIR)/
|
||||
install -m 0644 libexec/devdesk-files.sh libexec/devdesk-inbox.sh $(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)/
|
||||
|
||||
@@ -49,6 +49,8 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
|
||||
- **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. When CI fails, **Ask Claude to fix CI** types a prompt to read the failed job's log.
|
||||
- **Files** (Ctrl+Shift+E), laid out like VS Code: an explorer tree (git colours, gitignored entries dimmed) and an editor with tabs (a click opens a preview tab, a double-click keeps it), **Go to file** (Ctrl+P in the pane), markdown previews (sanitized, with relative images), images, and a Changes view of uncommitted files with line counts, a **C** badge on files Claude edited, and diffs against HEAD. **@Claude** types `@path` into the prompt. Tabs and open folders are remembered per session. Paths can't leave the session folder.
|
||||
- **Editing:** text files are editable; ● marks unsaved tabs; Ctrl+S (or Save) writes atomically and keeps the file's mode and owner. If the file changed on disk since you opened it (Claude edited it), saving asks: Compare (a diff of disk vs yours), Overwrite or Cancel; a change on disk while you edit shows a banner instead of replacing your text. Unsaved edits are kept as drafts in the browser, so a reload or a session switch doesn't lose them. + creates a file (folders as needed). The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser.
|
||||
- **Paths are links:** a file path Claude prints (`web/src/files.js`, `notes.md:12`) opens in Files when clicked, at that line if given.
|
||||
- **Ops guard:** `devdesk-guard` (a PreToolUse hook) makes Claude ask before destructive commands (`pct destroy`, `zfs destroy`, Ceph pool/OSD removal, `rm -r /…`, `git push --force`, `reboot`…), even in auto mode. `DEVDESK_GUARD=0` turns it off; `/etc/devdesk/guard.local` adds rules (`regex<TAB>reason`). Bash commands are logged per session, and sessions are tagged with the hosts they touch (ssh targets, CT/VM ids).
|
||||
- **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use and cost to `/var/lib/devdesk/context/<session>.json`. The Sessions panel shows it as a bar (cost in the tooltip), and Claude's own footer shows a one-line summary.
|
||||
- **Light mode:** the terminal switches palettes with the page. DevDesk starts Claude with its `dark-ansi` theme so Claude draws with the terminal's 16 colours, and the light palette maps bright white to ink; tmux and the status line use ANSI colours too. A session picks up Claude's theme the next time Claude starts in it (`/exit`, then ⏎ resumes the same conversation).
|
||||
- **Phone:** a key bar replaces the shortcut hints (Esc, Tab, ⇧Tab, ^C, ↑, ↓, ⏎, Claude/Shell window), and the panels open as a drawer.
|
||||
|
||||
+2
-1
@@ -69,6 +69,7 @@ cmd_list() {
|
||||
--arg proj "$(projects_tsv)" \
|
||||
--argjson ctx "$(per_session_json "$DEVDESK_CONTEXT_DIR")" \
|
||||
--argjson st "$(per_session_json "$DEVDESK_STATUS_DIR")" \
|
||||
--argjson tags "$(per_session_json "$DEVDESK_STATE_DIR/tags")" \
|
||||
--arg dflt "$DEVDESK_DEFAULT_SESSION" \
|
||||
--arg root "$DEVDESK_PROJECTS_ROOT" '
|
||||
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
|
||||
@@ -85,7 +86,7 @@ cmd_list() {
|
||||
| map({name: .key, dir: .value.dir, running: false, attached: 0,
|
||||
activity: null, windows: 0,
|
||||
claude_session_id: (.value.claude_session_id // null)}))
|
||||
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null)}),
|
||||
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null), tags: ($tags[.name].hosts // [])}),
|
||||
projects: ([{name: $dflt, dir: $root}]
|
||||
+ [rows($proj)[] | {name: .[0], dir: (.[1:] | join("\t"))}
|
||||
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
|
||||
|
||||
Vendored
+24
@@ -1,3 +1,27 @@
|
||||
devdesk (0.8.2) bookworm; urgency=medium
|
||||
|
||||
* Click file paths in the terminal (#21): a path Claude prints
|
||||
(web/src/files.js, ./notes.md:12, /root/code/x/y.py:3) is a link when it
|
||||
is a file of the session; it opens in Files, at that line if given.
|
||||
* Alerts: "In a new session" (#18) opens (or reuses) alert-<name> in the
|
||||
default folder and types the investigation prompt there once Claude is
|
||||
up, without sending it.
|
||||
* Ops trail and guard (#27):
|
||||
- devdesk-guard, a PreToolUse hook for Bash, makes Claude ask before
|
||||
commands that destroy things (pct/qm destroy, zfs/zpool destroy, Ceph
|
||||
pool/OSD removal, rbd rm, wipefs/mkfs/dd to /dev, rm -r of / or a
|
||||
top-level folder, git push --force, DROP TABLE, stopping devdesk-tmux,
|
||||
reboot/poweroff…), also in auto mode. It never blocks or allows on its
|
||||
own. DEVDESK_GUARD=0 turns it off; /etc/devdesk/guard.local adds rules
|
||||
(regex<TAB>reason).
|
||||
- Every Bash command is logged per session (last 500); sessions are
|
||||
tagged with the hosts they work on (ssh targets, CT/VM ids), shown in
|
||||
the session list, the switcher and the palette; Peek lists the last
|
||||
commands.
|
||||
* Sessions pick up the new hooks when Claude next starts (/exit, then ⏎).
|
||||
|
||||
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 20:36:32 -0400
|
||||
|
||||
devdesk (0.8.1) bookworm; urgency=medium
|
||||
|
||||
* Answer sessions from the 🔔 inbox without switching to them:
|
||||
|
||||
@@ -42,5 +42,10 @@ DEVDESK_PUBLIC_URL=https://dev.lotusguild.org
|
||||
DEVDESK_MATRIX_WEBHOOK_FILE=/etc/devdesk/matrix-webhook
|
||||
DEVDESK_MATRIX_DELAY=30
|
||||
|
||||
# 0 turns off the guard that makes Claude ask before destructive commands
|
||||
# (pct/zfs/ceph destroy, rm -r /…, force pushes, reboots). Extra rules:
|
||||
# /etc/devdesk/guard.local, one "regex<TAB>reason" per line.
|
||||
DEVDESK_GUARD=1
|
||||
|
||||
# 1 = also enable Remote Control so sessions can be continued from the Claude app.
|
||||
DEVDESK_REMOTE_CONTROL=0
|
||||
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# Claude Code PreToolUse hook for Bash (wired in via claude-settings.json):
|
||||
# commands that destroy things (containers, VMs, pools, disks, root-level
|
||||
# folders, cluster members…) get permissionDecision "ask", so Claude has to get
|
||||
# your OK even in auto mode (devdesk#27). It never blocks and never allows on
|
||||
# its own; anything else passes through untouched.
|
||||
#
|
||||
# DEVDESK_GUARD=0 in /etc/devdesk/env.local turns it off. Extra rules go in
|
||||
# /etc/devdesk/guard.local, one per line: an extended regex, a tab, a reason.
|
||||
|
||||
# shellcheck source=libexec/devdesk-lib.sh
|
||||
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" 2>/dev/null || exit 0
|
||||
[ "${DEVDESK_GUARD:-1}" != 0 ] || exit 0
|
||||
|
||||
command=$(jq -rM '.tool_input.command // empty' 2>/dev/null) || exit 0
|
||||
[ -n "$command" ] || exit 0
|
||||
|
||||
# Built-in rules: extended regex <TAB> reason.
|
||||
rules=$(
|
||||
cat <<'RULES'
|
||||
\bpct[[:space:]]+destroy\b destroys a Proxmox container
|
||||
\bqm[[:space:]]+destroy\b destroys a Proxmox VM
|
||||
\bpvecm[[:space:]]+delnode\b removes a node from the Proxmox cluster
|
||||
\bpveceph[[:space:]]+(osd|pool|mon|mgr|mds|fs)[[:space:]]+destroy\b destroys Ceph components
|
||||
\bceph[[:space:]]+osd[[:space:]]+(purge|destroy|rm|out)\b removes or takes out a Ceph OSD
|
||||
\bceph[[:space:]]+osd[[:space:]]+pool[[:space:]]+(delete|rm)\b deletes a Ceph pool
|
||||
\bceph[[:space:]]+(fs[[:space:]]+rm|mon[[:space:]]+remove|auth[[:space:]]+del)\b removes Ceph components
|
||||
\brbd[[:space:]]+(rm|remove|purge)\b deletes RBD images
|
||||
\bzfs[[:space:]]+destroy\b destroys a ZFS dataset or snapshot
|
||||
\bzpool[[:space:]]+(destroy|labelclear)\b destroys a ZFS pool
|
||||
\b(lvremove|vgremove|pvremove|wipefs|shred)\b wipes volumes or disks
|
||||
\bmkfs(\.[a-z0-9]+)?\b formats a filesystem
|
||||
\bsgdisk[[:space:]]+(-[a-zA-Z]*Z|--zap) wipes a partition table
|
||||
\bdd\b[^|;&]*\bof=/dev/ writes raw to a device
|
||||
\brm[[:space:]]+(-[^[:space:]]*[[:space:]]+)*-[^[:space:]]*[rR][^[:space:]]*[[:space:]]+(-[^[:space:]]*[[:space:]]+)*['"]?/([a-z0-9_.*-]+/?)?['"]?([[:space:];&|)]|$) recursively deletes / or a top-level folder
|
||||
\bgit[[:space:]]+push\b[^|;&]*([[:space:]]-f\b|--force) force-pushes (rewrites remote history)
|
||||
\b(DROP[[:space:]]+(DATABASE|SCHEMA|TABLE)|TRUNCATE[[:space:]]+TABLE)\b drops database objects
|
||||
\bsystemctl[[:space:]]+(stop|restart|kill|disable|mask)[[:space:]]+devdesk-tmux ends every DevDesk session
|
||||
(^|[;&|][[:space:]]*|\bssh[[:space:]]+[^[:space:]]+[[:space:]]+['"]?|\bsystemctl[[:space:]]+)(reboot|poweroff|halt|shutdown)\b reboots or shuts down a machine
|
||||
\bkubectl[[:space:]]+delete\b deletes Kubernetes resources
|
||||
\bdocker[[:space:]]+(system[[:space:]]+prune|volume[[:space:]]+(rm|prune))\b deletes Docker data
|
||||
RULES
|
||||
cat "${DEVDESK_GUARD_LOCAL:-/etc/devdesk/guard.local}" 2>/dev/null
|
||||
)
|
||||
|
||||
while IFS=$'\t' read -r pattern reason; do
|
||||
if [ -z "$pattern" ] || [ "${pattern:0:1}" = "#" ]; then continue; fi
|
||||
if grep -Eiq -- "$pattern" <<<"$command" 2>/dev/null; then
|
||||
jq -cMn --arg r "DevDesk guard: this command ${reason:-matches a rule in guard.local}. Confirm before it runs." \
|
||||
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: $r}}'
|
||||
exit 0
|
||||
fi
|
||||
done <<<"$rules"
|
||||
exit 0
|
||||
@@ -58,15 +58,18 @@ dd_transcript() {
|
||||
# of its transcript (the last assistant text and the last thing you asked).
|
||||
cmd_peek() {
|
||||
[ $# -eq 1 ] || dd_die "usage: devdesk attach --peek SESSION"
|
||||
local session=$1 transcript status
|
||||
local session=$1 transcript status commands
|
||||
dd_valid_name "$session" || dd_die "invalid session name: $session"
|
||||
status=$(jq -cM . "$(dd_state_file "$session")" 2>/dev/null) || status=null
|
||||
# The last commands this session ran (devdesk#27).
|
||||
commands=$(tail -n 8 "$DEVDESK_STATE_DIR/commands/$session.jsonl" 2>/dev/null | jq -cMs '.' 2>/dev/null) || commands='[]'
|
||||
[ -n "$commands" ] || commands='[]'
|
||||
transcript=$(dd_transcript "$session") || transcript=""
|
||||
if [ -z "$transcript" ] || [ ! -r "$transcript" ]; then
|
||||
jq -cMn --arg s "$session" --argjson st "${status:-null}" '{session: $s, status: $st, assistant: null, user: null}'
|
||||
jq -cMn --arg s "$session" --argjson st "${status:-null}" --argjson c "$commands" '{session: $s, status: $st, assistant: null, user: null, commands: $c}'
|
||||
return
|
||||
fi
|
||||
tail -n 400 -- "$transcript" | jq -cMs --arg s "$session" --argjson st "${status:-null}" '
|
||||
tail -n 400 -- "$transcript" | jq -cMs --arg s "$session" --argjson st "${status:-null}" --argjson c "$commands" '
|
||||
def text: if type == "string" then . else ([.[]? | select(.type == "text") | .text] | join("\n")) end;
|
||||
[.[] | select(.isSidechain != true and .isMeta != true)] as $all
|
||||
| ([$all[] | select(.type == "assistant") | {text: (.message.content | text), at: .timestamp} | select(.text != "")] | last) as $a
|
||||
@@ -75,8 +78,8 @@ cmd_peek() {
|
||||
| select(.text != "" and (.text | startswith("<") | not))] | last) as $u
|
||||
| {session: $s, status: $st,
|
||||
assistant: (if $a then {text: ($a.text | .[-4000:]), at: $a.at, truncated: ($a.text | length > 4000)} else null end),
|
||||
user: (if $u then {text: ($u.text | .[-600:]), at: $u.at} else null end)}' 2>/dev/null ||
|
||||
jq -cMn --arg s "$session" --argjson st "${status:-null}" '{session: $s, status: $st, assistant: null, user: null}'
|
||||
user: (if $u then {text: ($u.text | .[-600:]), at: $u.at} else null end), commands: $c}' 2>/dev/null ||
|
||||
jq -cMn --arg s "$session" --argjson st "${status:-null}" --argjson c "$commands" '{session: $s, status: $st, assistant: null, user: null, commands: $c}'
|
||||
}
|
||||
|
||||
# --reply SESSION BYTES: type a message into the session's Claude prompt and
|
||||
|
||||
+10
-4
@@ -132,7 +132,8 @@ dd_registry_set_claude() {
|
||||
|
||||
dd_registry_remove() {
|
||||
dd_registry_update 'del(.sessions[$n])' --arg n "$1"
|
||||
rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" "$DEVDESK_STATE_DIR/edits/$1.txt"
|
||||
rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" "$DEVDESK_STATE_DIR/edits/$1.txt" \
|
||||
"$DEVDESK_STATE_DIR/commands/$1.jsonl" "$DEVDESK_STATE_DIR/tags/$1.json"
|
||||
}
|
||||
|
||||
dd_registry_rename() {
|
||||
@@ -144,9 +145,14 @@ dd_registry_rename() {
|
||||
mv -f "$d/$1.json" "$d/$2.json"
|
||||
fi
|
||||
done
|
||||
if [ -f "$DEVDESK_STATE_DIR/edits/$1.txt" ]; then
|
||||
mv -f "$DEVDESK_STATE_DIR/edits/$1.txt" "$DEVDESK_STATE_DIR/edits/$2.txt"
|
||||
fi
|
||||
local f
|
||||
for f in "edits/%s.txt" "commands/%s.jsonl" "tags/%s.json"; do
|
||||
# shellcheck disable=SC2059 # the pattern is ours
|
||||
if [ -f "$DEVDESK_STATE_DIR/$(printf "$f" "$1")" ]; then
|
||||
# shellcheck disable=SC2059
|
||||
mv -f "$DEVDESK_STATE_DIR/$(printf "$f" "$1")" "$DEVDESK_STATE_DIR/$(printf "$f" "$2")"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Project name (directory or its slug) -> absolute directory inside the projects root.
|
||||
|
||||
@@ -36,6 +36,36 @@ PostToolUse:Edit | PostToolUse:Write | PostToolUse:MultiEdit | PostToolUse:Noteb
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
# Ops trail (devdesk#27): every Bash command, and the hosts a session works on
|
||||
# (ssh targets, Proxmox CT/VM ids) as tags for the session list.
|
||||
if [ "$event:$tool" = PostToolUse:Bash ]; then
|
||||
cmd=$(jq -rM '.tool_input.command // empty' <<<"$input" 2>/dev/null)
|
||||
if [ -n "$cmd" ]; then
|
||||
base="${DEVDESK_STATE_DIR:-/var/lib/devdesk}"
|
||||
mkdir -p "$base/commands" "$base/tags" 2>/dev/null
|
||||
log="$base/commands/$session.jsonl"
|
||||
jq -cMn --arg c "${cmd:0:500}" '{at: (now | floor), cmd: $c}' >>"$log" 2>/dev/null
|
||||
if [ "$(wc -l <"$log" 2>/dev/null || echo 0)" -gt 600 ]; then
|
||||
tail -n 500 "$log" >"$log.tmp" 2>/dev/null && mv -f "$log.tmp" "$log"
|
||||
fi
|
||||
hosts=$(
|
||||
# ssh [options] [user@]host: the first word that isn't an option (or an option's value)
|
||||
awk '{ for (i = 1; i <= NF; i++) if ($i == "ssh") { j = i + 1
|
||||
while (j <= NF && $j ~ /^-/) { if ($j ~ /^-[bcDEeFIiJLlmOopQRSWw]$/) j++; j++ }
|
||||
if (j <= NF) { h = $j; sub(/^[^@]*@/, "", h); gsub(/[^A-Za-z0-9_.-].*$/, "", h); if (h != "") print h } } }' <<<"$cmd"
|
||||
grep -oE '\bpct[[:space:]]+(exec|enter|push|pull|config|set|start|stop|shutdown|reboot|status|snapshot|rollback|migrate)[[:space:]]+[0-9]{3,}' <<<"$cmd" | grep -oE '[0-9]+$' | sed 's/^/CT /'
|
||||
grep -oE '\bqm[[:space:]]+(guest[[:space:]]+exec|terminal|config|set|start|stop|shutdown|reboot|status|snapshot|rollback|migrate)[[:space:]]+[0-9]{3,}' <<<"$cmd" | grep -oE '[0-9]+$' | sed 's/^/VM /'
|
||||
)
|
||||
if [ -n "$hosts" ]; then
|
||||
tags="$base/tags/$session.json"
|
||||
jq -cMn --rawfile new <(printf '%s\n' "$hosts") --slurpfile old <(cat "$tags" 2>/dev/null || echo '{}') '
|
||||
($new | split("\n") | map(select(length > 0))) as $n
|
||||
| {hosts: ($n + ($old[0].hosts // []) | reduce .[] as $h ([]; if index([$h]) then . else . + [$h] end) | .[:4]),
|
||||
at: (now | floor)}' >"$tags.tmp" 2>/dev/null && mv -f "$tags.tmp" "$tags"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
case $event in
|
||||
UserPromptSubmit | PostToolUse) state=working ;;
|
||||
Notification | PermissionRequest) state=waiting ;;
|
||||
|
||||
@@ -84,6 +84,18 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/usr/lib/devdesk/devdesk-guard",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"statusLine": {
|
||||
|
||||
@@ -740,3 +740,48 @@ JSONL
|
||||
[ "$status" -ne 0 ]
|
||||
[[ $output == *"answer that first"* ]]
|
||||
}
|
||||
|
||||
@test "the guard asks before destructive commands and lets the rest through (#27)" {
|
||||
guard() { jq -cn --arg c "$1" '{hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: {command: $c}}' | "$REPO/libexec/devdesk-guard"; }
|
||||
asks=("pct destroy 105" "ssh root@large1 'pct destroy 105 --purge'" "qm destroy 210" "zfs destroy rpool/data/vm-105-disk-0"
|
||||
"zpool destroy tank" "ceph osd pool delete rbd rbd --yes-i-really-really-mean-it" "rbd rm vm-105-disk-0"
|
||||
"rm -rf /" "rm -rf /etc" "sudo rm -r -f /var/" "dd if=/dev/zero of=/dev/sdb bs=1M" "mkfs.ext4 /dev/sdb1" "wipefs -a /dev/sdc"
|
||||
"git push --force origin main" "git push -f" "psql -c 'drop table users'" "systemctl restart devdesk-tmux"
|
||||
"ssh pve1 reboot" "systemctl poweroff" "cd /tmp && shutdown -h now")
|
||||
for c in "${asks[@]}"; do
|
||||
run guard "$c"
|
||||
[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" = ask ] || { echo "should ask: $c"; false; }
|
||||
done
|
||||
passes=("pct list" "pct exec 105 -- df -h" "zfs list" "rm -rf /tmp/build-cache" "rm -rf node_modules" "rm -rf ./dist"
|
||||
"git push origin main" "dd if=a.img of=b.img" "echo reboot later" "systemctl restart devdesk" "ceph -s" "ls -la /")
|
||||
for c in "${passes[@]}"; do
|
||||
run guard "$c"
|
||||
[ -z "$output" ] || { echo "should pass: $c -> $output"; false; }
|
||||
done
|
||||
# Local rules and the off switch
|
||||
printf 'nuke-it\tis a local rule\n' >"$TMP/guard.local"
|
||||
run env DEVDESK_GUARD_LOCAL="$TMP/guard.local" bash -c "jq -cn '{tool_input: {command: \"nuke-it now\"}}' | '$REPO/libexec/devdesk-guard'"
|
||||
[[ $output == *"is a local rule"* ]]
|
||||
run env DEVDESK_GUARD=0 bash -c "jq -cn '{tool_input: {command: \"pct destroy 1\"}}' | '$REPO/libexec/devdesk-guard'"
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
@test "Bash commands are logged and tag the session with the hosts it works on (#27)" {
|
||||
start_server
|
||||
devdesk new alpha
|
||||
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
|
||||
bash_cmd() { jq -cn --arg c "$1" '{hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: {command: $c}}' |
|
||||
TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
|
||||
bash_cmd "ssh -o BatchMode=yes root@large1 'pct exec 167 -- systemctl status authelia'"
|
||||
bash_cmd "ls -la"
|
||||
bash_cmd "ssh pve2 qm status 210"
|
||||
[ "$(wc -l <"$DEVDESK_STATE_DIR/commands/alpha.jsonl")" -eq 3 ]
|
||||
run devdesk attach --list
|
||||
[ "$(jq -r '.sessions[] | select(.name == "alpha") | .tags | join(",")' <<<"$output")" = "pve2,VM 210,large1,CT 167" ]
|
||||
run devdesk attach --peek alpha
|
||||
[ "$(jq -r '.commands | map(.cmd) | .[1]' <<<"$output")" = "ls -la" ]
|
||||
devdesk rename alpha beta
|
||||
[ -f "$DEVDESK_STATE_DIR/commands/beta.jsonl" ] && [ -f "$DEVDESK_STATE_DIR/tags/beta.json" ]
|
||||
devdesk end beta
|
||||
[ ! -e "$DEVDESK_STATE_DIR/commands/beta.jsonl" ] && [ ! -e "$DEVDESK_STATE_DIR/tags/beta.json" ]
|
||||
}
|
||||
|
||||
@@ -86,3 +86,15 @@ test('untrusted text loses control characters before it is typed (#2)', () => {
|
||||
assert.equal(pasteSafe('plain text'), 'plain text');
|
||||
assert.equal(pasteSafe(null), '');
|
||||
});
|
||||
|
||||
test('file paths in terminal output are found, URLs and plain words are not (#21)', async () => {
|
||||
const { findPaths } = await import('../web/src/links.js');
|
||||
const paths = (text) => findPaths(text).map((p) => `${p.path}${p.line ? `:${p.line}` : ''}`);
|
||||
assert.deepEqual(paths('● Update(web/src/files.js)'), ['web/src/files.js']);
|
||||
assert.deepEqual(paths(' ⎿ Wrote 12 lines to notes/todo.md'), ['notes/todo.md']);
|
||||
assert.deepEqual(paths('error at src/app.py:42:7 and ./README.md:3'), ['src/app.py:42', './README.md:3']);
|
||||
assert.deepEqual(paths('see /root/code/devdesk/Makefile'), ['/root/code/devdesk/Makefile']);
|
||||
assert.deepEqual(paths('open https://code.lotusguild.org/a/b.js now'), []);
|
||||
// Candidates only: they become links only if they're files of the session.
|
||||
assert.deepEqual(paths('just some words, v1.2 and e.g. stuff'), ['v1.2']);
|
||||
});
|
||||
|
||||
+9
-1
@@ -135,10 +135,17 @@ function baseExtensions(path, { readOnly = true, wrap = false } = {}) {
|
||||
];
|
||||
}
|
||||
|
||||
// Put the cursor on a line (1-based) and scroll it to the middle.
|
||||
function gotoLine(view, line) {
|
||||
const target = view.state.doc.line(Math.max(1, Math.min(line, view.state.doc.lines)));
|
||||
view.dispatch({ selection: { anchor: target.from, head: target.to }, effects: EditorView.scrollIntoView(target.from, { y: 'center' }) });
|
||||
view.focus();
|
||||
}
|
||||
|
||||
// Read-only view of a file.
|
||||
export function viewer(parent, { doc, path, wrap }) {
|
||||
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap }) }) });
|
||||
return { view, destroy: () => view.destroy() };
|
||||
return { view, goto: (line) => gotoLine(view, line), destroy: () => view.destroy() };
|
||||
}
|
||||
|
||||
// Editable file (devdesk#20): onChange(text) after every edit, onSave() on
|
||||
@@ -159,6 +166,7 @@ export function editor(parent, { doc, path, wrap, onChange, onSave }) {
|
||||
});
|
||||
return {
|
||||
view,
|
||||
goto: (line) => gotoLine(view, line),
|
||||
text: () => view.state.doc.toString(),
|
||||
// Replace the whole document (reload from disk) keeping the cursor roughly where it was.
|
||||
setText: (text) => {
|
||||
|
||||
+6
-1
@@ -60,5 +60,10 @@ export function renderAlerts(root, data) {
|
||||
el('span', { class: 'dd-muted dd-alert-age' }, age(a.since))),
|
||||
a.instances?.length ? el('div', { class: 'dd-muted dd-alert-instances' }, a.instances.join(' · ')) : null,
|
||||
a.summary ? el('div', { class: 'dd-alert-summary' }, a.summary) : null,
|
||||
el('button', { type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a) }, 'Ask Claude'))));
|
||||
el('div', { class: 'dd-ask-row' },
|
||||
el('button', { type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a), title: 'Type the prompt into the current session (not sent)' }, 'Ask Claude'),
|
||||
el('button', {
|
||||
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a),
|
||||
'data-ask-session': `alert-${a.name}`, title: 'Open a separate ops session for this alert and type the prompt there (not sent)',
|
||||
}, 'In a new session')))));
|
||||
}
|
||||
|
||||
+52
-2
@@ -437,6 +437,7 @@ export class FilesPane {
|
||||
async loadFileList() {
|
||||
if (this.fileList && Date.now() - this.fileList.at < 60000 && this.fileList.session === this.session) return this.fileList.items;
|
||||
const list = parseListOutput(await runCommand(['--fs-find', this.session], 45000));
|
||||
this.fileListRoot = list.root;
|
||||
const items = list.files.map((path) => {
|
||||
const name = base(path);
|
||||
const [icon] = iconFor(name);
|
||||
@@ -462,6 +463,9 @@ export class FilesPane {
|
||||
}
|
||||
|
||||
async claudeTurnEnded() {
|
||||
// New files may exist now: refresh the list terminal links are checked against.
|
||||
this.fileList = null;
|
||||
this.knownFiles();
|
||||
if (!this.isOpen) {
|
||||
// Next time it opens it revalidates anyway; open tabs re-check on activation.
|
||||
for (const v of this.views.values()) v.stale = true;
|
||||
@@ -707,8 +711,9 @@ export class FilesPane {
|
||||
|
||||
// Single click opens a preview tab (italic) that the next click replaces;
|
||||
// double click, Enter or go-to-file keeps it.
|
||||
openFile(path, { mode, pin = false } = {}) {
|
||||
const wanted = mode ?? (isMarkdown(path) ? 'preview' : 'view');
|
||||
openFile(path, { mode, pin = false, line } = {}) {
|
||||
// A line number means source: markdown opens as text, not as a preview.
|
||||
const wanted = mode ?? (isMarkdown(path) && !line ? 'preview' : 'view');
|
||||
const key = `${wanted}:${path}`;
|
||||
let tab = this.tabs.find((t) => t.key === key) ?? this.tabs.find((t) => t.path === path && !mode);
|
||||
if (!tab) {
|
||||
@@ -729,6 +734,11 @@ export class FilesPane {
|
||||
tab.pinned = true;
|
||||
}
|
||||
this.pushRecent(path);
|
||||
if (line) {
|
||||
const loaded = this.views.get(tab.key)?.editor;
|
||||
if (loaded?.goto) setTimeout(() => loaded.goto(line), 0);
|
||||
else tab.line = line;
|
||||
}
|
||||
this.activate(tab.key);
|
||||
if (this.root.classList.contains('is-narrow')) this.setExplorer(false);
|
||||
}
|
||||
@@ -1030,9 +1040,49 @@ export class FilesPane {
|
||||
view.node.replaceChildren(this.bar(tab, header, view), content);
|
||||
if (banner) this.oldDraft(tab.key, banner);
|
||||
if (view.dirty) this.renderTabs();
|
||||
this.gotoPending(tab, view);
|
||||
return;
|
||||
}
|
||||
view.node.replaceChildren(this.bar(tab, header, view), content);
|
||||
this.gotoPending(tab, view);
|
||||
}
|
||||
|
||||
gotoPending(tab, view) {
|
||||
if (!tab.line || !view.editor?.goto) return;
|
||||
const line = tab.line;
|
||||
delete tab.line;
|
||||
setTimeout(() => view.editor.goto(line), 0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ paths in the terminal (devdesk#21)
|
||||
|
||||
// The session's file list (cached by go-to-file). Loaded ahead of time (on
|
||||
// connect and after Claude's turns): xterm asks for a line's links once and
|
||||
// caches the answer, so it has to be ready before the first hover.
|
||||
knownFiles() {
|
||||
if (this.session !== this.hooks.session()) this.syncSession();
|
||||
if (this.fileList?.session === this.session) {
|
||||
if (!this.fileSet || this.fileSet.list !== this.fileList.items) {
|
||||
this.fileSet = { list: this.fileList.items, set: new Set(this.fileList.items.map((x) => x.id)) };
|
||||
}
|
||||
return this.fileSet.set;
|
||||
}
|
||||
if (!this.loadingList && this.session) {
|
||||
this.loadingList = true;
|
||||
this.loadFileList().catch(() => {}).finally(() => { this.loadingList = false; });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// A path as printed in the terminal -> the session-relative path, if it's a
|
||||
// file of this session (absolute paths inside the folder count too).
|
||||
resolvePrinted(text) {
|
||||
const known = this.knownFiles();
|
||||
if (!known) return null;
|
||||
let path = text.replace(/^\.\//, '');
|
||||
const root = this.fileListRoot;
|
||||
if (path.startsWith('/') && root && path.startsWith(`${root}/`)) path = path.slice(root.length + 1);
|
||||
return known.has(path) ? path : null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ editing (devdesk#20)
|
||||
|
||||
@@ -184,6 +184,10 @@ export class Inbox {
|
||||
el('div', { class: 'dd-peek-assistant' },
|
||||
el('span', { class: 'dd-peek-label' }, 'Claude'),
|
||||
data.assistant ? `${data.assistant.truncated ? '…' : ''}${data.assistant.text}` : 'Nothing yet.'),
|
||||
data.commands?.length ? el('div', { class: 'dd-peek-commands' },
|
||||
el('span', { class: 'dd-peek-label' }, 'Recent commands'),
|
||||
...data.commands.slice(-8).map((c) => el('div', { class: 'dd-peek-command', title: c.cmd },
|
||||
el('span', { class: 'dd-peek-when' }, ago(c.at)), el('code', {}, c.cmd)))) : null,
|
||||
waitingRequest
|
||||
? el('div', { class: 'dd-notif-empty' }, 'Claude is asking for a permission: answer it from the list, or open the session.')
|
||||
: el('div', { class: 'dd-peek-reply' }, input, el('div', { class: 'dd-peek-actions' },
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// File paths in the terminal become links (devdesk#21): click one in Claude's
|
||||
// output (web/src/files.js, ./notes.md:12, /root/code/x/y.py:3:9) to open it in
|
||||
// the Files pane, at that line if there is one. Only paths that are files of
|
||||
// the session become links, so words and URLs are left alone.
|
||||
|
||||
// A path: optional ./ ../ or /, segments, and either a slash or an extension;
|
||||
// then an optional :line[:col].
|
||||
const PATH_RE = /(?<![\w/.@~+-])((?:\.{1,2}\/|\/)?(?:[\w@+~-][\w.@+~-]*\/)*[\w@+~-][\w.@+~-]*(?:\.[A-Za-z0-9]{1,10})?)(?::(\d+)(?::(\d+))?)?/g;
|
||||
const URL_RE = /\b[a-z][a-z0-9+.-]*:\/\/\S+/gi;
|
||||
|
||||
export function findPaths(text) {
|
||||
const urls = [...text.matchAll(URL_RE)].map((m) => [m.index, m.index + m[0].length]);
|
||||
const out = [];
|
||||
for (const m of text.matchAll(PATH_RE)) {
|
||||
const candidate = m[1];
|
||||
if (!candidate.includes('/') && !/\.[A-Za-z0-9]{1,10}$/.test(candidate)) continue;
|
||||
if (urls.some(([a, b]) => m.index >= a && m.index < b)) continue;
|
||||
out.push({ index: m.index, length: m[0].length, path: candidate, line: m[2] ? Number(m[2]) : undefined });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function registerPathLinks(term, { resolve, open }) {
|
||||
term.registerLinkProvider({
|
||||
provideLinks(y, callback) {
|
||||
const line = term.buffer.active.getLine(y - 1);
|
||||
if (!line) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
const links = [];
|
||||
for (const found of findPaths(line.translateToString(true))) {
|
||||
const path = resolve(found.path);
|
||||
if (!path) continue;
|
||||
links.push({
|
||||
range: { start: { x: found.index + 1, y }, end: { x: found.index + found.length, y } },
|
||||
text: path,
|
||||
decorations: { underline: true, pointerCursor: true },
|
||||
activate: () => open(path, found.line),
|
||||
});
|
||||
}
|
||||
callback(links.length ? links : undefined);
|
||||
},
|
||||
});
|
||||
}
|
||||
+52
-4
@@ -4,7 +4,7 @@ import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
|
||||
import {
|
||||
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
|
||||
} from './connection.js';
|
||||
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
|
||||
import { parseTitle, pasteSafe, sessionFromSearch, slugify } from './protocol.js';
|
||||
import { SessionsPanel, recentNames, rememberSession, sessionStatus } from './sessions.js';
|
||||
import { renderAlerts } from './alerts.js';
|
||||
import { renderRepo } from './repo.js';
|
||||
@@ -13,6 +13,7 @@ import { heardWithin, onShared, share } from './shared.js';
|
||||
import { CommandPalette, dialogOpen, openDialog, toast } from './ui.js';
|
||||
import { Inbox } from './inbox.js';
|
||||
import { FilesPane } from './files.js';
|
||||
import { registerPathLinks } from './links.js';
|
||||
import { el } from './dom.js';
|
||||
import * as notify from './notify.js';
|
||||
|
||||
@@ -357,6 +358,7 @@ function connect(session, { existing = false, reconnect = false } = {}) {
|
||||
state.attempts = 0;
|
||||
setWsState('connected', 'live');
|
||||
if (session) rememberSession(session);
|
||||
if (session) files?.knownFiles(); // so paths in the output are links from the first hover
|
||||
refreshSoon(800); // opening ?arg=<new name> just created this session
|
||||
refreshRepo();
|
||||
fit.fit();
|
||||
@@ -548,7 +550,8 @@ function renderSwitcher() {
|
||||
title: s.dir || '', onclick: choose(() => connect(s.name, { existing: true })),
|
||||
},
|
||||
el('span', { class: `lt-dot ${status.dot}` }),
|
||||
el('span', { class: 'dd-switch-name' }, s.name),
|
||||
el('span', { class: 'dd-switch-name' }, s.name,
|
||||
s.tags?.length ? el('span', { class: 'dd-tags' }, ...s.tags.slice(0, 2).map((t) => el('span', { class: 'dd-tag' }, t))) : null),
|
||||
el('span', { class: 'dd-switch-meta' }, `${status.label}${ctx}`));
|
||||
});
|
||||
if (!items.length) items.push(el('div', { class: 'dd-menu-note' }, sessionsPanel?.data ? 'No sessions yet.' : 'Loading…'));
|
||||
@@ -589,6 +592,38 @@ async function toggleNotifications() {
|
||||
}
|
||||
}
|
||||
|
||||
// "In a new session" (devdesk#18): open (or reuse) a session for this alert,
|
||||
// wait until its Claude is up (SessionStart sets it idle), then type the
|
||||
// prompt without sending it.
|
||||
let pendingAsk = null;
|
||||
function askInSession(name, text) {
|
||||
closeDrawerIfNarrow();
|
||||
pendingAsk = { session: name, text, until: Date.now() + 60000 };
|
||||
connect(name);
|
||||
toast.info(`Opening ${name}; the prompt will be typed when Claude is ready`);
|
||||
const tick = async () => {
|
||||
if (!pendingAsk || pendingAsk.session !== name) return;
|
||||
if (Date.now() > pendingAsk.until) {
|
||||
toast.warning(`${name}: Claude didn't start in time; the prompt was not typed`);
|
||||
pendingAsk = null;
|
||||
return;
|
||||
}
|
||||
await sessionsPanel.reload();
|
||||
const s = sessionsPanel.data?.sessions?.find((x) => x.name === name);
|
||||
const ready = s && ['idle', 'waiting', 'working'].includes(sessionStatus(s).key);
|
||||
if (ready && state.session === name && state.conn?.isOpen) {
|
||||
setTimeout(() => {
|
||||
term.paste(pasteSafe(pendingAsk.text));
|
||||
term.focus();
|
||||
pendingAsk = null;
|
||||
}, 800);
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, 1500);
|
||||
};
|
||||
setTimeout(tick, 1500);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- palette & help
|
||||
|
||||
const sendKeys = (keys) => state.conn?.sendInput(keys);
|
||||
@@ -620,7 +655,8 @@ function buildCommands() {
|
||||
if (s.name === state.session) continue;
|
||||
const status = sessionStatus(s);
|
||||
add({
|
||||
id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: status.label, keywords: s.dir,
|
||||
id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: [status.label, ...(s.tags ?? []).slice(0, 2)].join(' · '),
|
||||
keywords: `${s.dir} ${(s.tags ?? []).join(' ')}`,
|
||||
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name, { existing: true }),
|
||||
});
|
||||
}
|
||||
@@ -718,6 +754,7 @@ const KEY_HELP = [
|
||||
['Ctrl+⇧ C / V', 'Copy selection / paste'],
|
||||
['⇧ drag', 'Select text in the browser'],
|
||||
['Drop or paste a file', 'Upload it and attach it for Claude'],
|
||||
['Click a file path', 'Open it in Files (at the line, if given)'],
|
||||
['Ctrl+W', 'Asks before closing; Chromium fullscreen keeps it for the terminal'],
|
||||
]],
|
||||
];
|
||||
@@ -867,6 +904,13 @@ function wireChrome() {
|
||||
},
|
||||
});
|
||||
$('files-toggle').addEventListener('click', () => files.toggle());
|
||||
registerPathLinks(term, {
|
||||
resolve: (printed) => (state.session ? files.resolvePrinted(printed) : null),
|
||||
open: (path, line) => {
|
||||
if (!files.isOpen) files.open();
|
||||
files.openFile(path, { pin: true, line });
|
||||
},
|
||||
});
|
||||
wireResizer();
|
||||
palette = new CommandPalette(buildCommands);
|
||||
$('cmd-toggle').addEventListener('click', () => palette.open());
|
||||
@@ -1079,7 +1123,11 @@ async function boot() {
|
||||
onData: (_data, meta) => onSessionData(meta),
|
||||
},
|
||||
});
|
||||
bindAskClaude($('sidebar'), (text) => {
|
||||
bindAskClaude($('sidebar'), (text, session) => {
|
||||
if (session) {
|
||||
askInSession(slugify(session).slice(0, 48) || 'alert', text);
|
||||
return;
|
||||
}
|
||||
// Alert text is untrusted: no control characters (a newline would run it in a
|
||||
// shell window, CR would submit), and bracketed paste around it (devdesk#2).
|
||||
term.paste(pasteSafe(text));
|
||||
|
||||
+2
-1
@@ -285,7 +285,8 @@ export class SessionsPanel {
|
||||
return el('li', { class: `dd-session-item is-${status.key}${isCurrent ? ' is-current' : ''}` },
|
||||
el('button', { class: 'dd-session-open', type: 'button', title, onclick: () => this.hooks.onOpen(s.name) },
|
||||
el('span', { class: `lt-dot ${status.dot}` }),
|
||||
el('span', { class: 'dd-session-name' }, s.name),
|
||||
el('span', { class: 'dd-session-name' }, s.name,
|
||||
s.tags?.length ? el('span', { class: 'dd-tags', title: 'Hosts this session worked on' }, ...s.tags.slice(0, 3).map((t) => el('span', { class: 'dd-tag' }, t))) : null),
|
||||
el('span', { class: 'dd-session-meta' }, meta),
|
||||
pct != null ? el('span', { class: `dd-ctx${pct >= 90 ? ' is-full' : pct >= 70 ? ' is-high' : ''}`, 'aria-hidden': 'true' },
|
||||
el('span', { class: 'dd-ctx-fill', style: `width:${Math.min(100, pct)}%` })) : null),
|
||||
|
||||
+4
-3
@@ -1,11 +1,12 @@
|
||||
// "Ask Claude": any [data-ask-claude] button (Ops Alerts, Repo/CI) types its
|
||||
// prompt into the current session without pressing Enter, so you can review it
|
||||
// before sending.
|
||||
// prompt into a session without pressing Enter, so you can review it before
|
||||
// sending: the current session, or (data-ask-session) a separate one, e.g. an
|
||||
// ops session per alert (devdesk#18).
|
||||
|
||||
export function bindAskClaude(root, send) {
|
||||
root.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-ask-claude]');
|
||||
if (!button || button.disabled) return;
|
||||
send(button.dataset.askClaude);
|
||||
send(button.dataset.askClaude, button.dataset.askSession || null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -976,3 +976,13 @@ html[data-theme="light"] .dd-files.is-narrow .dd-explorer { box-shadow: 8px 0 24
|
||||
.dd-peek-assistant { max-height: 38vh; padding: 6px 8px; overflow: auto; border-left: 2px solid var(--accent-cyan); background: var(--bg-terminal); color: var(--text-primary); font-size: 0.74rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.dd-peek-input { width: 100%; min-height: 4.5em; resize: vertical; font-family: var(--font-mono); font-size: 0.76rem; }
|
||||
.dd-peek-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
|
||||
.dd-ask-row { display: flex; flex-wrap: wrap; gap: 6px; align-self: flex-start; margin-top: 2px; }
|
||||
.dd-alert .dd-ask-row .dd-ask { margin-top: 0; }
|
||||
|
||||
/* ---- ops trail (devdesk#27) ------------------------------------------------------------- */
|
||||
.dd-tags { display: inline-flex; flex-wrap: wrap; gap: 3px; margin-left: 6px; vertical-align: middle; }
|
||||
.dd-tag { padding: 0 4px; border: 1px solid var(--accent-cyan-border); color: var(--accent-cyan); font-size: 0.58rem; font-weight: 400; letter-spacing: 0; }
|
||||
.dd-peek-commands { display: flex; flex-direction: column; gap: 2px; }
|
||||
.dd-peek-command { display: flex; gap: 8px; min-width: 0; font-size: 0.68rem; }
|
||||
.dd-peek-command code { overflow: hidden; color: var(--text-secondary); font-family: var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dd-peek-when { flex: 0 0 2.6em; color: var(--text-muted); text-align: right; }
|
||||
|
||||
Reference in New Issue
Block a user