Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab7e127f70 | ||
|
|
24d71131e8 |
@@ -71,7 +71,9 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
|
||||
| Ctrl-b 0 / 1 | Claude window / shell window |
|
||||
| Ctrl-b s | Switch session (the URL follows) |
|
||||
| Ctrl-b d | Detach (the session keeps running) |
|
||||
| Shift+drag | Native browser selection (plain drag goes to tmux copy-mode, which copies via OSC 52) |
|
||||
| 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) |
|
||||
|
||||
+21
-2
@@ -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//-/_}" "$@"
|
||||
|
||||
Vendored
+19
@@ -1,3 +1,22 @@
|
||||
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 <jjvititoe1@gmail.com> 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
|
||||
drag is a browser selection that stays highlighted and is copied when
|
||||
you let go, with a short toast. Ctrl+C copies the selection and
|
||||
interrupts only when nothing is selected. The wheel still scrolls tmux
|
||||
history, and the tmux status line still takes clicks.
|
||||
|
||||
-- Jared Vititoe <jjvititoe1@gmail.com> Mon, 12 Oct 2026 16:36:00 -0400
|
||||
|
||||
devdesk (0.10.1) bookworm; urgency=medium
|
||||
|
||||
* Everything in 0.10.0, which was never published: its CI test for the
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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 ]
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
<span class="lt-footer-sep">│</span>
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">^b d</span>detach</span>
|
||||
<span class="lt-footer-sep">│</span>
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">⇧drag</span>select</span>
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">drag</span>copy</span>
|
||||
<span class="lt-footer-sep">│</span>
|
||||
<span class="lt-footer-hint"><span class="lt-footer-key">drop / paste</span>attach files</span>
|
||||
<span class="lt-footer-sep">│</span>
|
||||
|
||||
@@ -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) {
|
||||
@@ -876,6 +887,8 @@ const KEY_HELP = [
|
||||
['Ctrl+⇧ E', 'Files: browse, preview, diff, edit (Esc closes)'],
|
||||
['Ctrl+P', 'Files: go to file'],
|
||||
['Ctrl+S', 'Files: save the open file'],
|
||||
['Drag', 'Select text; it is copied when you let go'],
|
||||
['Ctrl+C', 'Copy the selection (interrupts only when nothing is selected)'],
|
||||
['Ctrl+⇧ C / V', 'Copy selection / paste'],
|
||||
['⇧ drag', 'Select text in the browser'],
|
||||
['Drop or paste a file', 'Upload it and attach it for Claude'],
|
||||
@@ -1199,6 +1212,7 @@ async function boot() {
|
||||
fontSize,
|
||||
theme: getTheme(),
|
||||
onShiftEnter: () => state.conn?.sendInput('\n'),
|
||||
onCopied: (text) => toast.success(`Copied ${text.length.toLocaleString()} character${text.length === 1 ? '' : 's'}`, 1500),
|
||||
}));
|
||||
onThemeChange((theme) => {
|
||||
term.options.theme = THEMES[theme];
|
||||
|
||||
+43
-2
@@ -90,7 +90,7 @@ export async function loadFonts(size) {
|
||||
}
|
||||
}
|
||||
|
||||
export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnter }) {
|
||||
export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnter, onCopied }) {
|
||||
const term = new Terminal({
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize,
|
||||
@@ -113,16 +113,28 @@ export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnt
|
||||
window.open(uri, '_blank', 'noopener');
|
||||
}));
|
||||
|
||||
const copySelection = () => {
|
||||
const text = term.getSelection();
|
||||
navigator.clipboard?.writeText(text).then(() => onCopied?.(text), () => {});
|
||||
};
|
||||
|
||||
term.attachCustomKeyEventHandler((event) => {
|
||||
// Shift+Enter: newline in Claude's prompt (Ctrl+J), not submit.
|
||||
if (event.key === 'Enter' && event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
||||
if (event.type === 'keydown') onShiftEnter();
|
||||
return false;
|
||||
}
|
||||
// Ctrl+C with text selected copies it instead of interrupting (devdesk#51).
|
||||
if (event.type === 'keydown' && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey
|
||||
&& event.code === 'KeyC' && term.hasSelection()) {
|
||||
copySelection();
|
||||
term.clearSelection();
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Shift+C / Ctrl+Shift+V: browser clipboard, like desktop terminals.
|
||||
if (event.type === 'keydown' && event.ctrlKey && event.shiftKey && !event.altKey) {
|
||||
if (event.code === 'KeyC' && term.hasSelection()) {
|
||||
navigator.clipboard?.writeText(term.getSelection()).catch(() => {});
|
||||
copySelection();
|
||||
return false;
|
||||
}
|
||||
if (event.code === 'KeyV') {
|
||||
@@ -135,6 +147,35 @@ export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnt
|
||||
|
||||
term.open(container);
|
||||
|
||||
// devdesk#51: tmux runs with the mouse on (wheel scrolls its history), so a
|
||||
// drag would select inside tmux, which copies and then drops the highlight on
|
||||
// release. A left-button drag in the terminal is made a browser selection
|
||||
// instead (what Shift+drag does): it stays highlighted and is copied on
|
||||
// release. Clicks on tmux's status line (the window tabs) still go to tmux.
|
||||
const screen = () => container.querySelector('.xterm-screen');
|
||||
container.addEventListener('mousedown', (event) => {
|
||||
if (event.button !== 0 || event.shiftKey || event.ddForced || term.modes.mouseTrackingMode === 'none') return;
|
||||
const rect = screen()?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const row = Math.floor(((event.clientY - rect.top) / rect.height) * term.rows);
|
||||
if (row >= term.rows - 1) return; // status line
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
const forced = new MouseEvent('mousedown', {
|
||||
bubbles: true, cancelable: true, view: window, detail: event.detail, button: 0, buttons: event.buttons,
|
||||
clientX: event.clientX, clientY: event.clientY, screenX: event.screenX, screenY: event.screenY,
|
||||
shiftKey: true, ctrlKey: event.ctrlKey, altKey: event.altKey, metaKey: event.metaKey,
|
||||
});
|
||||
forced.ddForced = true;
|
||||
event.target.dispatchEvent(forced);
|
||||
}, true);
|
||||
// Copy when the drag (or double/triple click) ends with something selected.
|
||||
container.addEventListener('mouseup', () => {
|
||||
setTimeout(() => {
|
||||
if (term.hasSelection() && term.getSelection().trim()) copySelection();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Touch devices: the DOM renderer is more reliable at high DPR than WebGL.
|
||||
if (window.matchMedia?.('(pointer: coarse)').matches) return { term, fit };
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user