fix(shortcuts): Ctrl+W no longer closes a session

Close Session was bound to Ctrl+W by default. Ctrl+W is delete-word in
every shell, readline prompt and agent CLI, so muscle memory killed the
session (its tmux pane and CLI, with no confirm) mid-sentence, and with
the split pane open it was not even the pane being typed in.

Close Session now has no default key: the capture-phase handler lets
Ctrl+W through and xterm sends ^W to whichever pane is focused. The
action stays in the registry and can be bound in App Settings ->
Shortcuts; the shortcut overlay shows it as not bound. The Help modal,
CLAUDE.md, the split and tile-grid specs and three wiki pages stop
advertising Ctrl+W as kill. Owner decision (tile-grid decision 5).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-06 15:46:49 +02:00
1 parent 9e032bdc3e
commit bcdccd14c4
13 files changed
+155 -33

No files matched your search

+1 -1
View File
@@ -793,7 +793,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
### Split-pane sessions
**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is an independent `TerminalTile` (terminal-tile.js, constructed by the orchestration in terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket, whose `cid` is the tab's identity with a `:tile` suffix, so it can never supersede the primary pane's socket (the registry supersedes by cid per session). ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input; `.btn-split` is hard-hidden below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `TerminalTile` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without the explicit `this._forEachTile?.((tile) => tile.fit())` call there Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket writes a `[disconnected, reconnecting…]` marker into Pane B and reconnects on the primary pane's backoff ladder (`CodemanWsReconnect` plus jitter; the attempt count resets only on a successful open), then refreshes the buffer to close the output gap. ⚠️ The open handler clears `_wsClosed`/`_markerOwed` BEFORE that refresh, or the refresh re-owes the marker and stamps it under a healthy pane. ⚠️ 4003/4004/4009/4010 stop the pane for good, report once through `onExit(code)` and write a marker saying why (`CodemanWsReconnect` alone would retry 4003). ⚠️ A replacement socket detaches the old one first and every handler ignores a socket that is no longer `this.ws`, so a late 4010 from a superseded socket cannot stop its successor; `destroy()` cancels a pending reconnect. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter newline, smart-copy Ctrl+C) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+Z is swallowed unless Pane B's own session is `mode === 'shell'`, mirroring terminal-ui.js's reasoning: in a plain shell it is the user's own job-control tool, everywhere else it silently suspends an unattended agent loop. Shift/Ctrl+Enter POSTs to `/api/sessions/:id/send-key` (`{key:'S-Enter'|'C-Enter'}`, typed by tmux `send-keys -H`: Ctrl+Enter is always a real 0x0a, Shift+Enter is the CLI's declared `capabilities.newline` chord, also 0x0a unless the CLI declares another; sent on keydown only, with the keypress and keyup swallowed too, see the keypress rule under Command palette and shortcut registry) targeting THIS pane's own `sessionId` rather than the primary pane's `activeSessionId` — without it xtermLine truncated
**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is an independent `TerminalTile` (terminal-tile.js, constructed by the orchestration in terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket, whose `cid` is the tab's identity with a `:tile` suffix, so it can never supersede the primary pane's socket (the registry supersedes by cid per session). ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input; `.btn-split` is hard-hidden below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `TerminalTile` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without the explicit `this._forEachTile?.((tile) => tile.fit())` call there Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket writes a `[disconnected, reconnecting…]` marker into Pane B and reconnects on the primary pane's backoff ladder (`CodemanWsReconnect` plus jitter; the attempt count resets only on a successful open), then refreshes the buffer to close the output gap. ⚠️ The open handler clears `_wsClosed`/`_markerOwed` BEFORE that refresh, or the refresh re-owes the marker and stamps it under a healthy pane. ⚠️ 4003/4004/4009/4010 stop the pane for good, report once through `onExit(code)` and write a marker saying why (`CodemanWsReconnect` alone would retry 4003). ⚠️ A replacement socket detaches the old one first and every handler ignores a socket that is no longer `this.ws`, so a late 4010 from a superseded socket cannot stop its successor; `destroy()` cancels a pending reconnect. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter newline, smart-copy Ctrl+C) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+Z is swallowed unless Pane B's own session is `mode === 'shell'`, mirroring terminal-ui.js's reasoning: in a plain shell it is the user's own job-control tool, everywhere else it silently suspends an unattended agent loop. Shift/Ctrl+Enter POSTs to `/api/sessions/:id/send-key` (`{key:'S-Enter'|'C-Enter'}`, typed by tmux `send-keys -H`: Ctrl+Enter is always a real 0x0a, Shift+Enter is the CLI's declared `capabilities.newline` chord, also 0x0a unless the CLI declares another; sent on keydown only, with the keypress and keyup swallowed too, see the keypress rule under Command palette and shortcut registry) targeting THIS pane's own `sessionId` rather than the primary pane's `activeSessionId` — without it xtermLine truncated
### Gesture control: the setting
+2 -1
View File
@@ -8,7 +8,8 @@
> (`terminal-tile.js`) and is no longer as plain as this spec describes: it
> reconnects after a drop, delivers input exactly once, has clickable paths
> and image paste, sizes its PTY without a floor and adopts `zc` columns, and
> the app-level terminal shortcuts follow the focused pane (Ctrl+W excepted).
> the app-level terminal shortcuts follow the focused pane. Ctrl+W no longer
> closes anything (Close Session has no default key).
> See `docs/tile-grid-plan.md` and `architecture-invariants#split-pane-sessions`.
## Problem
+16 -19
View File
@@ -26,7 +26,7 @@ small header: status dot, session name, a `⋯` menu, maximize, `+` and `×`.
- Every tile is equal: same terminal class, same features, same header. One
tile is **focused** and receives the keyboard.
- The rest of the app follows the focused tile: files panel, git status,
respawn and Ralph panels, subagent windows, voice, image paste, Ctrl+W.
respawn and Ralph panels, subagent windows, voice, image paste.
- Tiles survive a Codeman restart (reconnect) and a page reload (per-device
persistence).
- The split pane stays as it is for now (decided). The grid is a separate
@@ -190,9 +190,8 @@ animation frame, and sends one resize per affected tile at pointer-up.
in the normal single view; the grid is remembered and one click on Tiles
brings it back (decision 1). An app-driven selection (`auto: true`)
never collapses the grid; see "Selections while the grid is open".
- Ctrl+L clears the focused tile; Ctrl+W closes the focused session (killing
it without a confirm, exactly as in the single view, where the focused tile
is the active session); Ctrl +/-
- Ctrl+L clears the focused tile; Ctrl+W is delete-word in the focused tile
(it is not an app shortcut, decision 5); Ctrl +/-
changes the tile font size; Ctrl+Shift+R restores the focused tile's size.
### Persistence
@@ -461,7 +460,7 @@ the grid. Each one gets an explicit grid-aware behavior:
| Path | Today | With the grid open |
|---|---|---|
| Ctrl+W / Close session on the focused tile (`closeSession`) | Reads `wasActive` before its `await`, adds the id to `_closingSessions`, then selects the first remaining `sessionOrder` entry with `auto: true`, which is often NOT tiled | A grid-aware fallback picker: remove the tile, then focus the neighboring tile (next in grid order, else previous). Only with no tiles left does it fall back to the `sessionOrder` pick, which closes the grid. Note the split's `_onSessionDeleted` wrapper deliberately skips selection for ids in `_closingSessions`, so the fallback MUST live in `closeSession` itself, not in the delete wrapper. |
| Close session on the focused tile (`closeSession`, from its menu or a user-bound key) | Reads `wasActive` before its `await`, adds the id to `_closingSessions`, then selects the first remaining `sessionOrder` entry with `auto: true`, which is often NOT tiled | A grid-aware fallback picker: remove the tile, then focus the neighboring tile (next in grid order, else previous). Only with no tiles left does it fall back to the `sessionOrder` pick, which closes the grid. Note the split's `_onSessionDeleted` wrapper deliberately skips selection for ids in `_closingSessions`, so the fallback MUST live in `closeSession` itself, not in the delete wrapper. |
| Session deleted elsewhere (`_onSessionDeleted`) | The handoff selects the first remaining `sessionOrder` entry | If it was tiled: remove the tile and focus a neighbor with `auto: true`. If it was not tiled it was not active, so there is no handoff. |
| Boot restore (`handleInit`) | `selectSession(restoreId, { auto: true })` | Replaced by the grid restore when a stored grid is open (see "Persistence") |
| URL `#session=<id>` link | `selectSession(id, { auto: true })` | Following a link is navigation, so this path passes `leaveTiles: true`: a tiled id focuses its tile, a non-tiled id opens the single view (grid kept in storage) |
@@ -493,7 +492,7 @@ sound/title/desktop notification, so nothing is silently swallowed.
| Copy | `copyTerminalSelection` / `cleanedTerminalSelection` read `this.terminal`; Pane B re-implements them | Both take `(terminal, sessionId)`; Pane B's copy is deleted |
| Image paste | `_handleImagePaste()` uses the main terminal; `_uploadAndInsertImages` inserts with `sendInput()`, which re-reads `activeSessionId` AFTER the upload (an existing bug: switch tabs mid-upload and the paths land in the wrong session) | `_handleImagePaste({ terminal, sessionId })`; insert with `_sendInputAsync(sessionId, paths, { useMux: true })` |
| Voice | `_insertText` re-reads `app.activeSessionId` at insert time and appends to the main local-echo overlay | Capture the target in `start()`; send with `_sendInputAsync(target, …)`; skip the overlay when the target is not the main terminal |
| Shortcuts | Ctrl+L (`clearTerminal`) and Ctrl+Shift+R (`restoreTerminalSize`) act on `this.terminal` | Resolve through `_focusedPane()` returning `{ terminal, sessionId, isPrimary }`; Ctrl+W already takes an id |
| Shortcuts | Ctrl+L (`clearTerminal`) and Ctrl+Shift+R (`restoreTerminalSize`) act on `this.terminal` | Resolve through `_focusedPane()` returning `{ terminal, sessionId, isPrimary }`; Close Session (no default key) already takes an id |
| Font, family, weight, skin | `setFontSize` / `setFontFamily` / `setFontWeight` / `applyTerminalSkin` special-case `this._splitPane` | Loop over all tiles |
### 7. Fonts
@@ -551,7 +550,7 @@ and share one tile class:
| Situation | Behavior |
|---|---|
| A tiled session is deleted (here or elsewhere) | Tile removed; a neighbor gets focus with `auto: true`; the last tile gone falls through to the normal handoff |
| Ctrl+W on the focused tile | The grid stays open and the neighboring tile takes focus (grid-aware fallback in `closeSession`, see "Selections while the grid is open") |
| Closing the focused tile's session | The grid stays open and the neighboring tile takes focus (grid-aware fallback in `closeSession`, see "Selections while the grid is open") |
| A tiled session is popped out to its own window | Tile removed: that window now owns the PTY size |
| Session exited or not attached (`pid === null`, `paneExit`) | The tile body shows "Not attached" with an Attach button: `POST /interactive` (or `/shell` for shell mode) with NO body, at most one in flight per session (the route has no in-flight guard of its own). A tripped PTY-exit breaker goes through the existing confirm before `clearBreaker: true`; no automatic path ever sends it |
| A web tab is opened | Grid hidden by CSS; sockets stay up; hidden tiles send no resizes. Selecting a tiled session's tab brings the grid back |
@@ -651,11 +650,8 @@ Commits:
spec documented and accepted for its v1 ("Ctrl+L or Ctrl+W typed while Pane
B has focus clears or closes Pane A"). Typing into Pane B now clears its
idle alert through `_ackDelivery`, which it never did.
**Ctrl+W is deliberately NOT retargeted in PR 1** (open decision 5):
`killActiveSession` calls `closeSession(id)` with `killMux = true` and no
confirm dialog, so moving it to Pane B changes which agent a muscle-memory
Ctrl+W kills outright. It keeps closing Pane A's session unless the
decision says otherwise.
**Ctrl+W no longer closes anything** (decision 5): Close Session has no
default key, so Ctrl+W reaches the focused pane as delete-word.
4. **Docs.** Update the split-pane paragraph in CLAUDE.md and
`docs/architecture-invariants.md#split-pane-sessions` (Pane B now
reconnects, delivers input exactly once, has links and image paste, and
@@ -680,7 +676,9 @@ PR 1 tests (gate):
redelivery per socket, POST fallback when no socket is registered.
- `test/focused-pane-shortcuts.test.ts`: with Pane B focused, Ctrl+L clears
Pane B, Ctrl+Shift+R restores Pane B's size, voice and image paste target
Pane B's session, and Ctrl+W still targets Pane A's session (decision 5);
Pane B's session, and a user-bound Close Session still targets the active
session; `test/ctrl-w-never-closes.test.ts` pins that no default shortcut
answers Ctrl+W;
with Pane A focused nothing changes.
- Geometry: with the split divider at its 20% clamp, Pane B's xterm and the
size it sends are both under 40 columns and equal (no floor regression).
@@ -737,7 +735,7 @@ Commits:
`_cleanupPreviousSession`; acknowledgement only when user-initiated; an
`auto: true` selection of a non-tiled session leaves the grid open; a
user-initiated one closes it; `leaveTiles: true` closes it.
- `test/tile-grid-close-fallback.test.ts`: Ctrl+W (`closeSession`) on the
- `test/tile-grid-close-fallback.test.ts`: closing (`closeSession`) the
focused tile keeps the grid open and focuses the neighboring tile, even when
the first `sessionOrder` entry is not tiled; closing the last tile falls back
to the normal pick.
@@ -829,11 +827,10 @@ exits green. Use the browser runner for those files and read the file count.
3. **Persistence.** Decided: per device, restored on reload.
4. **PR shape.** Decided: two PRs. PR 1 is the tile foundation (the split
improves on its own), PR 2 is the grid.
5. **Ctrl+W in the split while Pane B has focus.** Open, default applied:
keep it closing Pane A's session (today's behavior), because Ctrl+W kills
without a confirm. Alternative: retarget it to the focused pane like the
other shortcuts. In the grid it always follows focus, since the focused tile
IS the active session there.
5. **Ctrl+W.** Decided: it never closes a session. Close Session has no
default key (Ctrl+W is delete-word in every shell and agent CLI, and it
killed sessions with no confirm); it stays bindable in App Settings →
Shortcuts.
## Code anchors
+4 -1
View File
@@ -9,13 +9,16 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
| Shortcut | Action |
| ------------------------------- | --------------------------------------------------------------- |
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. |
| `Ctrl+W` | Kill the active session. |
| `Ctrl+Tab` | Next session. |
| `Alt+[` / `Alt+]` | Previous / next tab. |
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. |
| `Alt+B` | Collapse / expand the session sidebar, when that layout is on. |
`Ctrl+W` is not a Codeman shortcut: it goes to the terminal, where shells and agent CLIs
use it to delete the previous word. **Close Session** has no key by default; close a session
from its tab, or bind a key to it in App Settings → Shortcuts.
## Terminal
| Shortcut | Action |
+1 -1
View File
@@ -131,7 +131,7 @@ the tmux server or rebooting the machine.
| To do this | Do that |
| ------------------------- | ------------------------------------------------------------------- |
| Interrupt the current turn | `Ctrl+C` with nothing selected, or the **Stop** button. |
| Close one session | `Ctrl+W`, or the tab's close control. |
| Close one session | The tab's close control (`Ctrl+W` is delete-word in the terminal). |
| Stop the server, keep agents | `codeman web --stop`. The tmux sessions stay alive. |
| Stop everything | `tmux -L codeman kill-server`. |
+1 -1
View File
@@ -64,7 +64,7 @@ reloading while a permission prompt is blocking does not lose the red tab.
| Jump to tab N | `Alt+1` to `Alt+9` (the number on the tab) |
| Next / previous | `Ctrl+Tab`, `Alt+[`, `Alt+]` |
| Move the active tab | `Ctrl+Shift+{`, `Ctrl+Shift+}` |
| Close | `Ctrl+W` |
| Close | The tab's close control (no key by default) |
| Find any session, open or past | `Ctrl+K` (also `Cmd+K` and `Alt+K`) |
Tabs can also be dragged to reorder.