diff --git a/README.md b/README.md index 5b73f41..796128b 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ The tmux server runs in its own unit, so every Claude process lives in **that** | Ctrl-b d | Detach (the session keeps running) | | Drag | Selects text; it stays highlighted and is copied when you let go. Double-click selects a word, triple-click a line. Clicks on the tmux status line still go to tmux. | | Ctrl+C | Copies the selection when there is one; otherwise interrupts as usual | +| Palette → Close split panes | Removes extra tmux panes (a stray `^b %` split) and keeps Claude. tmux's right-click menus are off. | | Ctrl+Shift+C / V | Copy selection / paste | | Ctrl+Shift+P | Command palette (also the ⌕ button) | | Ctrl+Shift+E | Files pane (Esc: back, then close) | diff --git a/bin/devdesk b/bin/devdesk index a10ab5d..ed3ee43 100755 --- a/bin/devdesk +++ b/bin/devdesk @@ -15,7 +15,7 @@ # # The web UI calls `devdesk attach --list | --repo NAME | --alerts | --investigate SESSION UID | # --upload SESSION FILE BYTES | --new NAME [PROJECT] [--worktree] | --rename OLD NEW | --end NAME | -# --last-reply NAME | --existing NAME` +# --last-reply NAME | --unsplit NAME | --existing NAME` # through ttyd; those print JSON. set -euo pipefail @@ -182,6 +182,25 @@ dd_worktree_end() { fi } +# --unsplit NAME (devdesk#52): close the extra panes a split left in the session's +# windows. Each window keeps one pane: Claude's in the claude window, else the +# first. Prints {ok, closed}. +cmd_unsplit() { + [ $# -eq 1 ] || dd_die "usage: devdesk attach --unsplit NAME" + dd_valid_name "$1" || dd_die "invalid session name: $1" + { dd_server_up && dd_tmux has-session -t "=$1" 2>/dev/null; } || dd_die "session '$1' isn't running" + local win keep pane closed=0 + while read -r win; do + keep=$(dd_tmux list-panes -t "=$1:$win" -F '#{pane_id} #{pane_start_command}' | awk '/devdesk-claude-run/ { print $1; exit }') + [ -n "$keep" ] || keep=$(dd_tmux list-panes -t "=$1:$win" -F '#{pane_id}' | head -n 1) + while read -r pane; do + [ "$pane" = "$keep" ] && continue + dd_tmux kill-pane -t "$pane" && closed=$((closed + 1)) + done < <(dd_tmux list-panes -t "=$1:$win" -F '#{pane_id}') + done < <(dd_tmux list-windows -t "=$1" -F '#{window_index}') + jq -cMn --argjson c "$closed" '{ok: true, closed: $c}' +} + cmd_rename() { [ $# -eq 2 ] || dd_die "usage: devdesk rename OLD NEW" local old=$1 new=$2 running=0 @@ -366,7 +385,7 @@ cmd_attach() { dd_linger return ;; - --peek | --search | --last-reply) + --peek | --search | --last-reply | --unsplit) local action=${1#--} shift "cmd_${action//-/_}" "$@" diff --git a/debian/changelog b/debian/changelog index d2f4b01..3d40a85 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,12 @@ +devdesk (0.10.3) bookworm; urgency=medium + + * No tmux right-click menus (#52). A stray click on one split a pane, or + could kill Claude's window. "Close split panes (keeps Claude)" in the + command palette, backed by `--unsplit`, removes a split that happened + anyway. + + -- Jared Vititoe Tue, 13 Oct 2026 09:00:00 -0400 + devdesk (0.10.2) bookworm; urgency=medium * Selecting text in the terminal works like a normal terminal (#51). A diff --git a/share/tmux.conf b/share/tmux.conf index d861252..2812629 100644 --- a/share/tmux.conf +++ b/share/tmux.conf @@ -8,7 +8,13 @@ set -s set-clipboard on # tmux copy-mode -> OSC 52 -> browser cli set -s default-terminal "tmux-256color" set -s terminal-features[90] "xterm-256color:RGB" # fixed index: re-sourcing must not append set -g history-limit 50000 -set -g mouse on # wheel scrolls history; Shift+drag selects natively +set -g mouse on # wheel scrolls history; a drag selects in the browser (devdesk#51) +# No right-click menus (devdesk#52): one stray click on them splits a pane or +# kills a window (Claude's). Splits and kills stay on the keyboard (^b % ^b x). +unbind -n MouseDown3Pane +unbind -n M-MouseDown3Pane +unbind -n MouseDown3Status +unbind -n MouseDown3StatusLeft set -g allow-passthrough off # output may not bypass tmux (title/clipboard spoofing) set -g renumber-windows on diff --git a/tests/attach.bats b/tests/attach.bats index 54d7e39..2ab8d05 100644 --- a/tests/attach.bats +++ b/tests/attach.bats @@ -1181,3 +1181,17 @@ JSONL [ "$(posts)" -eq 1 ] [ "$(cat "$DEVDESK_STATE_DIR/usage-warned")" = 2000018000 ] } + +@test "unsplit closes extra panes and keeps Claude's (#52)" { + start_server + devdesk new alpha + tmux -S "$DEVDESK_SOCKET" split-window -h -t =alpha:claude + tmux -S "$DEVDESK_SOCKET" split-window -v -t =alpha:shell + run devdesk attach --unsplit alpha + [ "$status" -eq 0 ] + [ "$(jq -r .closed <<<"$output")" = 2 ] + [ "$(tmux -S "$DEVDESK_SOCKET" list-panes -t =alpha:claude -F '#{pane_start_command}')" = "$REPO/libexec/devdesk-claude-run" ] + [ "$(tmux -S "$DEVDESK_SOCKET" list-panes -t =alpha:shell | wc -l)" = 1 ] + run devdesk attach --unsplit alpha + [ "$(jq -r .closed <<<"$output")" = 0 ] +} diff --git a/web/src/main.js b/web/src/main.js index b15ae08..cc975cf 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -750,6 +750,16 @@ async function compact(session) { return undefined; } +// A stray split (devdesk#52): close every extra pane, keeping Claude's. +async function unsplit(session) { + try { + const result = await sessionAction('unsplit', session); + toast.success(result?.closed ? `Closed ${result.closed} pane${result.closed === 1 ? '' : 's'}` : 'No split panes'); + } catch (error) { + toast.error(error.message); + } +} + async function copyLink() { const url = new URL(window.location.href); url.search = state.session ? `?arg=${encodeURIComponent(state.session)}` : ''; @@ -801,6 +811,7 @@ function buildCommands() { add({ id: 'win-shell', group: 'Terminal', icon: '1', label: 'Shell window', kbd: '^b 1', run: () => sendKeys('\x021') }); add({ id: 'scroll', group: 'Terminal', icon: '⇞', label: 'Scroll back (tmux copy mode, q to leave)', kbd: '^b [', keywords: 'history scrollback', run: () => sendKeys('\x02[') }); add({ id: 'detach', group: 'Terminal', icon: '⏏', label: 'Detach (the session keeps running)', kbd: '^b d', run: () => sendKeys('\x02d') }); + add({ id: 'unsplit', group: 'Terminal', icon: '▣', label: 'Close split panes (keeps Claude)', keywords: 'pane split remove side by side layout', run: () => unsplit(state.session) }); } if (state.session) {